From 83d682c8f3f2976c00c57b05bab89860c97a60e6 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 22 May 2026 14:00:37 +0200 Subject: [PATCH 01/34] Adding a new voting mechanism for resharing --- crates/contract/src/dto_mapping.rs | 2 + crates/contract/src/errors.rs | 4 + crates/contract/src/primitives/domain.rs | 132 +- .../src/primitives/threshold_votes.rs | 41 + crates/contract/src/primitives/thresholds.rs | 48 +- ...contract_borsh_schema_has_not_changed.snap | 16 +- crates/contract/src/state/resharing.rs | 88 +- crates/contract/src/state/running.rs | 100 +- crates/contract/src/v3_10_state.rs | 125 +- .../abi__abi_has_not_changed.snap.new | 4172 +++++++++++++++++ crates/devnet/src/cli.rs | 15 + crates/devnet/src/mpc.rs | 12 + crates/e2e-tests/src/cluster.rs | 2 + .../src/types/state.rs | 34 + crates/node/src/indexer/participants.rs | 3 + 15 files changed, 4772 insertions(+), 22 deletions(-) create mode 100644 crates/contract/tests/snapshots/abi__abi_has_not_changed.snap.new diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index dd8ad67f07..870c5af6c3 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -215,6 +215,7 @@ impl IntoContractType for dtos::Participants { impl IntoContractType for dtos::ThresholdParameters { fn into_contract_type(self) -> ThresholdParameters { ThresholdParameters::new_unvalidated(self.participants.into_contract_type(), self.threshold) + .with_per_domain_thresholds(self.per_domain_thresholds) } } @@ -725,6 +726,7 @@ impl IntoInterfaceType for &ThresholdParameters { dtos::ThresholdParameters { participants: self.participants().into_dto_type(), threshold: self.threshold(), + per_domain_thresholds: self.per_domain_thresholds().clone(), } } } diff --git a/crates/contract/src/errors.rs b/crates/contract/src/errors.rs index 0774bb60cc..45bdd28781 100644 --- a/crates/contract/src/errors.rs +++ b/crates/contract/src/errors.rs @@ -220,6 +220,10 @@ pub enum DomainError { "Reconstruction threshold {threshold} overflowed when computing the DamgardEtAl bound." )] ReconstructionThresholdOverflow { threshold: u64 }, + #[error( + "Resharing proposal references domain ID {domain_id}, which is not in the current registry." + )] + UnknownDomainInProposal { domain_id: DomainId }, } /// A list specifying general categories of MPC Contract errors. diff --git a/crates/contract/src/primitives/domain.rs b/crates/contract/src/primitives/domain.rs index 293daca871..a0f9e6db89 100644 --- a/crates/contract/src/primitives/domain.rs +++ b/crates/contract/src/primitives/domain.rs @@ -1,7 +1,9 @@ use super::key_state::AuthenticatedParticipantId; use crate::errors::{DomainError, Error}; use crate::primitives::participants::Participants; -use near_mpc_contract_interface::types::{Curve, DomainConfig, DomainId, DomainPurpose, Protocol}; +use near_mpc_contract_interface::types::{ + Curve, DomainConfig, DomainId, DomainPurpose, Protocol, ReconstructionThreshold, +}; use near_sdk::{log, near}; use std::collections::BTreeMap; @@ -177,6 +179,41 @@ impl DomainRegistry { pub fn next_domain_id(&self) -> u64 { self.next_domain_id } + + /// Returns a new registry whose domains have their + /// `reconstruction_threshold` rewritten from `overlay`. Domain IDs in + /// `overlay` that are not present in the registry are rejected with + /// [`DomainError::UnknownDomainInProposal`]. Domains absent from + /// `overlay` retain their existing threshold. An empty overlay returns a + /// structurally identical clone. + pub fn with_overlaid_thresholds( + &self, + overlay: &BTreeMap, + ) -> Result { + for id in overlay.keys() { + if !self.domains.iter().any(|d| d.id == *id) { + return Err(DomainError::UnknownDomainInProposal { domain_id: *id }.into()); + } + } + let domains = self + .domains + .iter() + .map(|d| { + let reconstruction_threshold = overlay + .get(&d.id) + .copied() + .unwrap_or(d.reconstruction_threshold); + DomainConfig { + reconstruction_threshold, + ..d.clone() + } + }) + .collect(); + Ok(DomainRegistry { + domains, + next_domain_id: self.next_domain_id, + }) + } } /// Tracks votes to add domains. Each participant can at any given time vote for a list of domains @@ -601,4 +638,97 @@ pub mod tests { assert_eq!(remaining.proposal_by_account[&auth_ids[0]], proposal_a); assert_eq!(remaining.proposal_by_account[&auth_ids[1]], proposal_b); } + + #[expect(non_snake_case)] + mod with_overlaid_thresholds { + use super::*; + use std::collections::BTreeMap; + + fn registry_of(domains: Vec) -> DomainRegistry { + let next_domain_id = domains.iter().map(|d| d.id.0).max().map_or(0, |m| m + 1); + DomainRegistry::from_raw_validated(domains, next_domain_id).unwrap() + } + + #[test] + fn with_overlaid_thresholds__should_be_identity_when_overlay_is_empty() { + // Given a non-empty registry and an empty overlay + let registry = registry_of(vec![ + DomainConfig { + id: DomainId(0), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(3), + purpose: DomainPurpose::Sign, + }, + DomainConfig { + id: DomainId(1), + protocol: Protocol::Frost, + reconstruction_threshold: ReconstructionThreshold::new(2), + purpose: DomainPurpose::Sign, + }, + ]); + let overlay = BTreeMap::new(); + + // When applying the overlay + let result = registry.with_overlaid_thresholds(&overlay).unwrap(); + + // Then the registry is structurally identical + assert_eq!(result, registry); + } + + #[test] + fn with_overlaid_thresholds__should_apply_per_domain_updates() { + // Given a registry with two domains and an overlay targeting one + let registry = registry_of(vec![ + DomainConfig { + id: DomainId(0), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(3), + purpose: DomainPurpose::Sign, + }, + DomainConfig { + id: DomainId(1), + protocol: Protocol::Frost, + reconstruction_threshold: ReconstructionThreshold::new(2), + purpose: DomainPurpose::Sign, + }, + ]); + let mut overlay = BTreeMap::new(); + overlay.insert(DomainId(0), ReconstructionThreshold::new(5)); + + // When applying the overlay + let result = registry.with_overlaid_thresholds(&overlay).unwrap(); + + // Then only the targeted domain's threshold changes + assert_eq!( + result.domains()[0].reconstruction_threshold, + ReconstructionThreshold::new(5) + ); + assert_eq!( + result.domains()[1].reconstruction_threshold, + ReconstructionThreshold::new(2) + ); + } + + #[test] + fn with_overlaid_thresholds__should_reject_unknown_domain_id() { + // Given a registry with one domain and an overlay referencing a different ID + let registry = registry_of(vec![DomainConfig { + id: DomainId(0), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(3), + purpose: DomainPurpose::Sign, + }]); + let mut overlay = BTreeMap::new(); + overlay.insert(DomainId(42), ReconstructionThreshold::new(5)); + + // When applying the overlay + let err = registry.with_overlaid_thresholds(&overlay).unwrap_err(); + + // Then unknown-domain guard rejects + assert!( + err.to_string().contains("not in the current registry"), + "Expected UnknownDomainInProposal, got: {err}" + ); + } + } } diff --git a/crates/contract/src/primitives/threshold_votes.rs b/crates/contract/src/primitives/threshold_votes.rs index d5b8e4748d..a56ed562a7 100644 --- a/crates/contract/src/primitives/threshold_votes.rs +++ b/crates/contract/src/primitives/threshold_votes.rs @@ -64,7 +64,9 @@ mod tests { participants::Participants, test_utils::{gen_participant, gen_threshold_params}, }; + use near_mpc_contract_interface::types::{DomainId, ReconstructionThreshold}; use near_sdk::{test_utils::VMContextBuilder, testing_env}; + use std::collections::BTreeMap; #[test] fn test_voting_and_removal() { @@ -100,6 +102,45 @@ mod tests { assert_eq!(votes.n_votes(¶ms, &participants), 0); } + #[test] + #[expect(non_snake_case)] + fn vote__should_tally_distinct_per_domain_overlays_separately() { + // Given two voters and two proposals identical except for per_domain_thresholds + let mut participants = Participants::default(); + let (p0, p1) = (gen_participant(0), gen_participant(1)); + participants.insert(p0.0.clone(), p0.1).unwrap(); + participants.insert(p1.0.clone(), p1.1).unwrap(); + + let mut ctx = VMContextBuilder::new(); + let auth_p0 = { + ctx.signer_account_id(p0.0); + testing_env!(ctx.build()); + AuthenticatedAccountId::new(&participants).unwrap() + }; + let auth_p1 = { + ctx.signer_account_id(p1.0); + testing_env!(ctx.build()); + AuthenticatedAccountId::new(&participants).unwrap() + }; + + let base = gen_threshold_params(30); + let mut overlay_a = BTreeMap::new(); + overlay_a.insert(DomainId(0), ReconstructionThreshold::new(2)); + let mut overlay_b = BTreeMap::new(); + overlay_b.insert(DomainId(0), ReconstructionThreshold::new(3)); + let proposal_a = base.clone().with_per_domain_thresholds(overlay_a); + let proposal_b = base.with_per_domain_thresholds(overlay_b); + + // When each voter casts a different overlay + let mut votes = ThresholdParametersVotes::default(); + votes.vote(&proposal_a, auth_p0); + votes.vote(&proposal_b, auth_p1); + + // Then the two proposals are tallied independently + assert_eq!(votes.n_votes(&proposal_a, &participants), 1); + assert_eq!(votes.n_votes(&proposal_b, &participants), 1); + } + #[test] fn test_non_participant_votes_not_counted() { // given: two participants vote for a proposal diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index a7e59cca00..bbe8772a1d 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -1,6 +1,7 @@ use super::participants::{ParticipantId, ParticipantInfo, Participants}; use crate::errors::{Error, InvalidCandidateSet, InvalidThreshold}; use near_account_id::AccountId; +use near_mpc_contract_interface::types::{DomainId, ReconstructionThreshold}; use near_sdk::near; use std::collections::BTreeMap; @@ -12,26 +13,64 @@ const MIN_THRESHOLD_ABSOLUTE: u64 = 2; /// Stores information about the threshold key parameters: /// - owners of key shares /// - cryptographic threshold +/// - per-domain reconstruction thresholds (only populated when this struct +/// carries a resharing proposal; empty otherwise) #[near(serializers=[borsh, json])] #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] pub struct ThresholdParameters { participants: Participants, threshold: Threshold, + /// Proposed per-domain `ReconstructionThreshold` updates for this + /// resharing. Empty map means "keep current per-domain thresholds"; + /// populated map must cover every existing domain (validated in + /// [`super::super::state::running::RunningContractState::process_new_parameters_proposal`]). + /// `skip_serializing_if`+`default` preserve the legacy `state()` wire + /// shape when no resharing is in flight. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + per_domain_thresholds: BTreeMap, } impl ThresholdParameters { /// Constructs Threshold parameters from `participants` and `threshold` if the - /// threshold meets the absolute and relative validation criteria. + /// threshold meets the absolute and relative validation criteria. The + /// `per_domain_thresholds` map is initialized empty — populate it on + /// resharing proposals via [`Self::with_per_domain_thresholds`]. pub fn new(participants: Participants, threshold: Threshold) -> Result { match Self::validate_threshold(participants.len() as u64, threshold) { Ok(_) => Ok(ThresholdParameters { participants, threshold, + per_domain_thresholds: BTreeMap::new(), }), Err(err) => Err(err), } } + /// Builder-style helper: attach a per-domain threshold overlay. Used by + /// resharing proposal callers; non-proposal sites leave the map empty. + pub fn with_per_domain_thresholds( + mut self, + per_domain_thresholds: BTreeMap, + ) -> Self { + self.per_domain_thresholds = per_domain_thresholds; + self + } + + /// Returns the per-domain threshold overlay. Empty in non-proposal + /// contexts and on stored running-state parameters (after resharing + /// completes the overlay is consumed into the + /// [`super::domain::DomainRegistry`]). + pub fn per_domain_thresholds(&self) -> &BTreeMap { + &self.per_domain_thresholds + } + + /// 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(); + } + /// Ensures that the threshold `k` is sensible and meets the absolute and minimum requirements. /// That is: /// - threshold must be at least `MIN_THRESHOLD_ABSOLUTE` @@ -147,6 +186,7 @@ impl ThresholdParameters { ThresholdParameters { participants, threshold, + per_domain_thresholds: BTreeMap::new(), } } @@ -395,10 +435,8 @@ mod tests { .insert_with_id(account_id, participant_info, ParticipantId(wrong_id)) .unwrap(); - let tampered_params = ThresholdParameters { - participants: tampered_participants, - threshold: params.threshold, - }; + let tampered_params = + ThresholdParameters::new_unvalidated(tampered_participants, params.threshold); assert_eq!( params diff --git a/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap b/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap index 96bea24606..01d798c3d6 100644 --- a/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap +++ b/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap @@ -1,6 +1,5 @@ --- source: crates/contract/src/lib.rs -assertion_line: 5683 expression: schema --- BorshSchemaContainer { @@ -46,6 +45,12 @@ BorshSchemaContainer { "Vec", ], }, + "(DomainId, ReconstructionThreshold)": Tuple { + elements: [ + "DomainId", + "ReconstructionThreshold", + ], + }, "(ForeignChain, BTreeMap)": Tuple { elements: [ "ForeignChain", @@ -251,6 +256,11 @@ BorshSchemaContainer { length_range: 0..=4294967295, elements: "(AuthenticatedParticipantId, Vec)", }, + "BTreeMap": Sequence { + length_width: 4, + length_range: 0..=4294967295, + elements: "(DomainId, ReconstructionThreshold)", + }, "BTreeMap>": Sequence { length_width: 4, length_range: 0..=4294967295, @@ -1434,6 +1444,10 @@ BorshSchemaContainer { "threshold", "Threshold", ), + ( + "per_domain_thresholds", + "BTreeMap", + ), ], ), }, diff --git a/crates/contract/src/state/resharing.rs b/crates/contract/src/state/resharing.rs index 0bb1fcc70c..feadb972e1 100644 --- a/crates/contract/src/state/resharing.rs +++ b/crates/contract/src/state/resharing.rs @@ -138,10 +138,17 @@ impl ResharingContractState { self.resharing_key.proposed_parameters().clone(), ); } else { + let proposed = self.resharing_key.proposed_parameters(); + let new_domains = self + .previous_running_state + .domains + .with_overlaid_thresholds(proposed.per_domain_thresholds())?; + let mut new_parameters = proposed.clone(); + new_parameters.clear_per_domain_thresholds(); return Ok(Some(RunningContractState::new( - self.previous_running_state.domains.clone(), + new_domains, Keyset::new(self.prospective_epoch_id(), self.reshared_keys.clone()), - self.resharing_key.proposed_parameters().clone(), + new_parameters, self.previous_running_state.add_domains_votes.clone(), ))); } @@ -465,4 +472,81 @@ pub mod tests { .vote_new_parameters(new_state.prospective_epoch_id().next(), &new_params_2) .unwrap_err(); } + + #[expect(non_snake_case)] + mod per_domain_threshold_overlay { + use super::*; + use near_mpc_contract_interface::types::ReconstructionThreshold; + use std::collections::BTreeMap; + + /// On successful resharing transition, the proposal's + /// `per_domain_thresholds` overlay must be applied to the new + /// `DomainRegistry`, and the running-state `parameters` overlay + /// must be cleared. + #[test] + fn vote_reshared__final_transition__should_apply_overlay_to_registry() { + // Given a resharing state whose proposal carries a per-domain overlay + // setting domain 0's threshold to a new value distinct from the + // existing one. + let (mut env, mut state) = gen_resharing_state(1); + let original_threshold = + state.previous_running_state.domains.domains()[0].reconstruction_threshold; + // Pick a new value that is achievable for the proposed participant + // count: the current proposal's threshold is a safe lower-bound. + let new_value = state + .resharing_key + .proposed_parameters() + .threshold() + .value(); + assert!(new_value >= 2); + let new_threshold = ReconstructionThreshold::new(new_value); + assert_ne!(new_threshold, original_threshold); + let domain_id = state.previous_running_state.domains.domains()[0].id; + let mut overlay = BTreeMap::new(); + overlay.insert(domain_id, new_threshold); + let mut proposal_with_overlay = state.resharing_key.proposed_parameters().clone(); + proposal_with_overlay = + proposal_with_overlay.with_per_domain_thresholds(overlay.clone()); + state.resharing_key = crate::state::key_event::KeyEvent::new( + state.prospective_epoch_id(), + state.previous_running_state.domains.domains()[0].clone(), + proposal_with_overlay, + ); + + // When all candidates vote-reshared for the (single) domain + let leader = find_leader(&state.resharing_key); + env.set_signer(&leader.0); + let key_event_id = KeyEventId { + attempt_id: AttemptId::new(), + domain_id, + epoch_id: state.prospective_epoch_id(), + }; + state.start(key_event_id, 0).unwrap(); + let mut new_running = None; + let candidates: Vec<_> = state + .resharing_key + .proposed_parameters() + .participants() + .participants() + .iter() + .map(|(acc, _, _)| acc.clone()) + .collect(); + for account in &candidates { + env.set_signer(account); + new_running = state.vote_reshared(key_event_id).unwrap(); + } + + // Then the new running state's registry carries the overlay's + // threshold and its parameters overlay is cleared. + let new_running = new_running.expect("resharing should have transitioned to Running"); + assert_eq!( + new_running.domains.domains()[0].reconstruction_threshold, + new_threshold, + ); + assert!( + new_running.parameters.per_domain_thresholds().is_empty(), + "stored parameters.per_domain_thresholds should be cleared after applying overlay" + ); + } + } } diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index ff2ee73ca3..2d0e696fa4 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -137,14 +137,28 @@ impl RunningContractState { // ensure the proposal is valid against the current parameters self.parameters.validate_incoming_proposal(proposal)?; - // TODO(#3169): re-enable once resharing votes carry per-domain `t` - // and the node honors per-domain reconstruction thresholds (#3164). - // Until both are in place this check would block legitimate - // resharings that the node currently signs at the cluster threshold. - // let new_num_participants = proposal.participants().len() as u64; - // for domain in self.domains.domains() { - // crate::primitives::domain::validate_domain_threshold(domain, new_num_participants)?; - // } + // Validate effective per-domain thresholds against the proposed new + // participant count. Domains not present in the overlay keep their + // existing threshold; overlay entries override. An overlay entry + // referencing an unknown domain ID is rejected. + let new_num_participants = proposal.participants().len() as u64; + let overlay = proposal.per_domain_thresholds(); + for id in overlay.keys() { + if self.domains.get_domain_by_domain_id(*id).is_none() { + return Err(DomainError::UnknownDomainInProposal { domain_id: *id }.into()); + } + } + for domain in self.domains.domains() { + let effective_threshold = overlay + .get(&domain.id) + .copied() + .unwrap_or(domain.reconstruction_threshold); + let proposed = DomainConfig { + reconstruction_threshold: effective_threshold, + ..domain.clone() + }; + crate::primitives::domain::validate_domain_threshold(&proposed, new_num_participants)?; + } // ensure the signer is a proposed participant let candidate = AuthenticatedAccountId::new(proposal.participants())?; @@ -473,4 +487,74 @@ pub mod running_tests { "Expected InsufficientParticipantsForProtocol, got: {err}" ); } + + // ----- #3169: per-domain threshold proposals in resharing ----- + + use std::collections::BTreeMap; + + #[test] + fn process_new_parameters_proposal__should_accept_empty_per_domain_overlay() { + // Given a running state where existing thresholds are valid under the + // proposed participant count + let mut state = gen_running_state(1); + let mut env = Environment::new(None, None, None); + env.set_signer(&state.parameters.participants().participants()[0].0); + let proposal = gen_valid_params_proposal(&state.parameters); + + // When voting with an empty per_domain_thresholds map (legacy shape) + let res = state.vote_new_parameters(state.keyset.epoch_id.next(), &proposal); + + // Then the vote is recorded without error + assert!(res.is_ok(), "Expected accept with empty overlay: {res:?}"); + } + + #[test] + fn process_new_parameters_proposal__should_reject_overlay_with_unknown_domain_id() { + // Given a running state with one domain + let mut state = gen_running_state(1); + let mut env = Environment::new(None, None, None); + env.set_signer(&state.parameters.participants().participants()[0].0); + let proposal = gen_valid_params_proposal(&state.parameters); + + // When voting with an overlay referencing a non-existent domain ID + let mut overlay = BTreeMap::new(); + overlay.insert(DomainId(9999), ReconstructionThreshold::new(2)); + let proposal = proposal.with_per_domain_thresholds(overlay); + let err = state + .vote_new_parameters(state.keyset.epoch_id.next(), &proposal) + .unwrap_err(); + + // Then the unknown-domain guard rejects it + assert!( + err.to_string().contains("not in the current registry"), + "Expected UnknownDomainInProposal, got: {err}" + ); + } + + #[test] + fn process_new_parameters_proposal__should_apply_overlay_to_threshold_validation() { + // Given a running state with one domain whose existing threshold would + // remain valid under the new participants, but the overlay swaps it + // for an invalid (too-low) value. + let mut state = gen_running_state(1); + let mut env = Environment::new(None, None, None); + env.set_signer(&state.parameters.participants().participants()[0].0); + let proposal = gen_valid_params_proposal(&state.parameters); + + // When voting with an overlay that violates the universal lower bound + let domain_id = state.domains.domains()[0].id; + let mut overlay = BTreeMap::new(); + overlay.insert(domain_id, ReconstructionThreshold::new(1)); + let proposal = proposal.with_per_domain_thresholds(overlay); + let err = state + .vote_new_parameters(state.keyset.epoch_id.next(), &proposal) + .unwrap_err(); + + // Then the overlay's value (not the stored value) is validated and rejected + assert!( + err.to_string() + .contains("Reconstruction threshold must be at least"), + "Expected ReconstructionThresholdTooLow on overlay value, got: {err}" + ); + } } diff --git a/crates/contract/src/v3_10_state.rs b/crates/contract/src/v3_10_state.rs index 504986290a..7a66ecfd20 100644 --- a/crates/contract/src/v3_10_state.rs +++ b/crates/contract/src/v3_10_state.rs @@ -7,6 +7,8 @@ //! However, this approach (a) requires manual effort from a developer and (b) increases the binary size. //! A better approach: only copy the structures that have changed and import the rest from the existing codebase. +use std::collections::BTreeMap; + use borsh::{BorshDeserialize, BorshSerialize}; use near_mpc_contract_interface::types::{self as dtos, VerifyForeignTransactionRequest}; use near_sdk::{env, store::LookupMap}; @@ -17,17 +19,100 @@ use crate::{ pending_requests::LegacyPendingRequests, primitives::{ ckd::CKDRequest, + domain::{AddDomainsVotes, DomainRegistry}, + key_state::{AuthenticatedAccountId, EpochId, Keyset}, + participants::Participants, signature::{SignatureRequest, YieldIndex}, + threshold_votes::ThresholdParametersVotes, + thresholds::{Threshold, ThresholdParameters}, + }, + state::{ + initializing::InitializingContractState, resharing::ResharingContractState, + running::RunningContractState, ProtocolContractState, }, - state::ProtocolContractState, tee::tee_state::TeeState, update::ProposedUpdates, Config, SupportedForeignChainsByNode, }; +/// Pre-3.11 layout of `ThresholdParameters`. The new layout appends +/// `per_domain_thresholds: BTreeMap` +/// (see #3169). Borsh is positional, so old bytes can be decoded into this +/// shadow and then mapped to the new struct with the map defaulted to empty. +#[derive(Debug, BorshSerialize, BorshDeserialize)] +struct OldThresholdParameters { + participants: Participants, + threshold: Threshold, +} + +impl From for ThresholdParameters { + fn from(old: OldThresholdParameters) -> Self { + // Resharing votes from before this migration didn't carry per-domain + // thresholds, so the migrated proposal preserves the existing + // domains' thresholds (interpreted later as a no-change overlay). + ThresholdParameters::new_unvalidated(old.participants, old.threshold) + } +} + +/// Pre-3.11 layout of `ThresholdParametersVotes` — same shape but with the +/// old `OldThresholdParameters` as the value type. +#[derive(Debug, BorshSerialize, BorshDeserialize)] +struct OldThresholdParametersVotes { + proposal_by_account: BTreeMap, +} + +impl From for ThresholdParametersVotes { + fn from(old: OldThresholdParametersVotes) -> Self { + ThresholdParametersVotes { + proposal_by_account: old + .proposal_by_account + .into_iter() + .map(|(acc, params)| (acc, params.into())) + .collect(), + } + } +} + +/// Pre-3.11 layout of `RunningContractState`. Mirrors the current shape but +/// uses the old `OldThresholdParameters` and `OldThresholdParametersVotes`. +#[derive(Debug, BorshSerialize, BorshDeserialize)] +struct OldRunningContractState { + domains: DomainRegistry, + keyset: Keyset, + parameters: OldThresholdParameters, + parameters_votes: OldThresholdParametersVotes, + add_domains_votes: AddDomainsVotes, + previously_cancelled_resharing_epoch_id: Option, +} + +impl From for RunningContractState { + fn from(old: OldRunningContractState) -> Self { + RunningContractState { + domains: old.domains, + keyset: old.keyset, + parameters: old.parameters.into(), + parameters_votes: old.parameters_votes.into(), + add_domains_votes: old.add_domains_votes, + previously_cancelled_resharing_epoch_id: old.previously_cancelled_resharing_epoch_id, + } + } +} + +/// Pre-3.11 layout of `ProtocolContractState`. Only the `Running` variant +/// has a verified shadow — Initializing/Resharing reuse current types and +/// would fail to deserialize old data, which matches the pre-existing +/// "migration panics if not Running" policy. +#[derive(Debug, BorshSerialize, BorshDeserialize)] +enum OldProtocolContractState { + NotInitialized, + Initializing(InitializingContractState), + Running(OldRunningContractState), + Resharing(ResharingContractState), +} + #[derive(Debug, BorshSerialize, BorshDeserialize)] pub struct MpcContract { - protocol_state: ProtocolContractState, + protocol_state: OldProtocolContractState, pending_signature_requests: LookupMap>, pending_ckd_requests: LookupMap>, pending_verify_foreign_tx_requests: LookupMap>, @@ -44,12 +129,12 @@ pub struct MpcContract { impl From for crate::MpcContract { fn from(value: MpcContract) -> Self { - if !matches!(value.protocol_state, ProtocolContractState::Running(_)) { + let OldProtocolContractState::Running(running) = value.protocol_state else { env::panic_str("Contract must be in running state when migrating."); - } + }; Self { - protocol_state: value.protocol_state, + protocol_state: ProtocolContractState::Running(running.into()), pending_signature_requests: value.pending_signature_requests, pending_ckd_requests: value.pending_ckd_requests, pending_verify_foreign_tx_requests: value.pending_verify_foreign_tx_requests, @@ -67,3 +152,33 @@ impl From for crate::MpcContract { } } } + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::*; + use crate::primitives::test_utils::{gen_participants, NUM_PROTOCOLS}; + + /// Borsh round-trip: write a `ThresholdParameters` in the OLD layout + /// (no `per_domain_thresholds` field), deserialize via the shadow, + /// convert to the new struct, and assert the overlay defaults to empty. + #[test] + fn old_threshold_parameters__should_deserialize_into_empty_overlay() { + // Given old-layout bytes + let participants = gen_participants(NUM_PROTOCOLS); + let n = participants.len() as u64; + let old = OldThresholdParameters { + participants, + threshold: Threshold::new(n), + }; + let bytes = borsh::to_vec(&old).unwrap(); + + // When borsh-decoding through the shadow and migrating + let decoded: OldThresholdParameters = borsh::from_slice(&bytes).unwrap(); + let migrated: ThresholdParameters = decoded.into(); + + // Then per-domain overlay is empty and core fields round-trip + assert!(migrated.per_domain_thresholds().is_empty()); + assert_eq!(migrated.threshold().value(), n); + } +} diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap.new b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap.new new file mode 100644 index 0000000000..65fa7c7311 --- /dev/null +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap.new @@ -0,0 +1,4172 @@ +--- +source: crates/contract/tests/abi.rs +assertion_line: 47 +expression: abi +--- +{ + "schema_version": "0.4.0", + "metadata": { + "name": "mpc-contract", + "version": "3.10.0", + "build": { + "compiler": "rustc 1.86.0", + "builder": "[CARGO_NEAR_BUILD_VERSION]" + }, + "wasm_hash": "[WASM_HASH]" + }, + "body": { + "functions": [ + { + "name": "allowed_docker_image_hashes", + "doc": " Returns all allowed code hashes in order from most recent to least recent allowed code hashes. The first element is the most recent allowed code hash.", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "array", + "items": { + "$ref": "#/definitions/DockerImageHash" + } + } + } + }, + { + "name": "allowed_launcher_compose_hashes", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "array", + "items": { + "$ref": "#/definitions/LauncherDockerComposeHash" + } + } + } + }, + { + "name": "allowed_launcher_image_hashes", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "array", + "items": { + "$ref": "#/definitions/LauncherImageHash" + } + } + } + }, + { + "name": "allowed_os_measurements", + "doc": " Returns all currently allowed OS measurements.", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ContractExpectedMeasurements" + } + } + } + }, + { + "name": "clean_foreign_chain_data", + "doc": " Private endpoint to clean up foreign chain policy votes and node configurations\n for non-participants after resharing.\n This can only be called by the contract itself via a promise.", + "kind": "call", + "modifiers": [ + "private" + ], + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "clean_invalid_attestations", + "doc": " Prunes up to `max_scan` stored attestations that fail re-verification (expired or\n referencing stale whitelists). Returns the number of entries removed. Callable by\n anyone while the protocol is in `Running`.", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "max_scan", + "type_schema": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } + }, + { + "name": "clean_tee_status", + "doc": " Private endpoint to drop votes cast by non-participants after resharing.\n Attestation cleanup is handled separately by [`MpcContract::clean_invalid_attestations`].", + "kind": "call", + "modifiers": [ + "private" + ], + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "cleanup_orphaned_node_migrations", + "kind": "call", + "modifiers": [ + "private" + ], + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "code_hash_votes", + "doc": " Returns the current code hash votes, showing each participant's vote.", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/CodeHashesVotes" + } + } + }, + { + "name": "conclude_node_migration", + "doc": " Finalizes a node migration for the calling account.\n\n This method can only be called while the protocol is in a `Running` state\n and by an existing participant. On success, the participant’s information is\n updated to the new destination node.\n\n # Errors\n Returns the following errors:\n - `InvalidState::ProtocolStateNotRunning`: if protocol is not in `Running` state\n - `InvalidState::NotParticipant`: if caller is not a current participant\n - `NodeMigrationError::KeysetMismatch`: if provided keyset does not match the expected keyset\n - `NodeMigrationError::MigrationNotFound`: if no migration record exists for the caller\n - `NodeMigrationError::AccountPublicKeyMismatch`: if caller’s public key does not match the expected destination node\n - `InvalidParameters::InvalidTeeRemoteAttestation`: if destination node’s TEE quote is invalid", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "keyset", + "type_schema": { + "$ref": "#/definitions/Keyset" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "config", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/Config" + } + } + }, + { + "name": "contract_source_metadata", + "kind": "view" + }, + { + "name": "derived_public_key", + "doc": " This is the derived public key of the caller given path and predecessor\n if predecessor is not provided, it will be the caller of the contract.\n\n The domain parameter specifies which domain we're deriving the public key for;\n the default is the first domain.", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "path", + "type_schema": { + "type": "string" + } + }, + { + "name": "predecessor", + "type_schema": { + "description": "NEAR Account Identifier.\n\nThis is a unique, syntactically valid, human-readable account identifier on the NEAR network.\n\n[See the crate-level docs for information about validation.](index.html#account-id-rules)\n\nAlso see [Error kind precedence](AccountId#error-kind-precedence).\n\n## Examples\n\n``` use near_account_id::AccountId;\n\nlet alice: AccountId = \"alice.near\".parse().unwrap();\n\nassert!(\"ƒelicia.near\".parse::().is_err()); // (ƒ is not f) ```", + "type": [ + "string", + "null" + ] + } + }, + { + "name": "domain_id", + "type_schema": { + "anyOf": [ + { + "$ref": "#/definitions/DomainId" + }, + { + "type": "null" + } + ] + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/PublicKey" + } + } + }, + { + "name": "fail_on_timeout", + "kind": "view", + "modifiers": [ + "private" + ] + }, + { + "name": "get_attestation", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "tls_public_key", + "type_schema": { + "$ref": "#/definitions/Ed25519PublicKey" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "anyOf": [ + { + "$ref": "#/definitions/VerifiedAttestation" + }, + { + "type": "null" + } + ] + } + } + }, + { + "name": "get_foreign_chain_support_by_node", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/ForeignChainSupportByNode" + } + } + }, + { + "name": "get_pending_ckd_request", + "doc": " Presence check for a pending CKD request, exposed as a view call.\n\n See [`Self::get_pending_request`] for the contract: the returned `YieldIndex`\n is an arbitrary representative of a fan-out queue, not \"the\" yield. Only the\n `Some`/`None` distinction is meaningful.", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "request", + "type_schema": { + "$ref": "#/definitions/CKDRequest" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "anyOf": [ + { + "$ref": "#/definitions/YieldIndex" + }, + { + "type": "null" + } + ] + } + } + }, + { + "name": "get_pending_request", + "doc": " Presence check for a pending signature request, exposed as a view call.\n\n **The returned `YieldIndex` is an arbitrary representative, not \"the\" yield\n for this request.** Since the duplicate-request fan-out feature (PR #3187),\n a single request key can have N queued yields; this method returns the head of the\n queue. Callers that need to act on the full set are wrong to use this. The\n only correct interpretation is presence: `Some(_)` vs `None`.\n\n The `Option` shape is retained for JSON wire compatibility with\n out-of-tree consumers; the in-tree caller (`tx_sender::observe_tx_result`)\n only matches on presence. Prefer a `bool`-shaped accessor if one is added.\n\n Falls back to the legacy single-yield map for in-flight requests inherited\n from before the fan-out upgrade.", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "request", + "type_schema": { + "$ref": "#/definitions/SignatureRequest" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "anyOf": [ + { + "$ref": "#/definitions/YieldIndex" + }, + { + "type": "null" + } + ] + } + } + }, + { + "name": "get_pending_verify_foreign_tx_request", + "doc": " Presence check for a pending foreign-tx verification request, exposed as a\n view call.\n\n See [`Self::get_pending_request`] for the contract: the returned `YieldIndex`\n is an arbitrary representative of a fan-out queue, not \"the\" yield. Only the\n `Some`/`None` distinction is meaningful.", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "request", + "type_schema": { + "$ref": "#/definitions/VerifyForeignTransactionRequest" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "anyOf": [ + { + "$ref": "#/definitions/YieldIndex" + }, + { + "type": "null" + } + ] + } + } + }, + { + "name": "get_supported_foreign_chains", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/SupportedForeignChains" + } + } + }, + { + "name": "get_tee_accounts", + "doc": " Returns all accounts that have TEE attestations stored in the contract.\n Note: This includes both current protocol participants and accounts that may have\n submitted TEE information but are not currently part of the active participant set.", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "array", + "items": { + "$ref": "#/definitions/NodeId" + } + } + } + }, + { + "name": "init", + "kind": "call", + "modifiers": [ + "init" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "parameters", + "type_schema": { + "$ref": "#/definitions/ThresholdParameters" + } + }, + { + "name": "init_config", + "type_schema": { + "anyOf": [ + { + "$ref": "#/definitions/InitConfig" + }, + { + "type": "null" + } + ] + } + } + ] + } + }, + { + "name": "init_running", + "kind": "call", + "modifiers": [ + "init", + "private" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "domains", + "type_schema": { + "type": "array", + "items": { + "$ref": "#/definitions/DomainConfig" + } + } + }, + { + "name": "next_domain_id", + "type_schema": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + { + "name": "keyset", + "type_schema": { + "$ref": "#/definitions/Keyset" + } + }, + { + "name": "parameters", + "type_schema": { + "$ref": "#/definitions/ThresholdParameters" + } + }, + { + "name": "init_config", + "type_schema": { + "anyOf": [ + { + "$ref": "#/definitions/InitConfig" + }, + { + "type": "null" + } + ] + } + } + ] + } + }, + { + "name": "latest_key_version", + "doc": " Key versions refer new versions of the root key that we may choose to generate on cohort\n changes. Older key versions will always work but newer key versions were never held by\n older signers. Newer key versions may also add new security features, like only existing\n within a secure enclave. The signature_scheme parameter specifies which protocol\n we're querying the latest version for. The default is Secp256k1. The default is **NOT**\n to query across all protocols.", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "signature_scheme", + "type_schema": { + "anyOf": [ + { + "$ref": "#/definitions/Curve" + }, + { + "type": "null" + } + ] + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } + }, + { + "name": "launcher_hash_votes", + "doc": " Returns the current launcher hash votes, showing each participant's vote.", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/LauncherHashVotes" + } + } + }, + { + "name": "metrics", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/Metrics" + } + } + }, + { + "name": "migrate", + "doc": " This will be called internally by the contract to migrate the state when a new contract\n is deployed. This function should be changed every time state is changed to do the proper\n migrate flow.\n\n If nothing is changed, then this function will just return the current state. If it fails\n to read the state, then it will return an error.", + "kind": "call", + "modifiers": [ + "init", + "private" + ] + }, + { + "name": "migration_info", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": [ + { + "anyOf": [ + { + "$ref": "#/definitions/BackupServiceInfo" + }, + { + "type": "null" + } + ] + }, + { + "anyOf": [ + { + "$ref": "#/definitions/DestinationNodeInfo" + }, + { + "type": "null" + } + ] + } + ], + "maxItems": 2, + "minItems": 2 + } + } + } + }, + { + "name": "os_measurement_votes", + "doc": " Returns the current OS measurement votes, showing each participant's vote.", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/MeasurementVotes" + } + } + }, + { + "name": "propose_update", + "doc": " Propose update to either code or config, but not both of them at the same time.", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "borsh", + "args": [ + { + "name": "args", + "type_schema": { + "declaration": "ProposeUpdateArgs", + "definitions": { + "()": { + "Primitive": 0 + }, + "Config": { + "Struct": [ + [ + "key_event_timeout_blocks", + "u64" + ], + [ + "tee_upgrade_deadline_duration_seconds", + "u64" + ], + [ + "contract_upgrade_deposit_tera_gas", + "u64" + ], + [ + "sign_call_gas_attachment_requirement_tera_gas", + "u64" + ], + [ + "ckd_call_gas_attachment_requirement_tera_gas", + "u64" + ], + [ + "return_signature_and_clean_state_on_success_call_tera_gas", + "u64" + ], + [ + "return_ck_and_clean_state_on_success_call_tera_gas", + "u64" + ], + [ + "fail_on_timeout_tera_gas", + "u64" + ], + [ + "clean_tee_status_tera_gas", + "u64" + ], + [ + "clean_invalid_attestations_tera_gas", + "u64" + ], + [ + "cleanup_orphaned_node_migrations_tera_gas", + "u64" + ], + [ + "remove_non_participant_update_votes_tera_gas", + "u64" + ], + [ + "clean_foreign_chain_data_tera_gas", + "u64" + ] + ] + }, + "Option": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "None", + "()" + ], + [ + 1, + "Some", + "Config" + ] + ] + } + }, + "Option>": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "None", + "()" + ], + [ + 1, + "Some", + "Vec" + ] + ] + } + }, + "ProposeUpdateArgs": { + "Struct": [ + [ + "code", + "Option>" + ], + [ + "config", + "Option" + ] + ] + }, + "Vec": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "u64": { + "Primitive": 8 + }, + "u8": { + "Primitive": 1 + } + } + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/UpdateId" + } + } + }, + { + "name": "proposed_updates", + "doc": " returns all proposed updates", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/ProposedUpdates" + } + } + }, + { + "name": "public_key", + "doc": " This is the root public key combined from all the public keys of the participants.\n The domain parameter specifies which domain we're querying the public key for;\n the default is the first domain.", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "domain_id", + "type_schema": { + "anyOf": [ + { + "$ref": "#/definitions/DomainId" + }, + { + "type": "null" + } + ] + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/PublicKey" + } + } + }, + { + "name": "register_backup_service", + "doc": " Registers or updates the backup service information for the caller account.\n\n The caller (`signer_account_id`) must be an existing or prospective participant.\n Otherwise, the transaction will fail.\n\n # Notes\n - A deposit requirement may be added in the future.", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "backup_service_info", + "type_schema": { + "$ref": "#/definitions/BackupServiceInfo" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "register_foreign_chain_config", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "foreign_chain_configuration", + "type_schema": { + "$ref": "#/definitions/ForeignChainConfiguration" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "register_foreign_chain_support", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "foreign_chain_support", + "type_schema": { + "$ref": "#/definitions/SupportedForeignChains" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "remove_non_participant_update_votes", + "doc": " Cleans update votes from non-participants after resharing.\n Can be called by any participant or triggered automatically via promise.", + "kind": "call", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "remove_update_vote", + "doc": " Removes an update vote by the caller\n panics if the contract is not in a running state or if the caller is not a participant", + "kind": "call" + }, + { + "name": "request_app_private_key", + "doc": " To avoid overloading the network with too many requests,\n we ask for a small deposit for each ckd request.\n\n Note: identity points are accepted in `AppPublicKeyPV` to support use cases\n where the derived key is intentionally public (no encryption).", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "request", + "type_schema": { + "$ref": "#/definitions/CKDRequestArgs" + } + } + ] + } + }, + { + "name": "respond", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "request", + "type_schema": { + "$ref": "#/definitions/SignatureRequest" + } + }, + { + "name": "response", + "type_schema": { + "$ref": "#/definitions/SignatureResponse" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "respond_ckd", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "request", + "type_schema": { + "$ref": "#/definitions/CKDRequest" + } + }, + { + "name": "response", + "type_schema": { + "$ref": "#/definitions/CKDResponse" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "respond_verify_foreign_tx", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "request", + "type_schema": { + "$ref": "#/definitions/VerifyForeignTransactionRequest" + } + }, + { + "name": "response", + "type_schema": { + "$ref": "#/definitions/VerifyForeignTransactionResponse" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "return_ck_and_clean_state_on_success", + "doc": " Yield-resume callback for a single queued CKD request.\n\n On success, returns the confidential key to the original caller. On timeout,\n pops this yield's slot (the head of the FIFO fan-out queue) from the\n pending-request map — falling back to the legacy single-yield map for\n pre-fan-out entries — and fires `fail_on_timeout` to fail the original\n transaction. Sibling yields queued under the same request key remain pending\n and are cleaned up by their own timeouts (or drained together by a subsequent\n `respond_ckd`).", + "kind": "call", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "request", + "type_schema": { + "$ref": "#/definitions/CKDRequest" + } + } + ] + }, + "callbacks": [ + { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/CKDResponse" + } + } + ], + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/PromiseOrValueCKDResponse" + } + } + }, + { + "name": "return_signature_and_clean_state_on_success", + "doc": " Yield-resume callback for a single queued `sign` request.\n\n On success, returns the signature to the original caller. On timeout, pops this\n yield's slot (the head of the FIFO fan-out queue) from the pending-request map\n — falling back to the legacy single-yield map for pre-fan-out entries — and\n fires `fail_on_timeout` to fail the original transaction. Sibling yields queued\n under the same request key remain pending and are cleaned up by their own\n timeouts (or drained together by a subsequent `respond`).", + "kind": "call", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "request", + "type_schema": { + "$ref": "#/definitions/SignatureRequest" + } + } + ] + }, + "callbacks": [ + { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/SignatureResponse" + } + } + ], + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/PromiseOrValueSignatureResponse" + } + } + }, + { + "name": "return_verify_foreign_tx_and_clean_state_on_success", + "doc": " Yield-resume callback for a single queued foreign-tx verification request.\n\n On success, returns the verification response to the original caller. On\n timeout, pops this yield's slot (the head of the FIFO fan-out queue) from the\n pending-request map — falling back to the legacy single-yield map for\n pre-fan-out entries — and fires `fail_on_timeout` to fail the original\n transaction. Sibling yields queued under the same request key remain pending\n and are cleaned up by their own timeouts (or drained together by a subsequent\n `respond_verify_foreign_tx`).", + "kind": "call", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "request", + "type_schema": { + "$ref": "#/definitions/VerifyForeignTransactionRequest" + } + } + ] + }, + "callbacks": [ + { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/VerifyForeignTransactionResponse" + } + } + ], + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/PromiseOrValueVerifyForeignTransactionResponse" + } + } + }, + { + "name": "sign", + "doc": " `key_version` must be less than or equal to the value at `latest_key_version`\n To avoid overloading the network with too many requests,\n we ask for a small deposit for each signature request.", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "request", + "type_schema": { + "$ref": "#/definitions/SignRequestArgs" + } + } + ] + } + }, + { + "name": "start_keygen_instance", + "doc": " Starts a new attempt to generate a key for the current domain.\n This only succeeds if the signer is the leader (the participant with the lowest ID).", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "key_event_id", + "type_schema": { + "$ref": "#/definitions/KeyEventId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "start_node_migration", + "doc": " Sets the destination node for the calling account.\n\n This function can only be called while the protocol is in a `Running` state.\n The signer must be a current participant of the current epoch, otherwise an error is returned.\n On success, the provided `DestinationNodeInfo` is stored in the contract state\n under the signer’s account ID.\n\n # Errors\n - [`InvalidState::ProtocolStateNotRunning`] if the protocol is not in the `Running` state.\n - [`InvalidState::NotParticipant`] if the signer is not a current participant.\n # Note:\n - might require a deposit", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "destination_node_info", + "type_schema": { + "$ref": "#/definitions/DestinationNodeInfo" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "start_reshare_instance", + "doc": " Starts a new attempt to reshare the key for the current domain.\n This only succeeds if the signer is the leader (the participant with the lowest ID).", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "key_event_id", + "type_schema": { + "$ref": "#/definitions/KeyEventId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "state", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/ProtocolContractState" + } + } + }, + { + "name": "submit_participant_info", + "doc": " (Prospective) Participants can submit their tee participant information through this\n endpoint.", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "proposed_participant_attestation", + "type_schema": { + "$ref": "#/definitions/Attestation" + } + }, + { + "name": "tls_public_key", + "type_schema": { + "$ref": "#/definitions/Ed25519PublicKey" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "update_config", + "kind": "call", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "config", + "type_schema": { + "$ref": "#/definitions/Config" + } + } + ] + } + }, + { + "name": "verify_foreign_transaction", + "doc": " Submit a verification + signing request for a foreign chain transaction.\n MPC nodes will verify the transaction on the foreign chain before signing.\n The signed payload is derived from the transaction ID (hash of tx_id).", + "kind": "call", + "modifiers": [ + "payable" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "request", + "type_schema": { + "$ref": "#/definitions/VerifyForeignTransactionRequestArgs" + } + } + ] + } + }, + { + "name": "verify_tee", + "doc": " Verifies if all current participants have an accepted TEE state.\n Automatically enters a resharing, in case one or more participants do not have an accepted\n TEE state.\n Returns `false` and stops the contract from accepting new signature requests or responses,\n in case less than `threshold` participants run in an accepted TEE State.", + "kind": "call", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "boolean" + } + } + }, + { + "name": "version", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "string" + } + } + }, + { + "name": "vote_abort_key_event_instance", + "doc": " Casts a vote to abort the current key event instance. If succesful, the contract aborts the\n instance and a new instance with the next attempt_id can be started.", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "key_event_id", + "type_schema": { + "$ref": "#/definitions/KeyEventId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "vote_add_domains", + "doc": " Propose adding a new set of domains for the MPC network.\n If a threshold number of votes are reached on the exact same proposal, this will transition\n the contract into the Initializing state to generate keys for the new domains.\n\n The specified list of domains must have increasing and contiguous IDs, and the first ID\n must be the same as the `next_domain_id` returned by state().", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "domains", + "type_schema": { + "type": "array", + "items": { + "$ref": "#/definitions/DomainConfig" + } + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "vote_add_launcher_hash", + "doc": " Vote to add a new launcher image hash to the allowed set. Requires threshold votes.\n When the threshold is reached, compose hashes are automatically derived for all\n currently allowed MPC image hashes.", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "launcher_hash", + "type_schema": { + "$ref": "#/definitions/LauncherImageHash" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "vote_add_os_measurement", + "doc": " Vote to add a new OS measurement set to the allowed list. Requires threshold votes.", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "measurement", + "type_schema": { + "$ref": "#/definitions/ContractExpectedMeasurements" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "vote_cancel_keygen", + "doc": " Casts a vote to cancel key generation. Any keys that have already been generated\n are kept and we transition into Running state; remaining domains are permanently deleted.\n Deleted domain IDs cannot be reused again in future calls to vote_add_domains.\n\n A next_domain_id that matches that in the state's domains struct must be passed in. This is\n to prevent stale requests from accidentally cancelling a future key generation state.", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "next_domain_id", + "type_schema": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "vote_cancel_resharing", + "doc": " Casts a vote to cancel the current key resharing. If a threshold number of unique\n votes are collected to cancel the resharing, the contract state will revert back to the\n previous running state.\n\n - This method is idempotent, meaning a single account can not make more than one vote.\n - Only nodes from the previous running state are allowed to vote.\n\n Return value:\n - [Ok] if the vote was successfully collected.\n - [Err] if:\n - The signer is not a participant in the previous running state.\n - The contract is not in a resharing state.", + "kind": "call", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "vote_code_hash", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "code_hash", + "type_schema": { + "$ref": "#/definitions/DockerImageHash" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "vote_new_parameters", + "doc": " Propose a new set of parameters (participants and threshold) for the MPC network.\n If a threshold number of votes are reached on the exact same proposal, this will transition\n the contract into the Resharing state.\n\n The epoch_id must be equal to 1 plus the current epoch ID (if Running) or prospective epoch\n ID (if Resharing). Otherwise the vote is ignored. This is to prevent late transactions from\n accidentally voting on outdated proposals.", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "prospective_epoch_id", + "type_schema": { + "$ref": "#/definitions/EpochId" + } + }, + { + "name": "proposal", + "type_schema": { + "$ref": "#/definitions/ThresholdParameters" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "vote_pk", + "doc": " Casts a vote for `public_key` for the attempt identified by `key_event_id`.\n\n The effect of this method is either:\n - Returns error (which aborts with no changes), if there is no active key generation\n attempt (including if the attempt timed out), if the signer is not a participant, or if\n the key_event_id corresponds to a different domain, different epoch, or different attempt\n from the current key generation attempt.\n - Returns Ok(()), with one of the following changes:\n - A vote has been collected but we don't have enough votes yet.\n - This vote is for a public key that disagrees from an earlier voted public key, causing\n the attempt to abort; another call to `start` is then necessary.\n - Everyone has now voted for the same public key; the state transitions into generating a\n key for the next domain.\n - Same as the last case, except that all domains have a generated key now, and the state\n transitions into Running with the newly generated keys.", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "key_event_id", + "type_schema": { + "$ref": "#/definitions/KeyEventId" + } + }, + { + "name": "public_key", + "type_schema": { + "$ref": "#/definitions/PublicKey" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "vote_remove_launcher_hash", + "doc": " Vote to remove a launcher image hash from the allowed set. Requires ALL participants\n to vote for removal, since this invalidates attestations of nodes running that launcher.", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "launcher_hash", + "type_schema": { + "$ref": "#/definitions/LauncherImageHash" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "vote_remove_os_measurement", + "doc": " Vote to remove an OS measurement set from the allowed list. Requires ALL participants\n to vote for removal.", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "measurement", + "type_schema": { + "$ref": "#/definitions/ContractExpectedMeasurements" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "vote_reshared", + "doc": " Casts a vote for the successful resharing of the attempt identified by `key_event_id`.\n\n The effect of this method is either:\n - Returns error (which aborts with no changes), if there is no active key resharing attempt\n (including if the attempt timed out), if the signer is not a participant, or if the\n key_event_id corresponds to a different domain, different epoch, or different attempt\n from the current key resharing attempt.\n - Returns Ok(()), with one of the following changes:\n - A vote has been collected but we don't have enough votes yet.\n - Everyone has now voted; the state transitions into resharing the key for the next\n domain.\n - Same as the last case, except that all domains' keys have been reshared now, and the\n state transitions into Running with the newly reshared keys.", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "key_event_id", + "type_schema": { + "$ref": "#/definitions/KeyEventId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, + { + "name": "vote_update", + "doc": " Vote for a proposed update given the [`UpdateId`] of the update.\n\n Returns `Ok(true)` if the amount of voters surpassed the threshold and the update was\n executed. Returns `Ok(false)` if the amount of voters did not surpass the threshold.\n Returns [`Error`] if the update was not found or if the voter is not a participant\n in the protocol.", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "id", + "type_schema": { + "$ref": "#/definitions/UpdateId" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "boolean" + } + } + } + ], + "root_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "String", + "type": "string", + "definitions": { + "AddDomainsVotes": { + "description": "Votes for adding new domains.", + "type": "object", + "required": [ + "proposal_by_account" + ], + "properties": { + "proposal_by_account": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/DomainConfig" + } + } + } + } + }, + "AttemptId": { + "description": "Attempt identifier within a key event. Incremented for each attempt within the same epoch and domain.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "Attestation": { + "oneOf": [ + { + "type": "object", + "required": [ + "Dstack" + ], + "properties": { + "Dstack": { + "$ref": "#/definitions/DstackAttestation" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Mock" + ], + "properties": { + "Mock": { + "$ref": "#/definitions/MockAttestation" + } + }, + "additionalProperties": false + } + ] + }, + "AuthenticatedAccountId": { + "description": "An account ID that has been authenticated (i.e., the caller is this account).", + "type": "string" + }, + "AuthenticatedParticipantId": { + "description": "A participant ID that has been authenticated (i.e., the caller is this participant).", + "allOf": [ + { + "$ref": "#/definitions/ParticipantId" + } + ] + }, + "BackupServiceInfo": { + "type": "object", + "required": [ + "public_key" + ], + "properties": { + "public_key": { + "$ref": "#/definitions/Ed25519PublicKey" + } + } + }, + "BitcoinExtractor": { + "type": "string", + "enum": [ + "BlockHash" + ] + }, + "BitcoinRpcRequest": { + "type": "object", + "required": [ + "confirmations", + "extractors", + "tx_id" + ], + "properties": { + "confirmations": { + "$ref": "#/definitions/BlockConfirmations" + }, + "extractors": { + "type": "array", + "items": { + "$ref": "#/definitions/BitcoinExtractor" + } + }, + "tx_id": { + "$ref": "#/definitions/BitcoinTxId" + } + } + }, + "BitcoinTxId": { + "type": "string", + "pattern": "^(?:[0-9A-Fa-f]{2})*$" + }, + "BlockConfirmations": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "Bls12381G1PublicKey": { + "type": "string" + }, + "Bls12381G2PublicKey": { + "type": "string" + }, + "CKDAppPublicKey": { + "oneOf": [ + { + "type": "object", + "required": [ + "AppPublicKey" + ], + "properties": { + "AppPublicKey": { + "$ref": "#/definitions/Bls12381G1PublicKey" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "AppPublicKeyPV" + ], + "properties": { + "AppPublicKeyPV": { + "$ref": "#/definitions/CKDAppPublicKeyPV" + } + }, + "additionalProperties": false + } + ] + }, + "CKDAppPublicKeyPV": { + "type": "object", + "required": [ + "pk1", + "pk2" + ], + "properties": { + "pk1": { + "$ref": "#/definitions/Bls12381G1PublicKey" + }, + "pk2": { + "$ref": "#/definitions/Bls12381G2PublicKey" + } + } + }, + "CKDRequest": { + "type": "object", + "required": [ + "app_id", + "app_public_key", + "domain_id" + ], + "properties": { + "app_id": { + "$ref": "#/definitions/CkdAppId" + }, + "app_public_key": { + "description": "The app ephemeral public key", + "allOf": [ + { + "$ref": "#/definitions/CKDAppPublicKey" + } + ] + }, + "domain_id": { + "$ref": "#/definitions/DomainId" + } + } + }, + "CKDRequestArgs": { + "type": "object", + "required": [ + "app_public_key", + "derivation_path", + "domain_id" + ], + "properties": { + "app_public_key": { + "$ref": "#/definitions/CKDAppPublicKey" + }, + "derivation_path": { + "type": "string" + }, + "domain_id": { + "$ref": "#/definitions/DomainId" + } + } + }, + "CKDResponse": { + "type": "object", + "required": [ + "big_c", + "big_y" + ], + "properties": { + "big_c": { + "$ref": "#/definitions/Bls12381G1PublicKey" + }, + "big_y": { + "$ref": "#/definitions/Bls12381G1PublicKey" + } + } + }, + "CkdAppId": { + "description": "AppId for CKD", + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "maxItems": 32, + "minItems": 32 + }, + "CodeHashesVotes": { + "description": "Tracks votes to add whitelisted TEE code hashes. Each participant can at any given time vote for a code hash to add.", + "type": "object", + "required": [ + "proposal_by_account" + ], + "properties": { + "proposal_by_account": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/DockerImageHash" + } + } + } + }, + "Collateral": { + "type": "object", + "required": [ + "pck_crl", + "pck_crl_issuer_chain", + "qe_identity", + "qe_identity_issuer_chain", + "qe_identity_signature", + "root_ca_crl", + "tcb_info", + "tcb_info_issuer_chain", + "tcb_info_signature" + ], + "properties": { + "pck_certificate_chain": { + "type": [ + "string", + "null" + ] + }, + "pck_crl": { + "type": "string", + "pattern": "^(?:[0-9A-Fa-f]{2})*$" + }, + "pck_crl_issuer_chain": { + "type": "string" + }, + "qe_identity": { + "type": "string" + }, + "qe_identity_issuer_chain": { + "type": "string" + }, + "qe_identity_signature": { + "type": "string", + "pattern": "^(?:[0-9A-Fa-f]{2})*$" + }, + "root_ca_crl": { + "type": "string", + "pattern": "^(?:[0-9A-Fa-f]{2})*$" + }, + "tcb_info": { + "type": "string" + }, + "tcb_info_issuer_chain": { + "type": "string" + }, + "tcb_info_signature": { + "type": "string", + "pattern": "^(?:[0-9A-Fa-f]{2})*$" + } + } + }, + "Config": { + "description": "Configuration parameters of the contract.", + "type": "object", + "required": [ + "ckd_call_gas_attachment_requirement_tera_gas", + "clean_foreign_chain_data_tera_gas", + "clean_invalid_attestations_tera_gas", + "clean_tee_status_tera_gas", + "cleanup_orphaned_node_migrations_tera_gas", + "contract_upgrade_deposit_tera_gas", + "fail_on_timeout_tera_gas", + "key_event_timeout_blocks", + "remove_non_participant_update_votes_tera_gas", + "return_ck_and_clean_state_on_success_call_tera_gas", + "return_signature_and_clean_state_on_success_call_tera_gas", + "sign_call_gas_attachment_requirement_tera_gas", + "tee_upgrade_deadline_duration_seconds" + ], + "properties": { + "ckd_call_gas_attachment_requirement_tera_gas": { + "description": "Prepaid gas for a `return_signature_and_clean_state_on_success` call.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "clean_foreign_chain_data_tera_gas": { + "description": "Prepaid gas for a `clean_foreign_chain_data` call.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "clean_invalid_attestations_tera_gas": { + "description": "Prepaid gas for the reshare-time `clean_invalid_attestations` promise.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "clean_tee_status_tera_gas": { + "description": "Prepaid gas for a `clean_tee_status` call.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "cleanup_orphaned_node_migrations_tera_gas": { + "description": "Prepaid gas for a `cleanup_orphaned_node_migrations` call.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "contract_upgrade_deposit_tera_gas": { + "description": "Amount of gas to deposit for contract and config updates.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "fail_on_timeout_tera_gas": { + "description": "Prepaid gas for a `fail_on_timeout` call.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "key_event_timeout_blocks": { + "description": "If a key event attempt has not successfully completed within this many blocks, it is considered failed.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "remove_non_participant_update_votes_tera_gas": { + "description": "Prepaid gas for a `remove_non_participant_update_votes` call.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "return_ck_and_clean_state_on_success_call_tera_gas": { + "description": "Prepaid gas for a `return_ck_and_clean_state_on_success` call.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "return_signature_and_clean_state_on_success_call_tera_gas": { + "description": "Prepaid gas for a `return_signature_and_clean_state_on_success` call.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "sign_call_gas_attachment_requirement_tera_gas": { + "description": "Gas required for a sign request.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "tee_upgrade_deadline_duration_seconds": { + "description": "The grace period duration for expiry of old mpc image hashes once a new one is added.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "ContractExpectedMeasurements": { + "description": "On-chain representation of expected TDX measurements. Mirrors [`mpc_attestation::attestation::ExpectedMeasurements`] with contract-compatible serialization (hex strings in JSON, borsh for storage).", + "type": "object", + "required": [ + "key_provider_event_digest", + "mrtd", + "rtmr0", + "rtmr1", + "rtmr2" + ], + "properties": { + "key_provider_event_digest": { + "$ref": "#/definitions/KeyProviderEventDigest" + }, + "mrtd": { + "$ref": "#/definitions/MrtdHash" + }, + "rtmr0": { + "$ref": "#/definitions/Rtmr0Hash" + }, + "rtmr1": { + "$ref": "#/definitions/Rtmr1Hash" + }, + "rtmr2": { + "$ref": "#/definitions/Rtmr2Hash" + } + } + }, + "Curve": { + "description": "Elliptic curve used by a domain.", + "type": "string", + "enum": [ + "Secp256k1", + "Edwards25519", + "Bls12381" + ] + }, + "DestinationNodeInfo": { + "type": "object", + "required": [ + "destination_node_info", + "signer_account_pk" + ], + "properties": { + "destination_node_info": { + "$ref": "#/definitions/ParticipantInfo" + }, + "signer_account_pk": { + "description": "The public key used by the node to sign transactions to the contract. This key is different from the TLS key stored in [`ParticipantInfo::tls_public_key`].", + "allOf": [ + { + "$ref": "#/definitions/Ed25519PublicKey" + } + ] + } + } + }, + "DockerImageHash": { + "type": "string", + "maxLength": 64, + "minLength": 64, + "pattern": "^[0-9a-fA-F]+$" + }, + "DomainConfig": { + "description": "Configuration for a signature domain.", + "type": "object", + "required": [ + "id", + "protocol", + "purpose", + "reconstruction_threshold" + ], + "properties": { + "id": { + "$ref": "#/definitions/DomainId" + }, + "protocol": { + "$ref": "#/definitions/Protocol" + }, + "purpose": { + "$ref": "#/definitions/DomainPurpose" + }, + "reconstruction_threshold": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "DomainId": { + "description": "Each domain corresponds to a specific root key on a specific elliptic curve. There may be multiple domains per curve. The domain ID uniquely identifies a domain.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "DomainPurpose": { + "description": "The purpose that a domain serves.", + "oneOf": [ + { + "description": "Domain is used by `sign()`.", + "type": "string", + "enum": [ + "Sign" + ] + }, + { + "description": "Domain is used by `verify_foreign_transaction()`.", + "type": "string", + "enum": [ + "ForeignTx" + ] + }, + { + "description": "Domain is used by `request_app_private_key()` (Confidential Key Derivation).", + "type": "string", + "enum": [ + "CKD" + ] + } + ] + }, + "DomainRegistry": { + "description": "Registry of all signature domains.", + "type": "object", + "required": [ + "domains", + "next_domain_id" + ], + "properties": { + "domains": { + "type": "array", + "items": { + "$ref": "#/definitions/DomainConfig" + } + }, + "next_domain_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "DstackAttestation": { + "type": "object", + "required": [ + "collateral", + "quote", + "tcb_info" + ], + "properties": { + "collateral": { + "$ref": "#/definitions/Collateral" + }, + "quote": { + "type": "string", + "pattern": "^(?:[0-9A-Fa-f]{2})*$" + }, + "tcb_info": { + "$ref": "#/definitions/TcbInfo" + } + } + }, + "Ed25519PublicKey": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "maxItems": 32, + "minItems": 32 + }, + "Ed25519Signature": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } + }, + "EpochId": { + "description": "An EpochId uniquely identifies a ThresholdParameters (but not vice-versa). Every time we change the ThresholdParameters (participants and threshold), we increment EpochId. Locally on each node, each keyshare is uniquely identified by the tuple (EpochId, DomainId, AttemptId).", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "EventLog": { + "description": "Represents an event log entry in the system", + "type": "object", + "required": [ + "digest", + "event", + "event_payload", + "event_type", + "imr" + ], + "properties": { + "digest": { + "description": "The cryptographic digest of the event", + "type": "string" + }, + "event": { + "description": "The type of event as a string", + "type": "string" + }, + "event_payload": { + "description": "The payload data associated with the event", + "type": "string" + }, + "event_type": { + "description": "The type of event being logged", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "imr": { + "description": "The index of the IMR (Integrity Measurement Register)", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } + }, + "EvmExtractor": { + "oneOf": [ + { + "type": "string", + "enum": [ + "BlockHash" + ] + }, + { + "type": "object", + "required": [ + "Log" + ], + "properties": { + "Log": { + "type": "object", + "required": [ + "log_index" + ], + "properties": { + "log_index": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + } + ] + }, + "EvmFinality": { + "type": "string", + "enum": [ + "Latest", + "Safe", + "Finalized" + ] + }, + "EvmRpcRequest": { + "type": "object", + "required": [ + "extractors", + "finality", + "tx_id" + ], + "properties": { + "extractors": { + "type": "array", + "items": { + "$ref": "#/definitions/EvmExtractor" + } + }, + "finality": { + "$ref": "#/definitions/EvmFinality" + }, + "tx_id": { + "$ref": "#/definitions/EvmTxId" + } + } + }, + "EvmTxId": { + "type": "string", + "pattern": "^(?:[0-9A-Fa-f]{2})*$" + }, + "ForeignChain": { + "type": "string", + "enum": [ + "Solana", + "Bitcoin", + "Ethereum", + "Base", + "Bnb", + "Arbitrum", + "Abstract", + "Starknet", + "Polygon", + "HyperEvm" + ] + }, + "ForeignChainConfiguration": { + "deprecated": true, + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/NonEmptyBTreeSet_RpcProvider" + } + }, + "ForeignChainRpcRequest": { + "oneOf": [ + { + "type": "object", + "required": [ + "Abstract" + ], + "properties": { + "Abstract": { + "$ref": "#/definitions/EvmRpcRequest" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Ethereum" + ], + "properties": { + "Ethereum": { + "$ref": "#/definitions/EvmRpcRequest" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Solana" + ], + "properties": { + "Solana": { + "$ref": "#/definitions/SolanaRpcRequest" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Bitcoin" + ], + "properties": { + "Bitcoin": { + "$ref": "#/definitions/BitcoinRpcRequest" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Starknet" + ], + "properties": { + "Starknet": { + "$ref": "#/definitions/StarknetRpcRequest" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Bnb" + ], + "properties": { + "Bnb": { + "$ref": "#/definitions/EvmRpcRequest" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Base" + ], + "properties": { + "Base": { + "$ref": "#/definitions/EvmRpcRequest" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Arbitrum" + ], + "properties": { + "Arbitrum": { + "$ref": "#/definitions/EvmRpcRequest" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Polygon" + ], + "properties": { + "Polygon": { + "$ref": "#/definitions/EvmRpcRequest" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "HyperEvm" + ], + "properties": { + "HyperEvm": { + "$ref": "#/definitions/EvmRpcRequest" + } + }, + "additionalProperties": false + } + ] + }, + "ForeignChainSupportByNode": { + "type": "object", + "required": [ + "foreign_chain_support_by_node" + ], + "properties": { + "foreign_chain_support_by_node": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/SupportedForeignChains" + } + } + } + }, + "Hash256": { + "type": "string", + "pattern": "^(?:[0-9A-Fa-f]{2})*$" + }, + "HexString_Min32_Max1232": { + "type": "string", + "maxLength": 2464, + "minLength": 64, + "pattern": "^[0-9a-fA-F]*$" + }, + "HexString_Min32_Max32": { + "type": "string", + "maxLength": 64, + "minLength": 64, + "pattern": "^[0-9a-fA-F]*$" + }, + "InitConfig": { + "description": "The initial configuration parameters for when initializing the contract. All fields are optional, as the contract can fill in defaults for any missing fields.", + "type": "object", + "properties": { + "ckd_call_gas_attachment_requirement_tera_gas": { + "description": "Prepaid gas for a `return_signature_and_clean_state_on_success` call.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "clean_foreign_chain_data_tera_gas": { + "description": "Prepaid gas for a `clean_foreign_chain_data` call.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "clean_invalid_attestations_tera_gas": { + "description": "Prepaid gas for the reshare-time `clean_invalid_attestations` promise.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "clean_tee_status_tera_gas": { + "description": "Prepaid gas for a `clean_tee_status` call.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "cleanup_orphaned_node_migrations_tera_gas": { + "description": "Prepaid gas for a `cleanup_orphaned_node_migrations` call.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "contract_upgrade_deposit_tera_gas": { + "description": "Amount of gas to deposit for contract and config updates.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "fail_on_timeout_tera_gas": { + "description": "Prepaid gas for a `fail_on_timeout` call.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "key_event_timeout_blocks": { + "description": "If a key event attempt has not successfully completed within this many blocks, it is considered failed.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "remove_non_participant_update_votes_tera_gas": { + "description": "Prepaid gas for a `remove_non_participant_update_votes` call.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "return_ck_and_clean_state_on_success_call_tera_gas": { + "description": "Prepaid gas for a `return_ck_and_clean_state_on_success` call.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "return_signature_and_clean_state_on_success_call_tera_gas": { + "description": "Prepaid gas for a `return_signature_and_clean_state_on_success` call.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "sign_call_gas_attachment_requirement_tera_gas": { + "description": "Gas required for a sign request.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "tee_upgrade_deadline_duration_seconds": { + "description": "The grace period duration for expiry of old mpc image hashes once a new one is added.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + } + }, + "InitializingContractState": { + "description": "State when the contract is generating keys for new domains.", + "type": "object", + "required": [ + "cancel_votes", + "domains", + "epoch_id", + "generated_keys", + "generating_key" + ], + "properties": { + "cancel_votes": { + "type": "array", + "items": { + "$ref": "#/definitions/AuthenticatedParticipantId" + }, + "uniqueItems": true + }, + "domains": { + "$ref": "#/definitions/DomainRegistry" + }, + "epoch_id": { + "$ref": "#/definitions/EpochId" + }, + "generated_keys": { + "type": "array", + "items": { + "$ref": "#/definitions/KeyForDomain2" + } + }, + "generating_key": { + "$ref": "#/definitions/KeyEvent" + } + } + }, + "K256AffinePoint": { + "description": "AffinePoint on the Secp256k1 curve", + "type": "object", + "required": [ + "affine_point" + ], + "properties": { + "affine_point": { + "type": "string", + "pattern": "^(?:[0-9A-Fa-f]{2})*$" + } + } + }, + "K256Scalar": { + "type": "object", + "required": [ + "scalar" + ], + "properties": { + "scalar": { + "type": "string", + "pattern": "^(?:[0-9A-Fa-f]{2})*$" + } + } + }, + "KeyEvent": { + "description": "Key generation or resharing event state.", + "type": "object", + "required": [ + "domain", + "epoch_id", + "next_attempt_id", + "parameters" + ], + "properties": { + "domain": { + "$ref": "#/definitions/DomainConfig" + }, + "epoch_id": { + "$ref": "#/definitions/EpochId" + }, + "instance": { + "anyOf": [ + { + "$ref": "#/definitions/KeyEventInstance" + }, + { + "type": "null" + } + ] + }, + "next_attempt_id": { + "$ref": "#/definitions/AttemptId" + }, + "parameters": { + "$ref": "#/definitions/ThresholdParameters" + } + } + }, + "KeyEventId": { + "description": "A unique identifier for a key event (generation or resharing): `epoch_id`: identifies the ThresholdParameters that this key is intended to function in. `domain_id`: the domain this key is intended for. `attempt_id`: identifies a particular attempt for this key event, in case multiple attempts yielded partially valid results. This is incremented for each attempt within the same epoch and domain.", + "type": "object", + "required": [ + "attempt_id", + "domain_id", + "epoch_id" + ], + "properties": { + "attempt_id": { + "$ref": "#/definitions/AttemptId" + }, + "domain_id": { + "$ref": "#/definitions/DomainId" + }, + "epoch_id": { + "$ref": "#/definitions/EpochId" + } + } + }, + "KeyEventInstance": { + "description": "State of a key generation/resharing instance.", + "type": "object", + "required": [ + "attempt_id", + "completed", + "expires_on", + "started_in" + ], + "properties": { + "attempt_id": { + "$ref": "#/definitions/AttemptId" + }, + "completed": { + "type": "array", + "items": { + "$ref": "#/definitions/AuthenticatedParticipantId" + }, + "uniqueItems": true + }, + "expires_on": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "public_key": { + "anyOf": [ + { + "$ref": "#/definitions/PublicKeyExtended2" + }, + { + "type": "null" + } + ] + }, + "started_in": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "KeyForDomain": { + "description": "The identification of a specific distributed key, based on which a node would know exactly what keyshare it has corresponds to this distributed key. (A distributed key refers to a specific set of keyshares that nodes have which can be pieced together to form the secret key.)", + "type": "object", + "required": [ + "attempt", + "domain_id", + "key" + ], + "properties": { + "attempt": { + "description": "The attempt ID that generated (initially or as a result of resharing) this distributed key. Nodes may have made multiple attempts to generate the distributed key, and this uniquely identifies which one should ultimately be used.", + "allOf": [ + { + "$ref": "#/definitions/AttemptId" + } + ] + }, + "domain_id": { + "description": "Identifies the domain this key is intended for.", + "allOf": [ + { + "$ref": "#/definitions/DomainId" + } + ] + }, + "key": { + "description": "Identifies the public key. Although technically redundant given that we have the AttemptId, we keep it here in the contract so that it can be verified against and queried.", + "allOf": [ + { + "$ref": "#/definitions/PublicKeyExtended" + } + ] + } + } + }, + "KeyForDomain2": { + "description": "The identification of a specific distributed key, based on which a node would know exactly what keyshare it has corresponds to this distributed key. (A distributed key refers to a specific set of keyshares that nodes have which can be pieced together to form the secret key.)", + "type": "object", + "required": [ + "attempt", + "domain_id", + "key" + ], + "properties": { + "attempt": { + "description": "The attempt ID that generated (initially or as a result of resharing) this distributed key. Nodes may have made multiple attempts to generate the distributed key, and this uniquely identifies which one should ultimately be used.", + "allOf": [ + { + "$ref": "#/definitions/AttemptId" + } + ] + }, + "domain_id": { + "description": "Identifies the domain this key is intended for.", + "allOf": [ + { + "$ref": "#/definitions/DomainId" + } + ] + }, + "key": { + "description": "Identifies the public key. Although technically redundant given that we have the AttemptId, we keep it here in the contract so that it can be verified against and queried.", + "allOf": [ + { + "$ref": "#/definitions/PublicKeyExtended2" + } + ] + } + } + }, + "KeyProviderEventDigest": { + "type": "string", + "maxLength": 96, + "minLength": 96, + "pattern": "^[0-9a-fA-F]+$" + }, + "Keyset": { + "description": "Represents a key for every domain in a specific epoch.", + "type": "object", + "required": [ + "domains", + "epoch_id" + ], + "properties": { + "domains": { + "type": "array", + "items": { + "$ref": "#/definitions/KeyForDomain" + } + }, + "epoch_id": { + "$ref": "#/definitions/EpochId" + } + } + }, + "Keyset2": { + "description": "Represents a key for every domain in a specific epoch.", + "type": "object", + "required": [ + "domains", + "epoch_id" + ], + "properties": { + "domains": { + "type": "array", + "items": { + "$ref": "#/definitions/KeyForDomain2" + } + }, + "epoch_id": { + "$ref": "#/definitions/EpochId" + } + } + }, + "LauncherDockerComposeHash": { + "type": "string", + "maxLength": 64, + "minLength": 64, + "pattern": "^[0-9a-fA-F]+$" + }, + "LauncherHashVotes": { + "description": "Tracks votes for adding or removing launcher image hashes. Each participant can have at most one active vote at a time.", + "type": "object", + "required": [ + "vote_by_account" + ], + "properties": { + "vote_by_account": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/LauncherVoteAction" + } + } + } + }, + "LauncherImageHash": { + "type": "string", + "maxLength": 64, + "minLength": 64, + "pattern": "^[0-9a-fA-F]+$" + }, + "LauncherVoteAction": { + "description": "The action a participant is voting for on a launcher image hash.", + "oneOf": [ + { + "type": "object", + "required": [ + "Add" + ], + "properties": { + "Add": { + "$ref": "#/definitions/LauncherImageHash" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Remove" + ], + "properties": { + "Remove": { + "$ref": "#/definitions/LauncherImageHash" + } + }, + "additionalProperties": false + } + ] + }, + "MeasurementVoteAction": { + "description": "The action a participant is voting for on an OS measurement set.", + "oneOf": [ + { + "type": "object", + "required": [ + "Add" + ], + "properties": { + "Add": { + "$ref": "#/definitions/ContractExpectedMeasurements" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Remove" + ], + "properties": { + "Remove": { + "$ref": "#/definitions/ContractExpectedMeasurements" + } + }, + "additionalProperties": false + } + ] + }, + "MeasurementVotes": { + "description": "Tracks votes for adding or removing OS measurements. Each participant can have at most one active vote at a time.", + "type": "object", + "required": [ + "vote_by_account" + ], + "properties": { + "vote_by_account": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/MeasurementVoteAction" + } + } + } + }, + "Metrics": { + "type": "object", + "required": [ + "sign_with_v1_payload_count", + "sign_with_v2_payload_count" + ], + "properties": { + "sign_with_v1_payload_count": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "sign_with_v2_payload_count": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "MockAttestation": { + "oneOf": [ + { + "description": "Always pass validation", + "type": "string", + "enum": [ + "Valid" + ] + }, + { + "description": "Always fails validation", + "type": "string", + "enum": [ + "Invalid" + ] + }, + { + "description": "Pass validation depending on the set constraints", + "type": "object", + "required": [ + "WithConstraints" + ], + "properties": { + "WithConstraints": { + "type": "object", + "properties": { + "expected_measurements": { + "anyOf": [ + { + "$ref": "#/definitions/VerifiedMeasurements" + }, + { + "type": "null" + } + ] + }, + "expiry_timestamp_seconds": { + "description": "Unix time stamp for when this attestation expires.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "launcher_docker_compose_hash": { + "anyOf": [ + { + "$ref": "#/definitions/LauncherDockerComposeHash" + }, + { + "type": "null" + } + ] + }, + "mpc_docker_image_hash": { + "anyOf": [ + { + "$ref": "#/definitions/DockerImageHash" + }, + { + "type": "null" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "MrtdHash": { + "type": "string", + "maxLength": 96, + "minLength": 96, + "pattern": "^[0-9a-fA-F]+$" + }, + "NodeId": { + "type": "object", + "required": [ + "account_id", + "account_public_key", + "tls_public_key" + ], + "properties": { + "account_id": { + "description": "Operator account.", + "type": "string" + }, + "account_public_key": { + "description": "Full-access Ed25519 public key of the operator account.", + "allOf": [ + { + "$ref": "#/definitions/Ed25519PublicKey" + } + ] + }, + "tls_public_key": { + "description": "TLS public key used by the node for peer-to-peer communication.", + "allOf": [ + { + "$ref": "#/definitions/Ed25519PublicKey" + } + ] + } + } + }, + "NonEmptyBTreeSet_RpcProvider": { + "type": "array", + "items": { + "$ref": "#/definitions/RpcProvider" + }, + "minItems": 1, + "uniqueItems": true + }, + "ParticipantId": { + "description": "Stable identifier for a participant within the MPC protocol's participant set.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "ParticipantInfo": { + "type": "object", + "required": [ + "tls_public_key", + "url" + ], + "properties": { + "tls_public_key": { + "$ref": "#/definitions/Ed25519PublicKey" + }, + "url": { + "type": "string" + } + } + }, + "Participants": { + "description": "DTO representation of the contract-internal `Participants` type.\n\nIt decouples the JSON wire format (used in view methods like `state()` via [`ThresholdParameters`](crate::types::state::ThresholdParameters)) from the internal `Participants` representation, allowing internal changes (e.g., migrating to [`BTreeMap`](std::collections::BTreeMap) in [#1861](https://github.com/near/mpc/pull/1861)) without breaking the public API.", + "type": "object", + "required": [ + "next_id", + "participants" + ], + "properties": { + "next_id": { + "$ref": "#/definitions/ParticipantId" + }, + "participants": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "description": "NEAR Account Identifier.\n\nThis is a unique, syntactically valid, human-readable account identifier on the NEAR network.\n\n[See the crate-level docs for information about validation.](index.html#account-id-rules)\n\nAlso see [Error kind precedence](AccountId#error-kind-precedence).\n\n## Examples\n\n``` use near_account_id::AccountId;\n\nlet alice: AccountId = \"alice.near\".parse().unwrap();\n\nassert!(\"ƒelicia.near\".parse::().is_err()); // (ƒ is not f) ```", + "type": "string" + }, + { + "$ref": "#/definitions/ParticipantId" + }, + { + "$ref": "#/definitions/ParticipantInfo" + } + ], + "maxItems": 3, + "minItems": 3 + } + } + } + }, + "Payload": { + "description": "A signature payload; the right payload must be passed in for the curve. The json encoding for this payload converts the bytes to hex string.", + "oneOf": [ + { + "type": "object", + "required": [ + "Ecdsa" + ], + "properties": { + "Ecdsa": { + "$ref": "#/definitions/HexString_Min32_Max32" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Eddsa" + ], + "properties": { + "Eddsa": { + "$ref": "#/definitions/HexString_Min32_Max1232" + } + }, + "additionalProperties": false + } + ] + }, + "PromiseOrValueCKDResponse": { + "type": "object", + "required": [ + "big_c", + "big_y" + ], + "properties": { + "big_c": { + "$ref": "#/definitions/Bls12381G1PublicKey" + }, + "big_y": { + "$ref": "#/definitions/Bls12381G1PublicKey" + } + } + }, + "PromiseOrValueSignatureResponse": { + "oneOf": [ + { + "type": "object", + "required": [ + "big_r", + "recovery_id", + "s", + "scheme" + ], + "properties": { + "big_r": { + "$ref": "#/definitions/K256AffinePoint" + }, + "recovery_id": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "s": { + "$ref": "#/definitions/K256Scalar" + }, + "scheme": { + "type": "string", + "enum": [ + "Secp256k1" + ] + } + } + }, + { + "type": "object", + "required": [ + "scheme", + "signature" + ], + "properties": { + "scheme": { + "type": "string", + "enum": [ + "Ed25519" + ] + }, + "signature": { + "$ref": "#/definitions/Ed25519Signature" + } + } + } + ] + }, + "PromiseOrValueVerifyForeignTransactionResponse": { + "type": "object", + "required": [ + "payload_hash", + "signature" + ], + "properties": { + "payload_hash": { + "$ref": "#/definitions/Hash256" + }, + "signature": { + "$ref": "#/definitions/SignatureResponse" + } + } + }, + "ProposedUpdates": { + "type": "object", + "required": [ + "updates", + "votes" + ], + "properties": { + "updates": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/UpdateHash" + } + }, + "votes": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "Protocol": { + "description": "MPC protocol run for a domain.", + "type": "string", + "enum": [ + "CaitSith", + "Frost", + "ConfidentialKeyDerivation", + "DamgardEtAl" + ] + }, + "ProtocolContractState": { + "description": "The main protocol contract state enum.", + "oneOf": [ + { + "type": "string", + "enum": [ + "NotInitialized" + ] + }, + { + "type": "object", + "required": [ + "Initializing" + ], + "properties": { + "Initializing": { + "$ref": "#/definitions/InitializingContractState" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Running" + ], + "properties": { + "Running": { + "$ref": "#/definitions/RunningContractState" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Resharing" + ], + "properties": { + "Resharing": { + "$ref": "#/definitions/ResharingContractState" + } + }, + "additionalProperties": false + } + ] + }, + "PublicKey": { + "oneOf": [ + { + "type": "object", + "required": [ + "Secp256k1" + ], + "properties": { + "Secp256k1": { + "$ref": "#/definitions/Secp256k1PublicKey" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Ed25519" + ], + "properties": { + "Ed25519": { + "$ref": "#/definitions/Ed25519PublicKey" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Bls12381" + ], + "properties": { + "Bls12381": { + "$ref": "#/definitions/Bls12381G2PublicKey" + } + }, + "additionalProperties": false + } + ] + }, + "PublicKeyExtended": { + "oneOf": [ + { + "type": "object", + "required": [ + "Secp256k1" + ], + "properties": { + "Secp256k1": { + "type": "object", + "required": [ + "near_public_key" + ], + "properties": { + "near_public_key": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Ed25519" + ], + "properties": { + "Ed25519": { + "type": "object", + "required": [ + "edwards_point", + "near_public_key_compressed" + ], + "properties": { + "edwards_point": { + "description": "Decompressed Edwards point used for curve arithmetic operations.", + "allOf": [ + { + "$ref": "#/definitions/SerializableEdwardsPoint" + } + ] + }, + "near_public_key_compressed": { + "description": "Serialized compressed Edwards-y point.", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Bls12381" + ], + "properties": { + "Bls12381": { + "type": "object", + "required": [ + "public_key" + ], + "properties": { + "public_key": { + "$ref": "#/definitions/PublicKey" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "PublicKeyExtended2": { + "description": "Extended public key representation for different signature schemes.", + "oneOf": [ + { + "description": "Secp256k1 public key (ECDSA).", + "type": "object", + "required": [ + "Secp256k1" + ], + "properties": { + "Secp256k1": { + "type": "object", + "required": [ + "near_public_key" + ], + "properties": { + "near_public_key": { + "description": "The public key in NEAR SDK format (string representation).", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Ed25519 public key.", + "type": "object", + "required": [ + "Ed25519" + ], + "properties": { + "Ed25519": { + "type": "object", + "required": [ + "edwards_point", + "near_public_key_compressed" + ], + "properties": { + "edwards_point": { + "description": "The Edwards point (32 bytes).", + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "maxItems": 32, + "minItems": 32 + }, + "near_public_key_compressed": { + "description": "The compressed public key in NEAR SDK format.", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "BLS12-381 public key.", + "type": "object", + "required": [ + "Bls12381" + ], + "properties": { + "Bls12381": { + "type": "object", + "required": [ + "public_key" + ], + "properties": { + "public_key": { + "description": "The public key.", + "allOf": [ + { + "$ref": "#/definitions/PublicKey" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "ResharingContractState": { + "description": "State when the contract is resharing keys to new participants.", + "type": "object", + "required": [ + "cancellation_requests", + "previous_running_state", + "reshared_keys", + "resharing_key" + ], + "properties": { + "cancellation_requests": { + "type": "array", + "items": { + "$ref": "#/definitions/AuthenticatedAccountId" + }, + "uniqueItems": true + }, + "previous_running_state": { + "$ref": "#/definitions/RunningContractState" + }, + "reshared_keys": { + "type": "array", + "items": { + "$ref": "#/definitions/KeyForDomain2" + } + }, + "resharing_key": { + "$ref": "#/definitions/KeyEvent" + } + } + }, + "RpcProvider": { + "type": "object", + "required": [ + "rpc_url" + ], + "properties": { + "rpc_url": { + "type": "string" + } + } + }, + "Rtmr0Hash": { + "type": "string", + "maxLength": 96, + "minLength": 96, + "pattern": "^[0-9a-fA-F]+$" + }, + "Rtmr1Hash": { + "type": "string", + "maxLength": 96, + "minLength": 96, + "pattern": "^[0-9a-fA-F]+$" + }, + "Rtmr2Hash": { + "type": "string", + "maxLength": 96, + "minLength": 96, + "pattern": "^[0-9a-fA-F]+$" + }, + "RunningContractState": { + "description": "State when the contract is ready for signature operations.", + "type": "object", + "required": [ + "add_domains_votes", + "domains", + "keyset", + "parameters", + "parameters_votes" + ], + "properties": { + "add_domains_votes": { + "$ref": "#/definitions/AddDomainsVotes" + }, + "domains": { + "$ref": "#/definitions/DomainRegistry" + }, + "keyset": { + "$ref": "#/definitions/Keyset2" + }, + "parameters": { + "$ref": "#/definitions/ThresholdParameters" + }, + "parameters_votes": { + "$ref": "#/definitions/ThresholdParametersVotes" + }, + "previously_cancelled_resharing_epoch_id": { + "anyOf": [ + { + "$ref": "#/definitions/EpochId" + }, + { + "type": "null" + } + ] + } + } + }, + "Secp256k1PublicKey": { + "type": "string" + }, + "SerializableEdwardsPoint": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "maxItems": 32, + "minItems": 32 + }, + "Sha384Digest": { + "type": "string", + "maxLength": 96, + "minLength": 96, + "pattern": "^[0-9a-fA-F]+$" + }, + "SignRequestArgs": { + "description": "Sign request args with backward-compatible deserialization.\n\nThe struct field is `payload` but serializes as `payload_v2` on the wire for compatibility with existing consumers. Deserialization accepts both `payload_v2` and the deprecated `payload` (as raw `[u8; 32]`) plus `key_version` as an alias for `domain_id`.", + "type": "object", + "required": [ + "domain_id", + "path", + "payload_v2" + ], + "properties": { + "domain_id": { + "$ref": "#/definitions/DomainId" + }, + "path": { + "type": "string" + }, + "payload_v2": { + "$ref": "#/definitions/Payload" + } + } + }, + "SignatureRequest": { + "description": "A signature request after computing the tweak from the caller's account and derivation path. This is what gets stored in the contract state and sent back to the respond function.", + "type": "object", + "required": [ + "domain_id", + "payload", + "tweak" + ], + "properties": { + "domain_id": { + "$ref": "#/definitions/DomainId" + }, + "payload": { + "$ref": "#/definitions/Payload" + }, + "tweak": { + "$ref": "#/definitions/Tweak" + } + } + }, + "SignatureResponse": { + "oneOf": [ + { + "type": "object", + "required": [ + "big_r", + "recovery_id", + "s", + "scheme" + ], + "properties": { + "big_r": { + "$ref": "#/definitions/K256AffinePoint" + }, + "recovery_id": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "s": { + "$ref": "#/definitions/K256Scalar" + }, + "scheme": { + "type": "string", + "enum": [ + "Secp256k1" + ] + } + } + }, + { + "type": "object", + "required": [ + "scheme", + "signature" + ], + "properties": { + "scheme": { + "type": "string", + "enum": [ + "Ed25519" + ] + }, + "signature": { + "$ref": "#/definitions/Ed25519Signature" + } + } + } + ] + }, + "SolanaExtractor": { + "oneOf": [ + { + "type": "object", + "required": [ + "SolanaProgramIdIndex" + ], + "properties": { + "SolanaProgramIdIndex": { + "type": "object", + "required": [ + "ix_index" + ], + "properties": { + "ix_index": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "SolanaDataHash" + ], + "properties": { + "SolanaDataHash": { + "type": "object", + "required": [ + "ix_index" + ], + "properties": { + "ix_index": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + } + ] + }, + "SolanaFinality": { + "type": "string", + "enum": [ + "Processed", + "Confirmed", + "Finalized" + ] + }, + "SolanaRpcRequest": { + "type": "object", + "required": [ + "extractors", + "finality", + "tx_id" + ], + "properties": { + "extractors": { + "type": "array", + "items": { + "$ref": "#/definitions/SolanaExtractor" + } + }, + "finality": { + "$ref": "#/definitions/SolanaFinality" + }, + "tx_id": { + "$ref": "#/definitions/SolanaTxId" + } + } + }, + "SolanaTxId": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } + }, + "StarknetExtractor": { + "oneOf": [ + { + "type": "string", + "enum": [ + "BlockHash" + ] + }, + { + "type": "object", + "required": [ + "Log" + ], + "properties": { + "Log": { + "type": "object", + "required": [ + "log_index" + ], + "properties": { + "log_index": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + } + ] + }, + "StarknetFelt": { + "type": "string", + "pattern": "^(?:[0-9A-Fa-f]{2})*$" + }, + "StarknetFinality": { + "type": "string", + "enum": [ + "AcceptedOnL2", + "AcceptedOnL1" + ] + }, + "StarknetRpcRequest": { + "type": "object", + "required": [ + "extractors", + "finality", + "tx_id" + ], + "properties": { + "extractors": { + "type": "array", + "items": { + "$ref": "#/definitions/StarknetExtractor" + } + }, + "finality": { + "$ref": "#/definitions/StarknetFinality" + }, + "tx_id": { + "$ref": "#/definitions/StarknetTxId" + } + } + }, + "StarknetTxId": { + "$ref": "#/definitions/StarknetFelt" + }, + "SupportedForeignChains": { + "type": "array", + "items": { + "$ref": "#/definitions/ForeignChain" + }, + "uniqueItems": true + }, + "TcbInfo": { + "description": "Trusted Computing Base information structure", + "type": "object", + "required": [ + "app_compose", + "compose_hash", + "device_id", + "event_log", + "mrtd", + "rtmr0", + "rtmr1", + "rtmr2", + "rtmr3" + ], + "properties": { + "app_compose": { + "description": "The app compose", + "type": "string" + }, + "compose_hash": { + "description": "The hash of the compose configuration", + "type": "string" + }, + "device_id": { + "description": "The device identifier", + "type": "string" + }, + "event_log": { + "description": "The event log entries", + "type": "array", + "items": { + "$ref": "#/definitions/EventLog" + } + }, + "mrtd": { + "description": "The measurement root of trust", + "type": "string" + }, + "os_image_hash": { + "description": "The hash of the OS image. This is empty if the OS image is not measured by KMS.", + "default": "", + "type": "string" + }, + "rtmr0": { + "description": "The value of RTMR0 (Runtime Measurement Register 0)", + "type": "string" + }, + "rtmr1": { + "description": "The value of RTMR1 (Runtime Measurement Register 1)", + "type": "string" + }, + "rtmr2": { + "description": "The value of RTMR2 (Runtime Measurement Register 2)", + "type": "string" + }, + "rtmr3": { + "description": "The value of RTMR3 (Runtime Measurement Register 3)", + "type": "string" + } + } + }, + "Threshold": { + "description": "Cryptographic threshold (`k`) for a distributed key: the minimum number of participants that must collaborate to produce a signature.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "ThresholdParameters": { + "description": "Threshold parameters for distributed key operations.\n\n`per_domain_thresholds` carries a proposed update for each domain's [`ReconstructionThreshold`] when this struct flows into `vote_new_parameters`. An empty map means \"keep current per-domain thresholds\"; a populated map must cover every existing domain (validated by the contract). Outside of resharing proposals the map is empty.", + "type": "object", + "required": [ + "participants", + "threshold" + ], + "properties": { + "participants": { + "$ref": "#/definitions/Participants" + }, + "per_domain_thresholds": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "threshold": { + "$ref": "#/definitions/Threshold" + } + } + }, + "ThresholdParametersVotes": { + "description": "Votes for threshold parameter changes.", + "type": "object", + "required": [ + "proposal_by_account" + ], + "properties": { + "proposal_by_account": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/ThresholdParameters" + } + } + } + }, + "Tweak": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "maxItems": 32, + "minItems": 32 + }, + "UpdateHash": { + "description": "An update hash", + "oneOf": [ + { + "type": "object", + "required": [ + "Code" + ], + "properties": { + "Code": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "maxItems": 32, + "minItems": 32 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Config" + ], + "properties": { + "Config": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "maxItems": 32, + "minItems": 32 + } + }, + "additionalProperties": false + } + ] + }, + "UpdateId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "VerifiedAttestation": { + "oneOf": [ + { + "type": "object", + "required": [ + "Dstack" + ], + "properties": { + "Dstack": { + "$ref": "#/definitions/VerifiedDstackAttestation" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "Mock" + ], + "properties": { + "Mock": { + "$ref": "#/definitions/MockAttestation" + } + }, + "additionalProperties": false + } + ] + }, + "VerifiedDstackAttestation": { + "type": "object", + "required": [ + "expiry_timestamp_seconds", + "launcher_compose_hash", + "measurements", + "mpc_image_hash" + ], + "properties": { + "expiry_timestamp_seconds": { + "description": "Unix time stamp for when this attestation expires.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "launcher_compose_hash": { + "description": "The digest of the launcher compose file running.", + "allOf": [ + { + "$ref": "#/definitions/LauncherDockerComposeHash" + } + ] + }, + "measurements": { + "description": "The OS measurements that were verified during initial attestation.", + "allOf": [ + { + "$ref": "#/definitions/VerifiedMeasurements" + } + ] + }, + "mpc_image_hash": { + "description": "The digest of the MPC image running.", + "allOf": [ + { + "$ref": "#/definitions/DockerImageHash" + } + ] + } + } + }, + "VerifiedMeasurements": { + "type": "object", + "required": [ + "key_provider_event_digest", + "mrtd", + "rtmr0", + "rtmr1", + "rtmr2" + ], + "properties": { + "key_provider_event_digest": { + "$ref": "#/definitions/Sha384Digest" + }, + "mrtd": { + "$ref": "#/definitions/Sha384Digest" + }, + "rtmr0": { + "$ref": "#/definitions/Sha384Digest" + }, + "rtmr1": { + "$ref": "#/definitions/Sha384Digest" + }, + "rtmr2": { + "$ref": "#/definitions/Sha384Digest" + } + } + }, + "VerifyForeignTransactionRequest": { + "type": "object", + "required": [ + "domain_id", + "payload_version", + "request" + ], + "properties": { + "domain_id": { + "$ref": "#/definitions/DomainId" + }, + "payload_version": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "request": { + "$ref": "#/definitions/ForeignChainRpcRequest" + } + } + }, + "VerifyForeignTransactionRequestArgs": { + "type": "object", + "required": [ + "domain_id", + "payload_version", + "request" + ], + "properties": { + "domain_id": { + "$ref": "#/definitions/DomainId" + }, + "payload_version": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "request": { + "$ref": "#/definitions/ForeignChainRpcRequest" + } + } + }, + "VerifyForeignTransactionResponse": { + "type": "object", + "required": [ + "payload_hash", + "signature" + ], + "properties": { + "payload_hash": { + "$ref": "#/definitions/Hash256" + }, + "signature": { + "$ref": "#/definitions/SignatureResponse" + } + } + }, + "YieldIndex": { + "description": "The index into calling the YieldResume feature of NEAR. This will allow to resume a yield call after the contract has been called back via this index.", + "type": "object", + "required": [ + "data_id" + ], + "properties": { + "data_id": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "maxItems": 32, + "minItems": 32 + } + } + } + } + } + } +} diff --git a/crates/devnet/src/cli.rs b/crates/devnet/src/cli.rs index 3dddd2a5f9..4577f06032 100644 --- a/crates/devnet/src/cli.rs +++ b/crates/devnet/src/cli.rs @@ -298,6 +298,21 @@ pub struct MpcVoteNewParametersCmd { /// The indices of the voters; leave empty to vote from every other participant. #[clap(long, value_delimiter = ',')] pub voters: Vec, + /// Optional per-domain reconstruction-threshold overlay, given as + /// repeated `DOMAIN_ID:THRESHOLD` pairs (e.g. + /// `--per-domain-threshold 0:6 --per-domain-threshold 1:5`). + /// Omitting it preserves every domain's existing threshold; supplying it + /// changes those listed and re-validates the rest against the new + /// participant count. + #[clap(long = "per-domain-threshold", value_parser = parse_per_domain_threshold)] + pub per_domain_thresholds: Vec<(u64, u64)>, +} + +fn parse_per_domain_threshold(s: &str) -> anyhow::Result<(u64, u64)> { + let (id, t) = s + .split_once(':') + .ok_or_else(|| anyhow::anyhow!("expected DOMAIN_ID:THRESHOLD, got `{s}`"))?; + Ok((id.parse()?, t.parse()?)) } #[derive(clap::Parser)] diff --git a/crates/devnet/src/mpc.rs b/crates/devnet/src/mpc.rs index 8221fcd228..60c716629a 100644 --- a/crates/devnet/src/mpc.rs +++ b/crates/devnet/src/mpc.rs @@ -348,6 +348,7 @@ impl MpcInitContractCmd { participants: participant_entries, }, threshold: Threshold::new(self.threshold), + per_domain_thresholds: std::collections::BTreeMap::new(), }; let args = serde_json::to_vec(&InitV2Args { parameters, @@ -731,9 +732,20 @@ impl MpcVoteNewParametersCmd { } else { parameters.threshold }; + let per_domain_thresholds: std::collections::BTreeMap<_, _> = self + .per_domain_thresholds + .iter() + .map(|(id, t)| { + ( + near_mpc_contract_interface::types::DomainId(*id), + near_mpc_contract_interface::types::ReconstructionThreshold::new(*t), + ) + }) + .collect(); let proposal = ThresholdParameters { participants, threshold, + per_domain_thresholds, }; let from_accounts = get_voter_account_ids(mpc_setup, &self.voters); diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index 3cc7c00dfa..3a1d4d5b1d 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -502,6 +502,7 @@ impl MpcCluster { let proposal = ThresholdParameters { threshold: Threshold(new_threshold as u64), participants, + per_domain_thresholds: std::collections::BTreeMap::new(), }; tracing::info!(?prospective_epoch_id, new_threshold, "voting for resharing"); @@ -1119,6 +1120,7 @@ async fn init_contract( let params = ThresholdParameters { threshold: Threshold(threshold as u64), participants, + per_domain_thresholds: std::collections::BTreeMap::new(), }; tracing::info!( diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index 9d6b19f3d3..bfbea4baee 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -238,14 +238,48 @@ pub use near_mpc_crypto_types::{KeyForDomain, Keyset}; // ============================================================================= /// Threshold parameters for distributed key operations. +/// +/// `per_domain_thresholds` carries a proposed update for each domain's +/// [`ReconstructionThreshold`] when this struct flows into +/// `vote_new_parameters`. An empty map means "keep current per-domain +/// thresholds"; a populated map must cover every existing domain (validated by +/// the contract). Outside of resharing proposals the map is empty. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(schemars::JsonSchema) )] +#[serde(from = "ThresholdParametersCompat")] pub struct ThresholdParameters { pub participants: Participants, pub threshold: Threshold, + // Deserialization goes through `ThresholdParametersCompat` (see + // `#[serde(from = …)]` above), which defaults this field for legacy + // JSON. The `skip_serializing_if` keeps the legacy wire shape on the + // serialize path when no overlay is in flight. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub per_domain_thresholds: BTreeMap, +} + +/// Backwards-compat for the legacy `{ participants, threshold }` JSON shape: +/// `per_domain_thresholds` is defaulted to an empty map. New JSON includes the +/// field directly. +#[derive(Deserialize)] +struct ThresholdParametersCompat { + participants: Participants, + threshold: Threshold, + #[serde(default)] + per_domain_thresholds: BTreeMap, +} + +impl From for ThresholdParameters { + fn from(c: ThresholdParametersCompat) -> Self { + Self { + participants: c.participants, + threshold: c.threshold, + per_domain_thresholds: c.per_domain_thresholds, + } + } } // ============================================================================= diff --git a/crates/node/src/indexer/participants.rs b/crates/node/src/indexer/participants.rs index c0bf8ec592..1ef9641ddd 100644 --- a/crates/node/src/indexer/participants.rs +++ b/crates/node/src/indexer/participants.rs @@ -490,6 +490,7 @@ mod tests { let params = ThresholdParameters { participants: chain_infos.clone(), threshold: Threshold(3), + per_domain_thresholds: std::collections::BTreeMap::new(), }; let converted = convert_participant_infos(params, None).unwrap(); @@ -516,6 +517,7 @@ mod tests { let params = ThresholdParameters { participants: chain_infos, threshold: Threshold(3), + per_domain_thresholds: std::collections::BTreeMap::new(), }; let converted = convert_participant_infos(params.clone(), None) .unwrap() @@ -553,6 +555,7 @@ mod tests { let params = ThresholdParameters { participants: new_infos, threshold: Threshold(3), + per_domain_thresholds: std::collections::BTreeMap::new(), }; print!("\n\nmy params: \n{:?}\n", params); let converted = convert_participant_infos(params, None); From 7feb1b1d24d9f0aeebc84d005f4ed21b5ef5078b Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 22 May 2026 14:23:20 +0200 Subject: [PATCH 02/34] Useless compat struct --- .../src/types/state.rs | 33 ++++--------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index bfbea4baee..761d4440f3 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -244,44 +244,23 @@ pub use near_mpc_crypto_types::{KeyForDomain, Keyset}; /// `vote_new_parameters`. An empty map means "keep current per-domain /// thresholds"; a populated map must cover every existing domain (validated by /// the contract). Outside of resharing proposals the map is empty. +/// +/// Backwards-compat for the legacy `{ participants, threshold }` wire shape is +/// intrinsic: `serde(default)` parses old JSON without `per_domain_thresholds` +/// as an empty map, and `skip_serializing_if` omits the field from `state()` +/// output when no overlay is in flight. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(schemars::JsonSchema) )] -#[serde(from = "ThresholdParametersCompat")] pub struct ThresholdParameters { pub participants: Participants, pub threshold: Threshold, - // Deserialization goes through `ThresholdParametersCompat` (see - // `#[serde(from = …)]` above), which defaults this field for legacy - // JSON. The `skip_serializing_if` keeps the legacy wire shape on the - // serialize path when no overlay is in flight. - #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub per_domain_thresholds: BTreeMap, } -/// Backwards-compat for the legacy `{ participants, threshold }` JSON shape: -/// `per_domain_thresholds` is defaulted to an empty map. New JSON includes the -/// field directly. -#[derive(Deserialize)] -struct ThresholdParametersCompat { - participants: Participants, - threshold: Threshold, - #[serde(default)] - per_domain_thresholds: BTreeMap, -} - -impl From for ThresholdParameters { - fn from(c: ThresholdParametersCompat) -> Self { - Self { - participants: c.participants, - threshold: c.threshold, - per_domain_thresholds: c.per_domain_thresholds, - } - } -} - // ============================================================================= // Voting Types // ============================================================================= From a9bd4eb43db775667b6eee0a86be2aba554f234e Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 22 May 2026 14:29:32 +0200 Subject: [PATCH 03/34] Adding comment --- crates/near-mpc-contract-interface/src/types/state.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index 761d4440f3..86d0d2056a 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -257,6 +257,7 @@ pub use near_mpc_crypto_types::{KeyForDomain, Keyset}; pub struct ThresholdParameters { pub participants: Participants, pub threshold: Threshold, + // The following skip_serializing_if should be deleted after release 3.11.0 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub per_domain_thresholds: BTreeMap, } From b48bbf17ecdce7182e983b7f8ebbf5e1a6e8a67e Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 22 May 2026 14:35:03 +0200 Subject: [PATCH 04/34] Accepted snap --- .../snapshots/abi__abi_has_not_changed.snap | 10 +- .../abi__abi_has_not_changed.snap.new | 4172 ----------------- 2 files changed, 9 insertions(+), 4173 deletions(-) delete mode 100644 crates/contract/tests/snapshots/abi__abi_has_not_changed.snap.new diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 07259d8db5..edc429b152 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -3897,7 +3897,7 @@ expression: abi "minimum": 0.0 }, "ThresholdParameters": { - "description": "Threshold parameters for distributed key operations.", + "description": "Threshold parameters for distributed key operations.\n\n`per_domain_thresholds` carries a proposed update for each domain's [`ReconstructionThreshold`] when this struct flows into `vote_new_parameters`. An empty map means \"keep current per-domain thresholds\"; a populated map must cover every existing domain (validated by the contract). Outside of resharing proposals the map is empty.", "type": "object", "required": [ "participants", @@ -3907,6 +3907,14 @@ expression: abi "participants": { "$ref": "#/definitions/Participants" }, + "per_domain_thresholds": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, "threshold": { "$ref": "#/definitions/Threshold" } diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap.new b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap.new deleted file mode 100644 index 65fa7c7311..0000000000 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap.new +++ /dev/null @@ -1,4172 +0,0 @@ ---- -source: crates/contract/tests/abi.rs -assertion_line: 47 -expression: abi ---- -{ - "schema_version": "0.4.0", - "metadata": { - "name": "mpc-contract", - "version": "3.10.0", - "build": { - "compiler": "rustc 1.86.0", - "builder": "[CARGO_NEAR_BUILD_VERSION]" - }, - "wasm_hash": "[WASM_HASH]" - }, - "body": { - "functions": [ - { - "name": "allowed_docker_image_hashes", - "doc": " Returns all allowed code hashes in order from most recent to least recent allowed code hashes. The first element is the most recent allowed code hash.", - "kind": "view", - "result": { - "serialization_type": "json", - "type_schema": { - "type": "array", - "items": { - "$ref": "#/definitions/DockerImageHash" - } - } - } - }, - { - "name": "allowed_launcher_compose_hashes", - "kind": "view", - "result": { - "serialization_type": "json", - "type_schema": { - "type": "array", - "items": { - "$ref": "#/definitions/LauncherDockerComposeHash" - } - } - } - }, - { - "name": "allowed_launcher_image_hashes", - "kind": "view", - "result": { - "serialization_type": "json", - "type_schema": { - "type": "array", - "items": { - "$ref": "#/definitions/LauncherImageHash" - } - } - } - }, - { - "name": "allowed_os_measurements", - "doc": " Returns all currently allowed OS measurements.", - "kind": "view", - "result": { - "serialization_type": "json", - "type_schema": { - "type": "array", - "items": { - "$ref": "#/definitions/ContractExpectedMeasurements" - } - } - } - }, - { - "name": "clean_foreign_chain_data", - "doc": " Private endpoint to clean up foreign chain policy votes and node configurations\n for non-participants after resharing.\n This can only be called by the contract itself via a promise.", - "kind": "call", - "modifiers": [ - "private" - ], - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "clean_invalid_attestations", - "doc": " Prunes up to `max_scan` stored attestations that fail re-verification (expired or\n referencing stale whitelists). Returns the number of entries removed. Callable by\n anyone while the protocol is in `Running`.", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "max_scan", - "type_schema": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - } - } - }, - { - "name": "clean_tee_status", - "doc": " Private endpoint to drop votes cast by non-participants after resharing.\n Attestation cleanup is handled separately by [`MpcContract::clean_invalid_attestations`].", - "kind": "call", - "modifiers": [ - "private" - ], - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "cleanup_orphaned_node_migrations", - "kind": "call", - "modifiers": [ - "private" - ], - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "code_hash_votes", - "doc": " Returns the current code hash votes, showing each participant's vote.", - "kind": "view", - "result": { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/CodeHashesVotes" - } - } - }, - { - "name": "conclude_node_migration", - "doc": " Finalizes a node migration for the calling account.\n\n This method can only be called while the protocol is in a `Running` state\n and by an existing participant. On success, the participant’s information is\n updated to the new destination node.\n\n # Errors\n Returns the following errors:\n - `InvalidState::ProtocolStateNotRunning`: if protocol is not in `Running` state\n - `InvalidState::NotParticipant`: if caller is not a current participant\n - `NodeMigrationError::KeysetMismatch`: if provided keyset does not match the expected keyset\n - `NodeMigrationError::MigrationNotFound`: if no migration record exists for the caller\n - `NodeMigrationError::AccountPublicKeyMismatch`: if caller’s public key does not match the expected destination node\n - `InvalidParameters::InvalidTeeRemoteAttestation`: if destination node’s TEE quote is invalid", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "keyset", - "type_schema": { - "$ref": "#/definitions/Keyset" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "config", - "kind": "view", - "result": { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/Config" - } - } - }, - { - "name": "contract_source_metadata", - "kind": "view" - }, - { - "name": "derived_public_key", - "doc": " This is the derived public key of the caller given path and predecessor\n if predecessor is not provided, it will be the caller of the contract.\n\n The domain parameter specifies which domain we're deriving the public key for;\n the default is the first domain.", - "kind": "view", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "path", - "type_schema": { - "type": "string" - } - }, - { - "name": "predecessor", - "type_schema": { - "description": "NEAR Account Identifier.\n\nThis is a unique, syntactically valid, human-readable account identifier on the NEAR network.\n\n[See the crate-level docs for information about validation.](index.html#account-id-rules)\n\nAlso see [Error kind precedence](AccountId#error-kind-precedence).\n\n## Examples\n\n``` use near_account_id::AccountId;\n\nlet alice: AccountId = \"alice.near\".parse().unwrap();\n\nassert!(\"ƒelicia.near\".parse::().is_err()); // (ƒ is not f) ```", - "type": [ - "string", - "null" - ] - } - }, - { - "name": "domain_id", - "type_schema": { - "anyOf": [ - { - "$ref": "#/definitions/DomainId" - }, - { - "type": "null" - } - ] - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/PublicKey" - } - } - }, - { - "name": "fail_on_timeout", - "kind": "view", - "modifiers": [ - "private" - ] - }, - { - "name": "get_attestation", - "kind": "view", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "tls_public_key", - "type_schema": { - "$ref": "#/definitions/Ed25519PublicKey" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "anyOf": [ - { - "$ref": "#/definitions/VerifiedAttestation" - }, - { - "type": "null" - } - ] - } - } - }, - { - "name": "get_foreign_chain_support_by_node", - "kind": "view", - "result": { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/ForeignChainSupportByNode" - } - } - }, - { - "name": "get_pending_ckd_request", - "doc": " Presence check for a pending CKD request, exposed as a view call.\n\n See [`Self::get_pending_request`] for the contract: the returned `YieldIndex`\n is an arbitrary representative of a fan-out queue, not \"the\" yield. Only the\n `Some`/`None` distinction is meaningful.", - "kind": "view", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "request", - "type_schema": { - "$ref": "#/definitions/CKDRequest" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "anyOf": [ - { - "$ref": "#/definitions/YieldIndex" - }, - { - "type": "null" - } - ] - } - } - }, - { - "name": "get_pending_request", - "doc": " Presence check for a pending signature request, exposed as a view call.\n\n **The returned `YieldIndex` is an arbitrary representative, not \"the\" yield\n for this request.** Since the duplicate-request fan-out feature (PR #3187),\n a single request key can have N queued yields; this method returns the head of the\n queue. Callers that need to act on the full set are wrong to use this. The\n only correct interpretation is presence: `Some(_)` vs `None`.\n\n The `Option` shape is retained for JSON wire compatibility with\n out-of-tree consumers; the in-tree caller (`tx_sender::observe_tx_result`)\n only matches on presence. Prefer a `bool`-shaped accessor if one is added.\n\n Falls back to the legacy single-yield map for in-flight requests inherited\n from before the fan-out upgrade.", - "kind": "view", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "request", - "type_schema": { - "$ref": "#/definitions/SignatureRequest" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "anyOf": [ - { - "$ref": "#/definitions/YieldIndex" - }, - { - "type": "null" - } - ] - } - } - }, - { - "name": "get_pending_verify_foreign_tx_request", - "doc": " Presence check for a pending foreign-tx verification request, exposed as a\n view call.\n\n See [`Self::get_pending_request`] for the contract: the returned `YieldIndex`\n is an arbitrary representative of a fan-out queue, not \"the\" yield. Only the\n `Some`/`None` distinction is meaningful.", - "kind": "view", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "request", - "type_schema": { - "$ref": "#/definitions/VerifyForeignTransactionRequest" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "anyOf": [ - { - "$ref": "#/definitions/YieldIndex" - }, - { - "type": "null" - } - ] - } - } - }, - { - "name": "get_supported_foreign_chains", - "kind": "view", - "result": { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/SupportedForeignChains" - } - } - }, - { - "name": "get_tee_accounts", - "doc": " Returns all accounts that have TEE attestations stored in the contract.\n Note: This includes both current protocol participants and accounts that may have\n submitted TEE information but are not currently part of the active participant set.", - "kind": "view", - "result": { - "serialization_type": "json", - "type_schema": { - "type": "array", - "items": { - "$ref": "#/definitions/NodeId" - } - } - } - }, - { - "name": "init", - "kind": "call", - "modifiers": [ - "init" - ], - "params": { - "serialization_type": "json", - "args": [ - { - "name": "parameters", - "type_schema": { - "$ref": "#/definitions/ThresholdParameters" - } - }, - { - "name": "init_config", - "type_schema": { - "anyOf": [ - { - "$ref": "#/definitions/InitConfig" - }, - { - "type": "null" - } - ] - } - } - ] - } - }, - { - "name": "init_running", - "kind": "call", - "modifiers": [ - "init", - "private" - ], - "params": { - "serialization_type": "json", - "args": [ - { - "name": "domains", - "type_schema": { - "type": "array", - "items": { - "$ref": "#/definitions/DomainConfig" - } - } - }, - { - "name": "next_domain_id", - "type_schema": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - }, - { - "name": "keyset", - "type_schema": { - "$ref": "#/definitions/Keyset" - } - }, - { - "name": "parameters", - "type_schema": { - "$ref": "#/definitions/ThresholdParameters" - } - }, - { - "name": "init_config", - "type_schema": { - "anyOf": [ - { - "$ref": "#/definitions/InitConfig" - }, - { - "type": "null" - } - ] - } - } - ] - } - }, - { - "name": "latest_key_version", - "doc": " Key versions refer new versions of the root key that we may choose to generate on cohort\n changes. Older key versions will always work but newer key versions were never held by\n older signers. Newer key versions may also add new security features, like only existing\n within a secure enclave. The signature_scheme parameter specifies which protocol\n we're querying the latest version for. The default is Secp256k1. The default is **NOT**\n to query across all protocols.", - "kind": "view", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "signature_scheme", - "type_schema": { - "anyOf": [ - { - "$ref": "#/definitions/Curve" - }, - { - "type": "null" - } - ] - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - } - } - }, - { - "name": "launcher_hash_votes", - "doc": " Returns the current launcher hash votes, showing each participant's vote.", - "kind": "view", - "result": { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/LauncherHashVotes" - } - } - }, - { - "name": "metrics", - "kind": "view", - "result": { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/Metrics" - } - } - }, - { - "name": "migrate", - "doc": " This will be called internally by the contract to migrate the state when a new contract\n is deployed. This function should be changed every time state is changed to do the proper\n migrate flow.\n\n If nothing is changed, then this function will just return the current state. If it fails\n to read the state, then it will return an error.", - "kind": "call", - "modifiers": [ - "init", - "private" - ] - }, - { - "name": "migration_info", - "kind": "view", - "result": { - "serialization_type": "json", - "type_schema": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": [ - { - "anyOf": [ - { - "$ref": "#/definitions/BackupServiceInfo" - }, - { - "type": "null" - } - ] - }, - { - "anyOf": [ - { - "$ref": "#/definitions/DestinationNodeInfo" - }, - { - "type": "null" - } - ] - } - ], - "maxItems": 2, - "minItems": 2 - } - } - } - }, - { - "name": "os_measurement_votes", - "doc": " Returns the current OS measurement votes, showing each participant's vote.", - "kind": "view", - "result": { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/MeasurementVotes" - } - } - }, - { - "name": "propose_update", - "doc": " Propose update to either code or config, but not both of them at the same time.", - "kind": "call", - "modifiers": [ - "payable" - ], - "params": { - "serialization_type": "borsh", - "args": [ - { - "name": "args", - "type_schema": { - "declaration": "ProposeUpdateArgs", - "definitions": { - "()": { - "Primitive": 0 - }, - "Config": { - "Struct": [ - [ - "key_event_timeout_blocks", - "u64" - ], - [ - "tee_upgrade_deadline_duration_seconds", - "u64" - ], - [ - "contract_upgrade_deposit_tera_gas", - "u64" - ], - [ - "sign_call_gas_attachment_requirement_tera_gas", - "u64" - ], - [ - "ckd_call_gas_attachment_requirement_tera_gas", - "u64" - ], - [ - "return_signature_and_clean_state_on_success_call_tera_gas", - "u64" - ], - [ - "return_ck_and_clean_state_on_success_call_tera_gas", - "u64" - ], - [ - "fail_on_timeout_tera_gas", - "u64" - ], - [ - "clean_tee_status_tera_gas", - "u64" - ], - [ - "clean_invalid_attestations_tera_gas", - "u64" - ], - [ - "cleanup_orphaned_node_migrations_tera_gas", - "u64" - ], - [ - "remove_non_participant_update_votes_tera_gas", - "u64" - ], - [ - "clean_foreign_chain_data_tera_gas", - "u64" - ] - ] - }, - "Option": { - "Enum": { - "tag_width": 1, - "variants": [ - [ - 0, - "None", - "()" - ], - [ - 1, - "Some", - "Config" - ] - ] - } - }, - "Option>": { - "Enum": { - "tag_width": 1, - "variants": [ - [ - 0, - "None", - "()" - ], - [ - 1, - "Some", - "Vec" - ] - ] - } - }, - "ProposeUpdateArgs": { - "Struct": [ - [ - "code", - "Option>" - ], - [ - "config", - "Option" - ] - ] - }, - "Vec": { - "Sequence": { - "length_width": 4, - "length_range": { - "start": 0, - "end": 4294967295 - }, - "elements": "u8" - } - }, - "u64": { - "Primitive": 8 - }, - "u8": { - "Primitive": 1 - } - } - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/UpdateId" - } - } - }, - { - "name": "proposed_updates", - "doc": " returns all proposed updates", - "kind": "view", - "result": { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/ProposedUpdates" - } - } - }, - { - "name": "public_key", - "doc": " This is the root public key combined from all the public keys of the participants.\n The domain parameter specifies which domain we're querying the public key for;\n the default is the first domain.", - "kind": "view", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "domain_id", - "type_schema": { - "anyOf": [ - { - "$ref": "#/definitions/DomainId" - }, - { - "type": "null" - } - ] - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/PublicKey" - } - } - }, - { - "name": "register_backup_service", - "doc": " Registers or updates the backup service information for the caller account.\n\n The caller (`signer_account_id`) must be an existing or prospective participant.\n Otherwise, the transaction will fail.\n\n # Notes\n - A deposit requirement may be added in the future.", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "backup_service_info", - "type_schema": { - "$ref": "#/definitions/BackupServiceInfo" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "register_foreign_chain_config", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "foreign_chain_configuration", - "type_schema": { - "$ref": "#/definitions/ForeignChainConfiguration" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "register_foreign_chain_support", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "foreign_chain_support", - "type_schema": { - "$ref": "#/definitions/SupportedForeignChains" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "remove_non_participant_update_votes", - "doc": " Cleans update votes from non-participants after resharing.\n Can be called by any participant or triggered automatically via promise.", - "kind": "call", - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "remove_update_vote", - "doc": " Removes an update vote by the caller\n panics if the contract is not in a running state or if the caller is not a participant", - "kind": "call" - }, - { - "name": "request_app_private_key", - "doc": " To avoid overloading the network with too many requests,\n we ask for a small deposit for each ckd request.\n\n Note: identity points are accepted in `AppPublicKeyPV` to support use cases\n where the derived key is intentionally public (no encryption).", - "kind": "call", - "modifiers": [ - "payable" - ], - "params": { - "serialization_type": "json", - "args": [ - { - "name": "request", - "type_schema": { - "$ref": "#/definitions/CKDRequestArgs" - } - } - ] - } - }, - { - "name": "respond", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "request", - "type_schema": { - "$ref": "#/definitions/SignatureRequest" - } - }, - { - "name": "response", - "type_schema": { - "$ref": "#/definitions/SignatureResponse" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "respond_ckd", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "request", - "type_schema": { - "$ref": "#/definitions/CKDRequest" - } - }, - { - "name": "response", - "type_schema": { - "$ref": "#/definitions/CKDResponse" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "respond_verify_foreign_tx", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "request", - "type_schema": { - "$ref": "#/definitions/VerifyForeignTransactionRequest" - } - }, - { - "name": "response", - "type_schema": { - "$ref": "#/definitions/VerifyForeignTransactionResponse" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "return_ck_and_clean_state_on_success", - "doc": " Yield-resume callback for a single queued CKD request.\n\n On success, returns the confidential key to the original caller. On timeout,\n pops this yield's slot (the head of the FIFO fan-out queue) from the\n pending-request map — falling back to the legacy single-yield map for\n pre-fan-out entries — and fires `fail_on_timeout` to fail the original\n transaction. Sibling yields queued under the same request key remain pending\n and are cleaned up by their own timeouts (or drained together by a subsequent\n `respond_ckd`).", - "kind": "call", - "modifiers": [ - "private" - ], - "params": { - "serialization_type": "json", - "args": [ - { - "name": "request", - "type_schema": { - "$ref": "#/definitions/CKDRequest" - } - } - ] - }, - "callbacks": [ - { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/CKDResponse" - } - } - ], - "result": { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/PromiseOrValueCKDResponse" - } - } - }, - { - "name": "return_signature_and_clean_state_on_success", - "doc": " Yield-resume callback for a single queued `sign` request.\n\n On success, returns the signature to the original caller. On timeout, pops this\n yield's slot (the head of the FIFO fan-out queue) from the pending-request map\n — falling back to the legacy single-yield map for pre-fan-out entries — and\n fires `fail_on_timeout` to fail the original transaction. Sibling yields queued\n under the same request key remain pending and are cleaned up by their own\n timeouts (or drained together by a subsequent `respond`).", - "kind": "call", - "modifiers": [ - "private" - ], - "params": { - "serialization_type": "json", - "args": [ - { - "name": "request", - "type_schema": { - "$ref": "#/definitions/SignatureRequest" - } - } - ] - }, - "callbacks": [ - { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/SignatureResponse" - } - } - ], - "result": { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/PromiseOrValueSignatureResponse" - } - } - }, - { - "name": "return_verify_foreign_tx_and_clean_state_on_success", - "doc": " Yield-resume callback for a single queued foreign-tx verification request.\n\n On success, returns the verification response to the original caller. On\n timeout, pops this yield's slot (the head of the FIFO fan-out queue) from the\n pending-request map — falling back to the legacy single-yield map for\n pre-fan-out entries — and fires `fail_on_timeout` to fail the original\n transaction. Sibling yields queued under the same request key remain pending\n and are cleaned up by their own timeouts (or drained together by a subsequent\n `respond_verify_foreign_tx`).", - "kind": "call", - "modifiers": [ - "private" - ], - "params": { - "serialization_type": "json", - "args": [ - { - "name": "request", - "type_schema": { - "$ref": "#/definitions/VerifyForeignTransactionRequest" - } - } - ] - }, - "callbacks": [ - { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/VerifyForeignTransactionResponse" - } - } - ], - "result": { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/PromiseOrValueVerifyForeignTransactionResponse" - } - } - }, - { - "name": "sign", - "doc": " `key_version` must be less than or equal to the value at `latest_key_version`\n To avoid overloading the network with too many requests,\n we ask for a small deposit for each signature request.", - "kind": "call", - "modifiers": [ - "payable" - ], - "params": { - "serialization_type": "json", - "args": [ - { - "name": "request", - "type_schema": { - "$ref": "#/definitions/SignRequestArgs" - } - } - ] - } - }, - { - "name": "start_keygen_instance", - "doc": " Starts a new attempt to generate a key for the current domain.\n This only succeeds if the signer is the leader (the participant with the lowest ID).", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "key_event_id", - "type_schema": { - "$ref": "#/definitions/KeyEventId" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "start_node_migration", - "doc": " Sets the destination node for the calling account.\n\n This function can only be called while the protocol is in a `Running` state.\n The signer must be a current participant of the current epoch, otherwise an error is returned.\n On success, the provided `DestinationNodeInfo` is stored in the contract state\n under the signer’s account ID.\n\n # Errors\n - [`InvalidState::ProtocolStateNotRunning`] if the protocol is not in the `Running` state.\n - [`InvalidState::NotParticipant`] if the signer is not a current participant.\n # Note:\n - might require a deposit", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "destination_node_info", - "type_schema": { - "$ref": "#/definitions/DestinationNodeInfo" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "start_reshare_instance", - "doc": " Starts a new attempt to reshare the key for the current domain.\n This only succeeds if the signer is the leader (the participant with the lowest ID).", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "key_event_id", - "type_schema": { - "$ref": "#/definitions/KeyEventId" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "state", - "kind": "view", - "result": { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/ProtocolContractState" - } - } - }, - { - "name": "submit_participant_info", - "doc": " (Prospective) Participants can submit their tee participant information through this\n endpoint.", - "kind": "call", - "modifiers": [ - "payable" - ], - "params": { - "serialization_type": "json", - "args": [ - { - "name": "proposed_participant_attestation", - "type_schema": { - "$ref": "#/definitions/Attestation" - } - }, - { - "name": "tls_public_key", - "type_schema": { - "$ref": "#/definitions/Ed25519PublicKey" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "update_config", - "kind": "call", - "modifiers": [ - "private" - ], - "params": { - "serialization_type": "json", - "args": [ - { - "name": "config", - "type_schema": { - "$ref": "#/definitions/Config" - } - } - ] - } - }, - { - "name": "verify_foreign_transaction", - "doc": " Submit a verification + signing request for a foreign chain transaction.\n MPC nodes will verify the transaction on the foreign chain before signing.\n The signed payload is derived from the transaction ID (hash of tx_id).", - "kind": "call", - "modifiers": [ - "payable" - ], - "params": { - "serialization_type": "json", - "args": [ - { - "name": "request", - "type_schema": { - "$ref": "#/definitions/VerifyForeignTransactionRequestArgs" - } - } - ] - } - }, - { - "name": "verify_tee", - "doc": " Verifies if all current participants have an accepted TEE state.\n Automatically enters a resharing, in case one or more participants do not have an accepted\n TEE state.\n Returns `false` and stops the contract from accepting new signature requests or responses,\n in case less than `threshold` participants run in an accepted TEE State.", - "kind": "call", - "result": { - "serialization_type": "json", - "type_schema": { - "type": "boolean" - } - } - }, - { - "name": "version", - "kind": "view", - "result": { - "serialization_type": "json", - "type_schema": { - "type": "string" - } - } - }, - { - "name": "vote_abort_key_event_instance", - "doc": " Casts a vote to abort the current key event instance. If succesful, the contract aborts the\n instance and a new instance with the next attempt_id can be started.", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "key_event_id", - "type_schema": { - "$ref": "#/definitions/KeyEventId" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "vote_add_domains", - "doc": " Propose adding a new set of domains for the MPC network.\n If a threshold number of votes are reached on the exact same proposal, this will transition\n the contract into the Initializing state to generate keys for the new domains.\n\n The specified list of domains must have increasing and contiguous IDs, and the first ID\n must be the same as the `next_domain_id` returned by state().", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "domains", - "type_schema": { - "type": "array", - "items": { - "$ref": "#/definitions/DomainConfig" - } - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "vote_add_launcher_hash", - "doc": " Vote to add a new launcher image hash to the allowed set. Requires threshold votes.\n When the threshold is reached, compose hashes are automatically derived for all\n currently allowed MPC image hashes.", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "launcher_hash", - "type_schema": { - "$ref": "#/definitions/LauncherImageHash" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "vote_add_os_measurement", - "doc": " Vote to add a new OS measurement set to the allowed list. Requires threshold votes.", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "measurement", - "type_schema": { - "$ref": "#/definitions/ContractExpectedMeasurements" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "vote_cancel_keygen", - "doc": " Casts a vote to cancel key generation. Any keys that have already been generated\n are kept and we transition into Running state; remaining domains are permanently deleted.\n Deleted domain IDs cannot be reused again in future calls to vote_add_domains.\n\n A next_domain_id that matches that in the state's domains struct must be passed in. This is\n to prevent stale requests from accidentally cancelling a future key generation state.", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "next_domain_id", - "type_schema": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "vote_cancel_resharing", - "doc": " Casts a vote to cancel the current key resharing. If a threshold number of unique\n votes are collected to cancel the resharing, the contract state will revert back to the\n previous running state.\n\n - This method is idempotent, meaning a single account can not make more than one vote.\n - Only nodes from the previous running state are allowed to vote.\n\n Return value:\n - [Ok] if the vote was successfully collected.\n - [Err] if:\n - The signer is not a participant in the previous running state.\n - The contract is not in a resharing state.", - "kind": "call", - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "vote_code_hash", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "code_hash", - "type_schema": { - "$ref": "#/definitions/DockerImageHash" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "vote_new_parameters", - "doc": " Propose a new set of parameters (participants and threshold) for the MPC network.\n If a threshold number of votes are reached on the exact same proposal, this will transition\n the contract into the Resharing state.\n\n The epoch_id must be equal to 1 plus the current epoch ID (if Running) or prospective epoch\n ID (if Resharing). Otherwise the vote is ignored. This is to prevent late transactions from\n accidentally voting on outdated proposals.", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "prospective_epoch_id", - "type_schema": { - "$ref": "#/definitions/EpochId" - } - }, - { - "name": "proposal", - "type_schema": { - "$ref": "#/definitions/ThresholdParameters" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "vote_pk", - "doc": " Casts a vote for `public_key` for the attempt identified by `key_event_id`.\n\n The effect of this method is either:\n - Returns error (which aborts with no changes), if there is no active key generation\n attempt (including if the attempt timed out), if the signer is not a participant, or if\n the key_event_id corresponds to a different domain, different epoch, or different attempt\n from the current key generation attempt.\n - Returns Ok(()), with one of the following changes:\n - A vote has been collected but we don't have enough votes yet.\n - This vote is for a public key that disagrees from an earlier voted public key, causing\n the attempt to abort; another call to `start` is then necessary.\n - Everyone has now voted for the same public key; the state transitions into generating a\n key for the next domain.\n - Same as the last case, except that all domains have a generated key now, and the state\n transitions into Running with the newly generated keys.", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "key_event_id", - "type_schema": { - "$ref": "#/definitions/KeyEventId" - } - }, - { - "name": "public_key", - "type_schema": { - "$ref": "#/definitions/PublicKey" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "vote_remove_launcher_hash", - "doc": " Vote to remove a launcher image hash from the allowed set. Requires ALL participants\n to vote for removal, since this invalidates attestations of nodes running that launcher.", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "launcher_hash", - "type_schema": { - "$ref": "#/definitions/LauncherImageHash" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "vote_remove_os_measurement", - "doc": " Vote to remove an OS measurement set from the allowed list. Requires ALL participants\n to vote for removal.", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "measurement", - "type_schema": { - "$ref": "#/definitions/ContractExpectedMeasurements" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "vote_reshared", - "doc": " Casts a vote for the successful resharing of the attempt identified by `key_event_id`.\n\n The effect of this method is either:\n - Returns error (which aborts with no changes), if there is no active key resharing attempt\n (including if the attempt timed out), if the signer is not a participant, or if the\n key_event_id corresponds to a different domain, different epoch, or different attempt\n from the current key resharing attempt.\n - Returns Ok(()), with one of the following changes:\n - A vote has been collected but we don't have enough votes yet.\n - Everyone has now voted; the state transitions into resharing the key for the next\n domain.\n - Same as the last case, except that all domains' keys have been reshared now, and the\n state transitions into Running with the newly reshared keys.", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "key_event_id", - "type_schema": { - "$ref": "#/definitions/KeyEventId" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "null" - } - } - }, - { - "name": "vote_update", - "doc": " Vote for a proposed update given the [`UpdateId`] of the update.\n\n Returns `Ok(true)` if the amount of voters surpassed the threshold and the update was\n executed. Returns `Ok(false)` if the amount of voters did not surpass the threshold.\n Returns [`Error`] if the update was not found or if the voter is not a participant\n in the protocol.", - "kind": "call", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "id", - "type_schema": { - "$ref": "#/definitions/UpdateId" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "boolean" - } - } - } - ], - "root_schema": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "String", - "type": "string", - "definitions": { - "AddDomainsVotes": { - "description": "Votes for adding new domains.", - "type": "object", - "required": [ - "proposal_by_account" - ], - "properties": { - "proposal_by_account": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/definitions/DomainConfig" - } - } - } - } - }, - "AttemptId": { - "description": "Attempt identifier within a key event. Incremented for each attempt within the same epoch and domain.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "Attestation": { - "oneOf": [ - { - "type": "object", - "required": [ - "Dstack" - ], - "properties": { - "Dstack": { - "$ref": "#/definitions/DstackAttestation" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Mock" - ], - "properties": { - "Mock": { - "$ref": "#/definitions/MockAttestation" - } - }, - "additionalProperties": false - } - ] - }, - "AuthenticatedAccountId": { - "description": "An account ID that has been authenticated (i.e., the caller is this account).", - "type": "string" - }, - "AuthenticatedParticipantId": { - "description": "A participant ID that has been authenticated (i.e., the caller is this participant).", - "allOf": [ - { - "$ref": "#/definitions/ParticipantId" - } - ] - }, - "BackupServiceInfo": { - "type": "object", - "required": [ - "public_key" - ], - "properties": { - "public_key": { - "$ref": "#/definitions/Ed25519PublicKey" - } - } - }, - "BitcoinExtractor": { - "type": "string", - "enum": [ - "BlockHash" - ] - }, - "BitcoinRpcRequest": { - "type": "object", - "required": [ - "confirmations", - "extractors", - "tx_id" - ], - "properties": { - "confirmations": { - "$ref": "#/definitions/BlockConfirmations" - }, - "extractors": { - "type": "array", - "items": { - "$ref": "#/definitions/BitcoinExtractor" - } - }, - "tx_id": { - "$ref": "#/definitions/BitcoinTxId" - } - } - }, - "BitcoinTxId": { - "type": "string", - "pattern": "^(?:[0-9A-Fa-f]{2})*$" - }, - "BlockConfirmations": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "Bls12381G1PublicKey": { - "type": "string" - }, - "Bls12381G2PublicKey": { - "type": "string" - }, - "CKDAppPublicKey": { - "oneOf": [ - { - "type": "object", - "required": [ - "AppPublicKey" - ], - "properties": { - "AppPublicKey": { - "$ref": "#/definitions/Bls12381G1PublicKey" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "AppPublicKeyPV" - ], - "properties": { - "AppPublicKeyPV": { - "$ref": "#/definitions/CKDAppPublicKeyPV" - } - }, - "additionalProperties": false - } - ] - }, - "CKDAppPublicKeyPV": { - "type": "object", - "required": [ - "pk1", - "pk2" - ], - "properties": { - "pk1": { - "$ref": "#/definitions/Bls12381G1PublicKey" - }, - "pk2": { - "$ref": "#/definitions/Bls12381G2PublicKey" - } - } - }, - "CKDRequest": { - "type": "object", - "required": [ - "app_id", - "app_public_key", - "domain_id" - ], - "properties": { - "app_id": { - "$ref": "#/definitions/CkdAppId" - }, - "app_public_key": { - "description": "The app ephemeral public key", - "allOf": [ - { - "$ref": "#/definitions/CKDAppPublicKey" - } - ] - }, - "domain_id": { - "$ref": "#/definitions/DomainId" - } - } - }, - "CKDRequestArgs": { - "type": "object", - "required": [ - "app_public_key", - "derivation_path", - "domain_id" - ], - "properties": { - "app_public_key": { - "$ref": "#/definitions/CKDAppPublicKey" - }, - "derivation_path": { - "type": "string" - }, - "domain_id": { - "$ref": "#/definitions/DomainId" - } - } - }, - "CKDResponse": { - "type": "object", - "required": [ - "big_c", - "big_y" - ], - "properties": { - "big_c": { - "$ref": "#/definitions/Bls12381G1PublicKey" - }, - "big_y": { - "$ref": "#/definitions/Bls12381G1PublicKey" - } - } - }, - "CkdAppId": { - "description": "AppId for CKD", - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "maxItems": 32, - "minItems": 32 - }, - "CodeHashesVotes": { - "description": "Tracks votes to add whitelisted TEE code hashes. Each participant can at any given time vote for a code hash to add.", - "type": "object", - "required": [ - "proposal_by_account" - ], - "properties": { - "proposal_by_account": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/DockerImageHash" - } - } - } - }, - "Collateral": { - "type": "object", - "required": [ - "pck_crl", - "pck_crl_issuer_chain", - "qe_identity", - "qe_identity_issuer_chain", - "qe_identity_signature", - "root_ca_crl", - "tcb_info", - "tcb_info_issuer_chain", - "tcb_info_signature" - ], - "properties": { - "pck_certificate_chain": { - "type": [ - "string", - "null" - ] - }, - "pck_crl": { - "type": "string", - "pattern": "^(?:[0-9A-Fa-f]{2})*$" - }, - "pck_crl_issuer_chain": { - "type": "string" - }, - "qe_identity": { - "type": "string" - }, - "qe_identity_issuer_chain": { - "type": "string" - }, - "qe_identity_signature": { - "type": "string", - "pattern": "^(?:[0-9A-Fa-f]{2})*$" - }, - "root_ca_crl": { - "type": "string", - "pattern": "^(?:[0-9A-Fa-f]{2})*$" - }, - "tcb_info": { - "type": "string" - }, - "tcb_info_issuer_chain": { - "type": "string" - }, - "tcb_info_signature": { - "type": "string", - "pattern": "^(?:[0-9A-Fa-f]{2})*$" - } - } - }, - "Config": { - "description": "Configuration parameters of the contract.", - "type": "object", - "required": [ - "ckd_call_gas_attachment_requirement_tera_gas", - "clean_foreign_chain_data_tera_gas", - "clean_invalid_attestations_tera_gas", - "clean_tee_status_tera_gas", - "cleanup_orphaned_node_migrations_tera_gas", - "contract_upgrade_deposit_tera_gas", - "fail_on_timeout_tera_gas", - "key_event_timeout_blocks", - "remove_non_participant_update_votes_tera_gas", - "return_ck_and_clean_state_on_success_call_tera_gas", - "return_signature_and_clean_state_on_success_call_tera_gas", - "sign_call_gas_attachment_requirement_tera_gas", - "tee_upgrade_deadline_duration_seconds" - ], - "properties": { - "ckd_call_gas_attachment_requirement_tera_gas": { - "description": "Prepaid gas for a `return_signature_and_clean_state_on_success` call.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "clean_foreign_chain_data_tera_gas": { - "description": "Prepaid gas for a `clean_foreign_chain_data` call.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "clean_invalid_attestations_tera_gas": { - "description": "Prepaid gas for the reshare-time `clean_invalid_attestations` promise.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "clean_tee_status_tera_gas": { - "description": "Prepaid gas for a `clean_tee_status` call.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "cleanup_orphaned_node_migrations_tera_gas": { - "description": "Prepaid gas for a `cleanup_orphaned_node_migrations` call.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "contract_upgrade_deposit_tera_gas": { - "description": "Amount of gas to deposit for contract and config updates.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "fail_on_timeout_tera_gas": { - "description": "Prepaid gas for a `fail_on_timeout` call.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "key_event_timeout_blocks": { - "description": "If a key event attempt has not successfully completed within this many blocks, it is considered failed.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "remove_non_participant_update_votes_tera_gas": { - "description": "Prepaid gas for a `remove_non_participant_update_votes` call.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "return_ck_and_clean_state_on_success_call_tera_gas": { - "description": "Prepaid gas for a `return_ck_and_clean_state_on_success` call.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "return_signature_and_clean_state_on_success_call_tera_gas": { - "description": "Prepaid gas for a `return_signature_and_clean_state_on_success` call.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "sign_call_gas_attachment_requirement_tera_gas": { - "description": "Gas required for a sign request.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "tee_upgrade_deadline_duration_seconds": { - "description": "The grace period duration for expiry of old mpc image hashes once a new one is added.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - } - }, - "ContractExpectedMeasurements": { - "description": "On-chain representation of expected TDX measurements. Mirrors [`mpc_attestation::attestation::ExpectedMeasurements`] with contract-compatible serialization (hex strings in JSON, borsh for storage).", - "type": "object", - "required": [ - "key_provider_event_digest", - "mrtd", - "rtmr0", - "rtmr1", - "rtmr2" - ], - "properties": { - "key_provider_event_digest": { - "$ref": "#/definitions/KeyProviderEventDigest" - }, - "mrtd": { - "$ref": "#/definitions/MrtdHash" - }, - "rtmr0": { - "$ref": "#/definitions/Rtmr0Hash" - }, - "rtmr1": { - "$ref": "#/definitions/Rtmr1Hash" - }, - "rtmr2": { - "$ref": "#/definitions/Rtmr2Hash" - } - } - }, - "Curve": { - "description": "Elliptic curve used by a domain.", - "type": "string", - "enum": [ - "Secp256k1", - "Edwards25519", - "Bls12381" - ] - }, - "DestinationNodeInfo": { - "type": "object", - "required": [ - "destination_node_info", - "signer_account_pk" - ], - "properties": { - "destination_node_info": { - "$ref": "#/definitions/ParticipantInfo" - }, - "signer_account_pk": { - "description": "The public key used by the node to sign transactions to the contract. This key is different from the TLS key stored in [`ParticipantInfo::tls_public_key`].", - "allOf": [ - { - "$ref": "#/definitions/Ed25519PublicKey" - } - ] - } - } - }, - "DockerImageHash": { - "type": "string", - "maxLength": 64, - "minLength": 64, - "pattern": "^[0-9a-fA-F]+$" - }, - "DomainConfig": { - "description": "Configuration for a signature domain.", - "type": "object", - "required": [ - "id", - "protocol", - "purpose", - "reconstruction_threshold" - ], - "properties": { - "id": { - "$ref": "#/definitions/DomainId" - }, - "protocol": { - "$ref": "#/definitions/Protocol" - }, - "purpose": { - "$ref": "#/definitions/DomainPurpose" - }, - "reconstruction_threshold": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - } - }, - "DomainId": { - "description": "Each domain corresponds to a specific root key on a specific elliptic curve. There may be multiple domains per curve. The domain ID uniquely identifies a domain.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "DomainPurpose": { - "description": "The purpose that a domain serves.", - "oneOf": [ - { - "description": "Domain is used by `sign()`.", - "type": "string", - "enum": [ - "Sign" - ] - }, - { - "description": "Domain is used by `verify_foreign_transaction()`.", - "type": "string", - "enum": [ - "ForeignTx" - ] - }, - { - "description": "Domain is used by `request_app_private_key()` (Confidential Key Derivation).", - "type": "string", - "enum": [ - "CKD" - ] - } - ] - }, - "DomainRegistry": { - "description": "Registry of all signature domains.", - "type": "object", - "required": [ - "domains", - "next_domain_id" - ], - "properties": { - "domains": { - "type": "array", - "items": { - "$ref": "#/definitions/DomainConfig" - } - }, - "next_domain_id": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - } - }, - "DstackAttestation": { - "type": "object", - "required": [ - "collateral", - "quote", - "tcb_info" - ], - "properties": { - "collateral": { - "$ref": "#/definitions/Collateral" - }, - "quote": { - "type": "string", - "pattern": "^(?:[0-9A-Fa-f]{2})*$" - }, - "tcb_info": { - "$ref": "#/definitions/TcbInfo" - } - } - }, - "Ed25519PublicKey": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "maxItems": 32, - "minItems": 32 - }, - "Ed25519Signature": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - } - }, - "EpochId": { - "description": "An EpochId uniquely identifies a ThresholdParameters (but not vice-versa). Every time we change the ThresholdParameters (participants and threshold), we increment EpochId. Locally on each node, each keyshare is uniquely identified by the tuple (EpochId, DomainId, AttemptId).", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "EventLog": { - "description": "Represents an event log entry in the system", - "type": "object", - "required": [ - "digest", - "event", - "event_payload", - "event_type", - "imr" - ], - "properties": { - "digest": { - "description": "The cryptographic digest of the event", - "type": "string" - }, - "event": { - "description": "The type of event as a string", - "type": "string" - }, - "event_payload": { - "description": "The payload data associated with the event", - "type": "string" - }, - "event_type": { - "description": "The type of event being logged", - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "imr": { - "description": "The index of the IMR (Integrity Measurement Register)", - "type": "integer", - "format": "uint32", - "minimum": 0.0 - } - } - }, - "EvmExtractor": { - "oneOf": [ - { - "type": "string", - "enum": [ - "BlockHash" - ] - }, - { - "type": "object", - "required": [ - "Log" - ], - "properties": { - "Log": { - "type": "object", - "required": [ - "log_index" - ], - "properties": { - "log_index": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - } - } - }, - "additionalProperties": false - } - ] - }, - "EvmFinality": { - "type": "string", - "enum": [ - "Latest", - "Safe", - "Finalized" - ] - }, - "EvmRpcRequest": { - "type": "object", - "required": [ - "extractors", - "finality", - "tx_id" - ], - "properties": { - "extractors": { - "type": "array", - "items": { - "$ref": "#/definitions/EvmExtractor" - } - }, - "finality": { - "$ref": "#/definitions/EvmFinality" - }, - "tx_id": { - "$ref": "#/definitions/EvmTxId" - } - } - }, - "EvmTxId": { - "type": "string", - "pattern": "^(?:[0-9A-Fa-f]{2})*$" - }, - "ForeignChain": { - "type": "string", - "enum": [ - "Solana", - "Bitcoin", - "Ethereum", - "Base", - "Bnb", - "Arbitrum", - "Abstract", - "Starknet", - "Polygon", - "HyperEvm" - ] - }, - "ForeignChainConfiguration": { - "deprecated": true, - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/NonEmptyBTreeSet_RpcProvider" - } - }, - "ForeignChainRpcRequest": { - "oneOf": [ - { - "type": "object", - "required": [ - "Abstract" - ], - "properties": { - "Abstract": { - "$ref": "#/definitions/EvmRpcRequest" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Ethereum" - ], - "properties": { - "Ethereum": { - "$ref": "#/definitions/EvmRpcRequest" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Solana" - ], - "properties": { - "Solana": { - "$ref": "#/definitions/SolanaRpcRequest" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Bitcoin" - ], - "properties": { - "Bitcoin": { - "$ref": "#/definitions/BitcoinRpcRequest" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Starknet" - ], - "properties": { - "Starknet": { - "$ref": "#/definitions/StarknetRpcRequest" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Bnb" - ], - "properties": { - "Bnb": { - "$ref": "#/definitions/EvmRpcRequest" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Base" - ], - "properties": { - "Base": { - "$ref": "#/definitions/EvmRpcRequest" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Arbitrum" - ], - "properties": { - "Arbitrum": { - "$ref": "#/definitions/EvmRpcRequest" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Polygon" - ], - "properties": { - "Polygon": { - "$ref": "#/definitions/EvmRpcRequest" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "HyperEvm" - ], - "properties": { - "HyperEvm": { - "$ref": "#/definitions/EvmRpcRequest" - } - }, - "additionalProperties": false - } - ] - }, - "ForeignChainSupportByNode": { - "type": "object", - "required": [ - "foreign_chain_support_by_node" - ], - "properties": { - "foreign_chain_support_by_node": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/SupportedForeignChains" - } - } - } - }, - "Hash256": { - "type": "string", - "pattern": "^(?:[0-9A-Fa-f]{2})*$" - }, - "HexString_Min32_Max1232": { - "type": "string", - "maxLength": 2464, - "minLength": 64, - "pattern": "^[0-9a-fA-F]*$" - }, - "HexString_Min32_Max32": { - "type": "string", - "maxLength": 64, - "minLength": 64, - "pattern": "^[0-9a-fA-F]*$" - }, - "InitConfig": { - "description": "The initial configuration parameters for when initializing the contract. All fields are optional, as the contract can fill in defaults for any missing fields.", - "type": "object", - "properties": { - "ckd_call_gas_attachment_requirement_tera_gas": { - "description": "Prepaid gas for a `return_signature_and_clean_state_on_success` call.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - }, - "clean_foreign_chain_data_tera_gas": { - "description": "Prepaid gas for a `clean_foreign_chain_data` call.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - }, - "clean_invalid_attestations_tera_gas": { - "description": "Prepaid gas for the reshare-time `clean_invalid_attestations` promise.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - }, - "clean_tee_status_tera_gas": { - "description": "Prepaid gas for a `clean_tee_status` call.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - }, - "cleanup_orphaned_node_migrations_tera_gas": { - "description": "Prepaid gas for a `cleanup_orphaned_node_migrations` call.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - }, - "contract_upgrade_deposit_tera_gas": { - "description": "Amount of gas to deposit for contract and config updates.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - }, - "fail_on_timeout_tera_gas": { - "description": "Prepaid gas for a `fail_on_timeout` call.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - }, - "key_event_timeout_blocks": { - "description": "If a key event attempt has not successfully completed within this many blocks, it is considered failed.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - }, - "remove_non_participant_update_votes_tera_gas": { - "description": "Prepaid gas for a `remove_non_participant_update_votes` call.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - }, - "return_ck_and_clean_state_on_success_call_tera_gas": { - "description": "Prepaid gas for a `return_ck_and_clean_state_on_success` call.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - }, - "return_signature_and_clean_state_on_success_call_tera_gas": { - "description": "Prepaid gas for a `return_signature_and_clean_state_on_success` call.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - }, - "sign_call_gas_attachment_requirement_tera_gas": { - "description": "Gas required for a sign request.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - }, - "tee_upgrade_deadline_duration_seconds": { - "description": "The grace period duration for expiry of old mpc image hashes once a new one is added.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - } - } - }, - "InitializingContractState": { - "description": "State when the contract is generating keys for new domains.", - "type": "object", - "required": [ - "cancel_votes", - "domains", - "epoch_id", - "generated_keys", - "generating_key" - ], - "properties": { - "cancel_votes": { - "type": "array", - "items": { - "$ref": "#/definitions/AuthenticatedParticipantId" - }, - "uniqueItems": true - }, - "domains": { - "$ref": "#/definitions/DomainRegistry" - }, - "epoch_id": { - "$ref": "#/definitions/EpochId" - }, - "generated_keys": { - "type": "array", - "items": { - "$ref": "#/definitions/KeyForDomain2" - } - }, - "generating_key": { - "$ref": "#/definitions/KeyEvent" - } - } - }, - "K256AffinePoint": { - "description": "AffinePoint on the Secp256k1 curve", - "type": "object", - "required": [ - "affine_point" - ], - "properties": { - "affine_point": { - "type": "string", - "pattern": "^(?:[0-9A-Fa-f]{2})*$" - } - } - }, - "K256Scalar": { - "type": "object", - "required": [ - "scalar" - ], - "properties": { - "scalar": { - "type": "string", - "pattern": "^(?:[0-9A-Fa-f]{2})*$" - } - } - }, - "KeyEvent": { - "description": "Key generation or resharing event state.", - "type": "object", - "required": [ - "domain", - "epoch_id", - "next_attempt_id", - "parameters" - ], - "properties": { - "domain": { - "$ref": "#/definitions/DomainConfig" - }, - "epoch_id": { - "$ref": "#/definitions/EpochId" - }, - "instance": { - "anyOf": [ - { - "$ref": "#/definitions/KeyEventInstance" - }, - { - "type": "null" - } - ] - }, - "next_attempt_id": { - "$ref": "#/definitions/AttemptId" - }, - "parameters": { - "$ref": "#/definitions/ThresholdParameters" - } - } - }, - "KeyEventId": { - "description": "A unique identifier for a key event (generation or resharing): `epoch_id`: identifies the ThresholdParameters that this key is intended to function in. `domain_id`: the domain this key is intended for. `attempt_id`: identifies a particular attempt for this key event, in case multiple attempts yielded partially valid results. This is incremented for each attempt within the same epoch and domain.", - "type": "object", - "required": [ - "attempt_id", - "domain_id", - "epoch_id" - ], - "properties": { - "attempt_id": { - "$ref": "#/definitions/AttemptId" - }, - "domain_id": { - "$ref": "#/definitions/DomainId" - }, - "epoch_id": { - "$ref": "#/definitions/EpochId" - } - } - }, - "KeyEventInstance": { - "description": "State of a key generation/resharing instance.", - "type": "object", - "required": [ - "attempt_id", - "completed", - "expires_on", - "started_in" - ], - "properties": { - "attempt_id": { - "$ref": "#/definitions/AttemptId" - }, - "completed": { - "type": "array", - "items": { - "$ref": "#/definitions/AuthenticatedParticipantId" - }, - "uniqueItems": true - }, - "expires_on": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "public_key": { - "anyOf": [ - { - "$ref": "#/definitions/PublicKeyExtended2" - }, - { - "type": "null" - } - ] - }, - "started_in": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - } - }, - "KeyForDomain": { - "description": "The identification of a specific distributed key, based on which a node would know exactly what keyshare it has corresponds to this distributed key. (A distributed key refers to a specific set of keyshares that nodes have which can be pieced together to form the secret key.)", - "type": "object", - "required": [ - "attempt", - "domain_id", - "key" - ], - "properties": { - "attempt": { - "description": "The attempt ID that generated (initially or as a result of resharing) this distributed key. Nodes may have made multiple attempts to generate the distributed key, and this uniquely identifies which one should ultimately be used.", - "allOf": [ - { - "$ref": "#/definitions/AttemptId" - } - ] - }, - "domain_id": { - "description": "Identifies the domain this key is intended for.", - "allOf": [ - { - "$ref": "#/definitions/DomainId" - } - ] - }, - "key": { - "description": "Identifies the public key. Although technically redundant given that we have the AttemptId, we keep it here in the contract so that it can be verified against and queried.", - "allOf": [ - { - "$ref": "#/definitions/PublicKeyExtended" - } - ] - } - } - }, - "KeyForDomain2": { - "description": "The identification of a specific distributed key, based on which a node would know exactly what keyshare it has corresponds to this distributed key. (A distributed key refers to a specific set of keyshares that nodes have which can be pieced together to form the secret key.)", - "type": "object", - "required": [ - "attempt", - "domain_id", - "key" - ], - "properties": { - "attempt": { - "description": "The attempt ID that generated (initially or as a result of resharing) this distributed key. Nodes may have made multiple attempts to generate the distributed key, and this uniquely identifies which one should ultimately be used.", - "allOf": [ - { - "$ref": "#/definitions/AttemptId" - } - ] - }, - "domain_id": { - "description": "Identifies the domain this key is intended for.", - "allOf": [ - { - "$ref": "#/definitions/DomainId" - } - ] - }, - "key": { - "description": "Identifies the public key. Although technically redundant given that we have the AttemptId, we keep it here in the contract so that it can be verified against and queried.", - "allOf": [ - { - "$ref": "#/definitions/PublicKeyExtended2" - } - ] - } - } - }, - "KeyProviderEventDigest": { - "type": "string", - "maxLength": 96, - "minLength": 96, - "pattern": "^[0-9a-fA-F]+$" - }, - "Keyset": { - "description": "Represents a key for every domain in a specific epoch.", - "type": "object", - "required": [ - "domains", - "epoch_id" - ], - "properties": { - "domains": { - "type": "array", - "items": { - "$ref": "#/definitions/KeyForDomain" - } - }, - "epoch_id": { - "$ref": "#/definitions/EpochId" - } - } - }, - "Keyset2": { - "description": "Represents a key for every domain in a specific epoch.", - "type": "object", - "required": [ - "domains", - "epoch_id" - ], - "properties": { - "domains": { - "type": "array", - "items": { - "$ref": "#/definitions/KeyForDomain2" - } - }, - "epoch_id": { - "$ref": "#/definitions/EpochId" - } - } - }, - "LauncherDockerComposeHash": { - "type": "string", - "maxLength": 64, - "minLength": 64, - "pattern": "^[0-9a-fA-F]+$" - }, - "LauncherHashVotes": { - "description": "Tracks votes for adding or removing launcher image hashes. Each participant can have at most one active vote at a time.", - "type": "object", - "required": [ - "vote_by_account" - ], - "properties": { - "vote_by_account": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/LauncherVoteAction" - } - } - } - }, - "LauncherImageHash": { - "type": "string", - "maxLength": 64, - "minLength": 64, - "pattern": "^[0-9a-fA-F]+$" - }, - "LauncherVoteAction": { - "description": "The action a participant is voting for on a launcher image hash.", - "oneOf": [ - { - "type": "object", - "required": [ - "Add" - ], - "properties": { - "Add": { - "$ref": "#/definitions/LauncherImageHash" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Remove" - ], - "properties": { - "Remove": { - "$ref": "#/definitions/LauncherImageHash" - } - }, - "additionalProperties": false - } - ] - }, - "MeasurementVoteAction": { - "description": "The action a participant is voting for on an OS measurement set.", - "oneOf": [ - { - "type": "object", - "required": [ - "Add" - ], - "properties": { - "Add": { - "$ref": "#/definitions/ContractExpectedMeasurements" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Remove" - ], - "properties": { - "Remove": { - "$ref": "#/definitions/ContractExpectedMeasurements" - } - }, - "additionalProperties": false - } - ] - }, - "MeasurementVotes": { - "description": "Tracks votes for adding or removing OS measurements. Each participant can have at most one active vote at a time.", - "type": "object", - "required": [ - "vote_by_account" - ], - "properties": { - "vote_by_account": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/MeasurementVoteAction" - } - } - } - }, - "Metrics": { - "type": "object", - "required": [ - "sign_with_v1_payload_count", - "sign_with_v2_payload_count" - ], - "properties": { - "sign_with_v1_payload_count": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "sign_with_v2_payload_count": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - } - }, - "MockAttestation": { - "oneOf": [ - { - "description": "Always pass validation", - "type": "string", - "enum": [ - "Valid" - ] - }, - { - "description": "Always fails validation", - "type": "string", - "enum": [ - "Invalid" - ] - }, - { - "description": "Pass validation depending on the set constraints", - "type": "object", - "required": [ - "WithConstraints" - ], - "properties": { - "WithConstraints": { - "type": "object", - "properties": { - "expected_measurements": { - "anyOf": [ - { - "$ref": "#/definitions/VerifiedMeasurements" - }, - { - "type": "null" - } - ] - }, - "expiry_timestamp_seconds": { - "description": "Unix time stamp for when this attestation expires.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - }, - "launcher_docker_compose_hash": { - "anyOf": [ - { - "$ref": "#/definitions/LauncherDockerComposeHash" - }, - { - "type": "null" - } - ] - }, - "mpc_docker_image_hash": { - "anyOf": [ - { - "$ref": "#/definitions/DockerImageHash" - }, - { - "type": "null" - } - ] - } - } - } - }, - "additionalProperties": false - } - ] - }, - "MrtdHash": { - "type": "string", - "maxLength": 96, - "minLength": 96, - "pattern": "^[0-9a-fA-F]+$" - }, - "NodeId": { - "type": "object", - "required": [ - "account_id", - "account_public_key", - "tls_public_key" - ], - "properties": { - "account_id": { - "description": "Operator account.", - "type": "string" - }, - "account_public_key": { - "description": "Full-access Ed25519 public key of the operator account.", - "allOf": [ - { - "$ref": "#/definitions/Ed25519PublicKey" - } - ] - }, - "tls_public_key": { - "description": "TLS public key used by the node for peer-to-peer communication.", - "allOf": [ - { - "$ref": "#/definitions/Ed25519PublicKey" - } - ] - } - } - }, - "NonEmptyBTreeSet_RpcProvider": { - "type": "array", - "items": { - "$ref": "#/definitions/RpcProvider" - }, - "minItems": 1, - "uniqueItems": true - }, - "ParticipantId": { - "description": "Stable identifier for a participant within the MPC protocol's participant set.", - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "ParticipantInfo": { - "type": "object", - "required": [ - "tls_public_key", - "url" - ], - "properties": { - "tls_public_key": { - "$ref": "#/definitions/Ed25519PublicKey" - }, - "url": { - "type": "string" - } - } - }, - "Participants": { - "description": "DTO representation of the contract-internal `Participants` type.\n\nIt decouples the JSON wire format (used in view methods like `state()` via [`ThresholdParameters`](crate::types::state::ThresholdParameters)) from the internal `Participants` representation, allowing internal changes (e.g., migrating to [`BTreeMap`](std::collections::BTreeMap) in [#1861](https://github.com/near/mpc/pull/1861)) without breaking the public API.", - "type": "object", - "required": [ - "next_id", - "participants" - ], - "properties": { - "next_id": { - "$ref": "#/definitions/ParticipantId" - }, - "participants": { - "type": "array", - "items": { - "type": "array", - "items": [ - { - "description": "NEAR Account Identifier.\n\nThis is a unique, syntactically valid, human-readable account identifier on the NEAR network.\n\n[See the crate-level docs for information about validation.](index.html#account-id-rules)\n\nAlso see [Error kind precedence](AccountId#error-kind-precedence).\n\n## Examples\n\n``` use near_account_id::AccountId;\n\nlet alice: AccountId = \"alice.near\".parse().unwrap();\n\nassert!(\"ƒelicia.near\".parse::().is_err()); // (ƒ is not f) ```", - "type": "string" - }, - { - "$ref": "#/definitions/ParticipantId" - }, - { - "$ref": "#/definitions/ParticipantInfo" - } - ], - "maxItems": 3, - "minItems": 3 - } - } - } - }, - "Payload": { - "description": "A signature payload; the right payload must be passed in for the curve. The json encoding for this payload converts the bytes to hex string.", - "oneOf": [ - { - "type": "object", - "required": [ - "Ecdsa" - ], - "properties": { - "Ecdsa": { - "$ref": "#/definitions/HexString_Min32_Max32" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Eddsa" - ], - "properties": { - "Eddsa": { - "$ref": "#/definitions/HexString_Min32_Max1232" - } - }, - "additionalProperties": false - } - ] - }, - "PromiseOrValueCKDResponse": { - "type": "object", - "required": [ - "big_c", - "big_y" - ], - "properties": { - "big_c": { - "$ref": "#/definitions/Bls12381G1PublicKey" - }, - "big_y": { - "$ref": "#/definitions/Bls12381G1PublicKey" - } - } - }, - "PromiseOrValueSignatureResponse": { - "oneOf": [ - { - "type": "object", - "required": [ - "big_r", - "recovery_id", - "s", - "scheme" - ], - "properties": { - "big_r": { - "$ref": "#/definitions/K256AffinePoint" - }, - "recovery_id": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "s": { - "$ref": "#/definitions/K256Scalar" - }, - "scheme": { - "type": "string", - "enum": [ - "Secp256k1" - ] - } - } - }, - { - "type": "object", - "required": [ - "scheme", - "signature" - ], - "properties": { - "scheme": { - "type": "string", - "enum": [ - "Ed25519" - ] - }, - "signature": { - "$ref": "#/definitions/Ed25519Signature" - } - } - } - ] - }, - "PromiseOrValueVerifyForeignTransactionResponse": { - "type": "object", - "required": [ - "payload_hash", - "signature" - ], - "properties": { - "payload_hash": { - "$ref": "#/definitions/Hash256" - }, - "signature": { - "$ref": "#/definitions/SignatureResponse" - } - } - }, - "ProposedUpdates": { - "type": "object", - "required": [ - "updates", - "votes" - ], - "properties": { - "updates": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/UpdateHash" - } - }, - "votes": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - } - } - }, - "Protocol": { - "description": "MPC protocol run for a domain.", - "type": "string", - "enum": [ - "CaitSith", - "Frost", - "ConfidentialKeyDerivation", - "DamgardEtAl" - ] - }, - "ProtocolContractState": { - "description": "The main protocol contract state enum.", - "oneOf": [ - { - "type": "string", - "enum": [ - "NotInitialized" - ] - }, - { - "type": "object", - "required": [ - "Initializing" - ], - "properties": { - "Initializing": { - "$ref": "#/definitions/InitializingContractState" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Running" - ], - "properties": { - "Running": { - "$ref": "#/definitions/RunningContractState" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Resharing" - ], - "properties": { - "Resharing": { - "$ref": "#/definitions/ResharingContractState" - } - }, - "additionalProperties": false - } - ] - }, - "PublicKey": { - "oneOf": [ - { - "type": "object", - "required": [ - "Secp256k1" - ], - "properties": { - "Secp256k1": { - "$ref": "#/definitions/Secp256k1PublicKey" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Ed25519" - ], - "properties": { - "Ed25519": { - "$ref": "#/definitions/Ed25519PublicKey" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Bls12381" - ], - "properties": { - "Bls12381": { - "$ref": "#/definitions/Bls12381G2PublicKey" - } - }, - "additionalProperties": false - } - ] - }, - "PublicKeyExtended": { - "oneOf": [ - { - "type": "object", - "required": [ - "Secp256k1" - ], - "properties": { - "Secp256k1": { - "type": "object", - "required": [ - "near_public_key" - ], - "properties": { - "near_public_key": { - "type": "string" - } - } - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Ed25519" - ], - "properties": { - "Ed25519": { - "type": "object", - "required": [ - "edwards_point", - "near_public_key_compressed" - ], - "properties": { - "edwards_point": { - "description": "Decompressed Edwards point used for curve arithmetic operations.", - "allOf": [ - { - "$ref": "#/definitions/SerializableEdwardsPoint" - } - ] - }, - "near_public_key_compressed": { - "description": "Serialized compressed Edwards-y point.", - "type": "string" - } - } - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Bls12381" - ], - "properties": { - "Bls12381": { - "type": "object", - "required": [ - "public_key" - ], - "properties": { - "public_key": { - "$ref": "#/definitions/PublicKey" - } - } - } - }, - "additionalProperties": false - } - ] - }, - "PublicKeyExtended2": { - "description": "Extended public key representation for different signature schemes.", - "oneOf": [ - { - "description": "Secp256k1 public key (ECDSA).", - "type": "object", - "required": [ - "Secp256k1" - ], - "properties": { - "Secp256k1": { - "type": "object", - "required": [ - "near_public_key" - ], - "properties": { - "near_public_key": { - "description": "The public key in NEAR SDK format (string representation).", - "type": "string" - } - } - } - }, - "additionalProperties": false - }, - { - "description": "Ed25519 public key.", - "type": "object", - "required": [ - "Ed25519" - ], - "properties": { - "Ed25519": { - "type": "object", - "required": [ - "edwards_point", - "near_public_key_compressed" - ], - "properties": { - "edwards_point": { - "description": "The Edwards point (32 bytes).", - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "maxItems": 32, - "minItems": 32 - }, - "near_public_key_compressed": { - "description": "The compressed public key in NEAR SDK format.", - "type": "string" - } - } - } - }, - "additionalProperties": false - }, - { - "description": "BLS12-381 public key.", - "type": "object", - "required": [ - "Bls12381" - ], - "properties": { - "Bls12381": { - "type": "object", - "required": [ - "public_key" - ], - "properties": { - "public_key": { - "description": "The public key.", - "allOf": [ - { - "$ref": "#/definitions/PublicKey" - } - ] - } - } - } - }, - "additionalProperties": false - } - ] - }, - "ResharingContractState": { - "description": "State when the contract is resharing keys to new participants.", - "type": "object", - "required": [ - "cancellation_requests", - "previous_running_state", - "reshared_keys", - "resharing_key" - ], - "properties": { - "cancellation_requests": { - "type": "array", - "items": { - "$ref": "#/definitions/AuthenticatedAccountId" - }, - "uniqueItems": true - }, - "previous_running_state": { - "$ref": "#/definitions/RunningContractState" - }, - "reshared_keys": { - "type": "array", - "items": { - "$ref": "#/definitions/KeyForDomain2" - } - }, - "resharing_key": { - "$ref": "#/definitions/KeyEvent" - } - } - }, - "RpcProvider": { - "type": "object", - "required": [ - "rpc_url" - ], - "properties": { - "rpc_url": { - "type": "string" - } - } - }, - "Rtmr0Hash": { - "type": "string", - "maxLength": 96, - "minLength": 96, - "pattern": "^[0-9a-fA-F]+$" - }, - "Rtmr1Hash": { - "type": "string", - "maxLength": 96, - "minLength": 96, - "pattern": "^[0-9a-fA-F]+$" - }, - "Rtmr2Hash": { - "type": "string", - "maxLength": 96, - "minLength": 96, - "pattern": "^[0-9a-fA-F]+$" - }, - "RunningContractState": { - "description": "State when the contract is ready for signature operations.", - "type": "object", - "required": [ - "add_domains_votes", - "domains", - "keyset", - "parameters", - "parameters_votes" - ], - "properties": { - "add_domains_votes": { - "$ref": "#/definitions/AddDomainsVotes" - }, - "domains": { - "$ref": "#/definitions/DomainRegistry" - }, - "keyset": { - "$ref": "#/definitions/Keyset2" - }, - "parameters": { - "$ref": "#/definitions/ThresholdParameters" - }, - "parameters_votes": { - "$ref": "#/definitions/ThresholdParametersVotes" - }, - "previously_cancelled_resharing_epoch_id": { - "anyOf": [ - { - "$ref": "#/definitions/EpochId" - }, - { - "type": "null" - } - ] - } - } - }, - "Secp256k1PublicKey": { - "type": "string" - }, - "SerializableEdwardsPoint": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "maxItems": 32, - "minItems": 32 - }, - "Sha384Digest": { - "type": "string", - "maxLength": 96, - "minLength": 96, - "pattern": "^[0-9a-fA-F]+$" - }, - "SignRequestArgs": { - "description": "Sign request args with backward-compatible deserialization.\n\nThe struct field is `payload` but serializes as `payload_v2` on the wire for compatibility with existing consumers. Deserialization accepts both `payload_v2` and the deprecated `payload` (as raw `[u8; 32]`) plus `key_version` as an alias for `domain_id`.", - "type": "object", - "required": [ - "domain_id", - "path", - "payload_v2" - ], - "properties": { - "domain_id": { - "$ref": "#/definitions/DomainId" - }, - "path": { - "type": "string" - }, - "payload_v2": { - "$ref": "#/definitions/Payload" - } - } - }, - "SignatureRequest": { - "description": "A signature request after computing the tweak from the caller's account and derivation path. This is what gets stored in the contract state and sent back to the respond function.", - "type": "object", - "required": [ - "domain_id", - "payload", - "tweak" - ], - "properties": { - "domain_id": { - "$ref": "#/definitions/DomainId" - }, - "payload": { - "$ref": "#/definitions/Payload" - }, - "tweak": { - "$ref": "#/definitions/Tweak" - } - } - }, - "SignatureResponse": { - "oneOf": [ - { - "type": "object", - "required": [ - "big_r", - "recovery_id", - "s", - "scheme" - ], - "properties": { - "big_r": { - "$ref": "#/definitions/K256AffinePoint" - }, - "recovery_id": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "s": { - "$ref": "#/definitions/K256Scalar" - }, - "scheme": { - "type": "string", - "enum": [ - "Secp256k1" - ] - } - } - }, - { - "type": "object", - "required": [ - "scheme", - "signature" - ], - "properties": { - "scheme": { - "type": "string", - "enum": [ - "Ed25519" - ] - }, - "signature": { - "$ref": "#/definitions/Ed25519Signature" - } - } - } - ] - }, - "SolanaExtractor": { - "oneOf": [ - { - "type": "object", - "required": [ - "SolanaProgramIdIndex" - ], - "properties": { - "SolanaProgramIdIndex": { - "type": "object", - "required": [ - "ix_index" - ], - "properties": { - "ix_index": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - } - } - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "SolanaDataHash" - ], - "properties": { - "SolanaDataHash": { - "type": "object", - "required": [ - "ix_index" - ], - "properties": { - "ix_index": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - } - } - } - }, - "additionalProperties": false - } - ] - }, - "SolanaFinality": { - "type": "string", - "enum": [ - "Processed", - "Confirmed", - "Finalized" - ] - }, - "SolanaRpcRequest": { - "type": "object", - "required": [ - "extractors", - "finality", - "tx_id" - ], - "properties": { - "extractors": { - "type": "array", - "items": { - "$ref": "#/definitions/SolanaExtractor" - } - }, - "finality": { - "$ref": "#/definitions/SolanaFinality" - }, - "tx_id": { - "$ref": "#/definitions/SolanaTxId" - } - } - }, - "SolanaTxId": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - } - }, - "StarknetExtractor": { - "oneOf": [ - { - "type": "string", - "enum": [ - "BlockHash" - ] - }, - { - "type": "object", - "required": [ - "Log" - ], - "properties": { - "Log": { - "type": "object", - "required": [ - "log_index" - ], - "properties": { - "log_index": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - } - } - }, - "additionalProperties": false - } - ] - }, - "StarknetFelt": { - "type": "string", - "pattern": "^(?:[0-9A-Fa-f]{2})*$" - }, - "StarknetFinality": { - "type": "string", - "enum": [ - "AcceptedOnL2", - "AcceptedOnL1" - ] - }, - "StarknetRpcRequest": { - "type": "object", - "required": [ - "extractors", - "finality", - "tx_id" - ], - "properties": { - "extractors": { - "type": "array", - "items": { - "$ref": "#/definitions/StarknetExtractor" - } - }, - "finality": { - "$ref": "#/definitions/StarknetFinality" - }, - "tx_id": { - "$ref": "#/definitions/StarknetTxId" - } - } - }, - "StarknetTxId": { - "$ref": "#/definitions/StarknetFelt" - }, - "SupportedForeignChains": { - "type": "array", - "items": { - "$ref": "#/definitions/ForeignChain" - }, - "uniqueItems": true - }, - "TcbInfo": { - "description": "Trusted Computing Base information structure", - "type": "object", - "required": [ - "app_compose", - "compose_hash", - "device_id", - "event_log", - "mrtd", - "rtmr0", - "rtmr1", - "rtmr2", - "rtmr3" - ], - "properties": { - "app_compose": { - "description": "The app compose", - "type": "string" - }, - "compose_hash": { - "description": "The hash of the compose configuration", - "type": "string" - }, - "device_id": { - "description": "The device identifier", - "type": "string" - }, - "event_log": { - "description": "The event log entries", - "type": "array", - "items": { - "$ref": "#/definitions/EventLog" - } - }, - "mrtd": { - "description": "The measurement root of trust", - "type": "string" - }, - "os_image_hash": { - "description": "The hash of the OS image. This is empty if the OS image is not measured by KMS.", - "default": "", - "type": "string" - }, - "rtmr0": { - "description": "The value of RTMR0 (Runtime Measurement Register 0)", - "type": "string" - }, - "rtmr1": { - "description": "The value of RTMR1 (Runtime Measurement Register 1)", - "type": "string" - }, - "rtmr2": { - "description": "The value of RTMR2 (Runtime Measurement Register 2)", - "type": "string" - }, - "rtmr3": { - "description": "The value of RTMR3 (Runtime Measurement Register 3)", - "type": "string" - } - } - }, - "Threshold": { - "description": "Cryptographic threshold (`k`) for a distributed key: the minimum number of participants that must collaborate to produce a signature.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "ThresholdParameters": { - "description": "Threshold parameters for distributed key operations.\n\n`per_domain_thresholds` carries a proposed update for each domain's [`ReconstructionThreshold`] when this struct flows into `vote_new_parameters`. An empty map means \"keep current per-domain thresholds\"; a populated map must cover every existing domain (validated by the contract). Outside of resharing proposals the map is empty.", - "type": "object", - "required": [ - "participants", - "threshold" - ], - "properties": { - "participants": { - "$ref": "#/definitions/Participants" - }, - "per_domain_thresholds": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - }, - "threshold": { - "$ref": "#/definitions/Threshold" - } - } - }, - "ThresholdParametersVotes": { - "description": "Votes for threshold parameter changes.", - "type": "object", - "required": [ - "proposal_by_account" - ], - "properties": { - "proposal_by_account": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/ThresholdParameters" - } - } - } - }, - "Tweak": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "maxItems": 32, - "minItems": 32 - }, - "UpdateHash": { - "description": "An update hash", - "oneOf": [ - { - "type": "object", - "required": [ - "Code" - ], - "properties": { - "Code": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "maxItems": 32, - "minItems": 32 - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Config" - ], - "properties": { - "Config": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "maxItems": 32, - "minItems": 32 - } - }, - "additionalProperties": false - } - ] - }, - "UpdateId": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "VerifiedAttestation": { - "oneOf": [ - { - "type": "object", - "required": [ - "Dstack" - ], - "properties": { - "Dstack": { - "$ref": "#/definitions/VerifiedDstackAttestation" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Mock" - ], - "properties": { - "Mock": { - "$ref": "#/definitions/MockAttestation" - } - }, - "additionalProperties": false - } - ] - }, - "VerifiedDstackAttestation": { - "type": "object", - "required": [ - "expiry_timestamp_seconds", - "launcher_compose_hash", - "measurements", - "mpc_image_hash" - ], - "properties": { - "expiry_timestamp_seconds": { - "description": "Unix time stamp for when this attestation expires.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "launcher_compose_hash": { - "description": "The digest of the launcher compose file running.", - "allOf": [ - { - "$ref": "#/definitions/LauncherDockerComposeHash" - } - ] - }, - "measurements": { - "description": "The OS measurements that were verified during initial attestation.", - "allOf": [ - { - "$ref": "#/definitions/VerifiedMeasurements" - } - ] - }, - "mpc_image_hash": { - "description": "The digest of the MPC image running.", - "allOf": [ - { - "$ref": "#/definitions/DockerImageHash" - } - ] - } - } - }, - "VerifiedMeasurements": { - "type": "object", - "required": [ - "key_provider_event_digest", - "mrtd", - "rtmr0", - "rtmr1", - "rtmr2" - ], - "properties": { - "key_provider_event_digest": { - "$ref": "#/definitions/Sha384Digest" - }, - "mrtd": { - "$ref": "#/definitions/Sha384Digest" - }, - "rtmr0": { - "$ref": "#/definitions/Sha384Digest" - }, - "rtmr1": { - "$ref": "#/definitions/Sha384Digest" - }, - "rtmr2": { - "$ref": "#/definitions/Sha384Digest" - } - } - }, - "VerifyForeignTransactionRequest": { - "type": "object", - "required": [ - "domain_id", - "payload_version", - "request" - ], - "properties": { - "domain_id": { - "$ref": "#/definitions/DomainId" - }, - "payload_version": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "request": { - "$ref": "#/definitions/ForeignChainRpcRequest" - } - } - }, - "VerifyForeignTransactionRequestArgs": { - "type": "object", - "required": [ - "domain_id", - "payload_version", - "request" - ], - "properties": { - "domain_id": { - "$ref": "#/definitions/DomainId" - }, - "payload_version": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "request": { - "$ref": "#/definitions/ForeignChainRpcRequest" - } - } - }, - "VerifyForeignTransactionResponse": { - "type": "object", - "required": [ - "payload_hash", - "signature" - ], - "properties": { - "payload_hash": { - "$ref": "#/definitions/Hash256" - }, - "signature": { - "$ref": "#/definitions/SignatureResponse" - } - } - }, - "YieldIndex": { - "description": "The index into calling the YieldResume feature of NEAR. This will allow to resume a yield call after the contract has been called back via this index.", - "type": "object", - "required": [ - "data_id" - ], - "properties": { - "data_id": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "maxItems": 32, - "minItems": 32 - } - } - } - } - } - } -} From ba2572320ba879b338d01a3f95510f072b5f83bf Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 22 May 2026 16:57:42 +0200 Subject: [PATCH 05/34] abi changed --- crates/contract/tests/snapshots/abi__abi_has_not_changed.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 142a8db1d6..a9ef55d67e 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -4549,7 +4549,7 @@ expression: abi "minimum": 0.0 }, "ThresholdParameters": { - "description": "Threshold parameters for distributed key operations.\n\n`per_domain_thresholds` carries a proposed update for each domain's [`ReconstructionThreshold`] when this struct flows into `vote_new_parameters`. An empty map means \"keep current per-domain thresholds\"; a populated map must cover every existing domain (validated by the contract). Outside of resharing proposals the map is empty.", + "description": "Threshold parameters for distributed key operations.\n\n`per_domain_thresholds` carries a proposed update for each domain's [`ReconstructionThreshold`] when this struct flows into `vote_new_parameters`. An empty map means \"keep current per-domain thresholds\"; a populated map must cover every existing domain (validated by the contract). Outside of resharing proposals the map is empty.\n\nBackwards-compat for the legacy `{ participants, threshold }` wire shape is intrinsic: `serde(default)` parses old JSON without `per_domain_thresholds` as an empty map, and `skip_serializing_if` omits the field from `state()` output when no overlay is in flight.", "type": "object", "required": [ "participants", From 376beb325e8fd4471d84d77ca819e8784d911a41 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 27 May 2026 10:58:27 +0200 Subject: [PATCH 06/34] Some small nits by Reynaldo --- crates/contract/src/state/running.rs | 6 ++--- crates/contract/src/v3_10_state.rs | 8 +++---- .../snapshots/abi__abi_has_not_changed.snap | 2 +- .../src/types/state.rs | 22 +++++++++---------- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index 2d0e696fa4..a190e70d4e 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -3,7 +3,7 @@ use super::key_event::KeyEvent; use super::resharing::ResharingContractState; use crate::errors::{DomainError, Error, InvalidParameters, VoteError}; use crate::primitives::{ - domain::{AddDomainsVotes, DomainRegistry}, + domain::{AddDomainsVotes, DomainRegistry, validate_domain_threshold}, key_state::{AuthenticatedAccountId, AuthenticatedParticipantId, EpochId, Keyset}, threshold_votes::ThresholdParametersVotes, thresholds::ThresholdParameters, @@ -157,7 +157,7 @@ impl RunningContractState { reconstruction_threshold: effective_threshold, ..domain.clone() }; - crate::primitives::domain::validate_domain_threshold(&proposed, new_num_participants)?; + validate_domain_threshold(&proposed, new_num_participants)?; } // ensure the signer is a proposed participant @@ -488,8 +488,6 @@ pub mod running_tests { ); } - // ----- #3169: per-domain threshold proposals in resharing ----- - use std::collections::BTreeMap; #[test] diff --git a/crates/contract/src/v3_10_state.rs b/crates/contract/src/v3_10_state.rs index 5c7405d511..2494e985d3 100644 --- a/crates/contract/src/v3_10_state.rs +++ b/crates/contract/src/v3_10_state.rs @@ -33,7 +33,7 @@ use crate::{ Config, SupportedForeignChainsByNode, }; -/// Pre-3.11 layout of `ThresholdParameters`. The new layout appends +/// `3.10.0` layout of `ThresholdParameters`. The new layout appends /// `per_domain_thresholds: BTreeMap` /// (see #3169). Borsh is positional, so old bytes can be decoded into this /// shadow and then mapped to the new struct with the map defaulted to empty. @@ -52,7 +52,7 @@ impl From for ThresholdParameters { } } -/// Pre-3.11 layout of `ThresholdParametersVotes` — same shape but with the +/// `3.10.0` layout of `ThresholdParametersVotes` — same shape but with the /// old `OldThresholdParameters` as the value type. #[derive(Debug, BorshSerialize, BorshDeserialize)] struct OldThresholdParametersVotes { @@ -71,7 +71,7 @@ impl From for ThresholdParametersVotes { } } -/// Pre-3.11 layout of `RunningContractState`. Mirrors the current shape but +/// `3.10.0` layout of `RunningContractState`. Mirrors the current shape but /// uses the old `OldThresholdParameters` and `OldThresholdParametersVotes`. #[derive(Debug, BorshSerialize, BorshDeserialize)] struct OldRunningContractState { @@ -96,7 +96,7 @@ impl From for RunningContractState { } } -/// Pre-3.11 layout of `ProtocolContractState`. Only the `Running` variant +/// `3.10.0` layout of `ProtocolContractState`. Only the `Running` variant /// has a verified shadow — Initializing/Resharing reuse current types and /// would fail to deserialize old data, which matches the pre-existing /// "migration panics if not Running" policy. diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index a9ef55d67e..03c7f27b1c 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -4549,7 +4549,7 @@ expression: abi "minimum": 0.0 }, "ThresholdParameters": { - "description": "Threshold parameters for distributed key operations.\n\n`per_domain_thresholds` carries a proposed update for each domain's [`ReconstructionThreshold`] when this struct flows into `vote_new_parameters`. An empty map means \"keep current per-domain thresholds\"; a populated map must cover every existing domain (validated by the contract). Outside of resharing proposals the map is empty.\n\nBackwards-compat for the legacy `{ participants, threshold }` wire shape is intrinsic: `serde(default)` parses old JSON without `per_domain_thresholds` as an empty map, and `skip_serializing_if` omits the field from `state()` output when no overlay is in flight.", + "description": "Threshold parameters for distributed key operations.", "type": "object", "required": [ "participants", diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index 486bcdf5a0..4c2b0e7d17 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -131,17 +131,17 @@ pub use near_mpc_crypto_types::{KeyForDomain, Keyset}; // ============================================================================= /// Threshold parameters for distributed key operations. -/// -/// `per_domain_thresholds` carries a proposed update for each domain's -/// [`ReconstructionThreshold`] when this struct flows into -/// `vote_new_parameters`. An empty map means "keep current per-domain -/// thresholds"; a populated map must cover every existing domain (validated by -/// the contract). Outside of resharing proposals the map is empty. -/// -/// Backwards-compat for the legacy `{ participants, threshold }` wire shape is -/// intrinsic: `serde(default)` parses old JSON without `per_domain_thresholds` -/// as an empty map, and `skip_serializing_if` omits the field from `state()` -/// output when no overlay is in flight. +// +// `per_domain_thresholds` carries a proposed update for each domain's +// `ReconstructionThreshold` when this struct flows into `vote_new_parameters`. +// An empty map means "keep current per-domain thresholds"; a populated map must +// cover every existing domain (validated by the contract). Outside of resharing +// proposals the map is empty. +// +// Backwards-compat for the legacy `{ participants, threshold }` wire shape is +// intrinsic: `serde(default)` parses old JSON without `per_domain_thresholds` +// as an empty map, and `skip_serializing_if` omits the field from `state()` +// output when no overlay is in flight. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), From 7187ab354bf09365b89c7baf577d082a358a9790 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 27 May 2026 17:41:45 +0200 Subject: [PATCH 07/34] No need for skip_serializing_if --- crates/contract/src/primitives/thresholds.rs | 7 ++++--- crates/near-mpc-contract-interface/src/types/state.rs | 11 +++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index bbe8772a1d..1c487a0b12 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -24,9 +24,10 @@ pub struct ThresholdParameters { /// resharing. Empty map means "keep current per-domain thresholds"; /// populated map must cover every existing domain (validated in /// [`super::super::state::running::RunningContractState::process_new_parameters_proposal`]). - /// `skip_serializing_if`+`default` preserve the legacy `state()` wire - /// shape when no resharing is in flight. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + /// `serde(default)` keeps JSON decodes tolerant of the pre-#3169 shape; the + /// public wire (`state()`, `vote_new_parameters`) goes through the interface + /// DTO, while on-chain storage is borsh — neither depends on this attribute. + #[serde(default)] per_domain_thresholds: BTreeMap, } diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index 4c2b0e7d17..2331fc1713 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -138,10 +138,10 @@ pub use near_mpc_crypto_types::{KeyForDomain, Keyset}; // cover every existing domain (validated by the contract). Outside of resharing // proposals the map is empty. // -// Backwards-compat for the legacy `{ participants, threshold }` wire shape is -// intrinsic: `serde(default)` parses old JSON without `per_domain_thresholds` -// as an empty map, and `skip_serializing_if` omits the field from `state()` -// output when no overlay is in flight. +// Input back-compat is intrinsic: `serde(default)` parses an old +// `{ participants, threshold }` payload without `per_domain_thresholds` as an +// empty map. The field is always serialized — an additive change that consumers +// which don't recognize it simply ignore. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), @@ -150,8 +150,7 @@ pub use near_mpc_crypto_types::{KeyForDomain, Keyset}; pub struct ThresholdParameters { pub participants: Participants, pub threshold: Threshold, - // The following skip_serializing_if should be deleted after release 3.11.0 - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default)] pub per_domain_thresholds: BTreeMap, } From b93c5d2696692595211d6414a3727339cdb59c90 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Thu, 28 May 2026 15:03:37 +0200 Subject: [PATCH 08/34] fixing the bug generated due to conflict solving --- crates/contract/src/state/running.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index 253945adbf..8a23d64833 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -561,6 +561,9 @@ pub mod running_tests { assert!( err.to_string().contains("not in the current registry"), "Expected UnknownDomainInProposal, got: {err}" + ); + } + /// Builds a `DomainConfig` for the next domain id with the given protocol, /// purpose, and reconstruction threshold. fn caitsith_lock_test_proposal( @@ -674,6 +677,8 @@ pub mod running_tests { "Expected ReconstructionThresholdTooLow on overlay value, got: {err}" ); } + + #[test] fn vote_add_domains__should_accept_caitsith_threshold_matching_existing() { // Given a Running state with a CaitSith domain at t = 2 (fixture // default) and a matching proposal. From a724aed66ebe4fe9e094f25d7ef7a9d6fe70cf98 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 29 May 2026 11:47:34 +0200 Subject: [PATCH 09/34] cargo fmt --- crates/contract/src/v3_10_state.rs | 6 +++--- flake.lock | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/contract/src/v3_10_state.rs b/crates/contract/src/v3_10_state.rs index eba2489ce3..cb901a004c 100644 --- a/crates/contract/src/v3_10_state.rs +++ b/crates/contract/src/v3_10_state.rs @@ -26,8 +26,8 @@ use crate::{ thresholds::{Threshold, ThresholdParameters}, }, state::{ - initializing::InitializingContractState, resharing::ResharingContractState, - running::RunningContractState, ProtocolContractState, + ProtocolContractState, initializing::InitializingContractState, + resharing::ResharingContractState, running::RunningContractState, }, tee::tee_state::TeeState, update::ProposedUpdates, @@ -195,7 +195,7 @@ impl From for crate::MpcContract { #[expect(non_snake_case)] mod tests { use super::*; - use crate::primitives::test_utils::{gen_participants, NUM_PROTOCOLS}; + use crate::primitives::test_utils::{NUM_PROTOCOLS, gen_participants}; /// Borsh round-trip: write a `ThresholdParameters` in the OLD layout /// (no `per_domain_thresholds` field), deserialize via the shadow, diff --git a/flake.lock b/flake.lock index 9f1d16cc70..05588c0b3d 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1776635034, - "narHash": "sha256-OEOJrT3ZfwbChzODfIH4GzlNTtOFuZFWPtW7jIeR8xU=", + "lastModified": 1780029330, + "narHash": "sha256-fWFKEKB5CP+NO8/3uOaEZIVbd03mvDJ//VsnKkXty08=", "owner": "ipetkov", "repo": "crane", - "rev": "dc7496d8ea6e526b1254b55d09b966e94673750f", + "rev": "6823b493a0bc2142082075576efa6633b537197e", "type": "github" }, "original": { @@ -17,11 +17,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1767892417, - "narHash": "sha256-dhhvQY67aboBk8b0/u0XB6vwHdgbROZT3fJAjyNh5Ww=", + "lastModified": 1779560665, + "narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786", "type": "github" }, "original": { @@ -45,11 +45,11 @@ ] }, "locked": { - "lastModified": 1779333539, - "narHash": "sha256-lpmN2lrBDZDPjov2cbD3bOOJsI0fkKolKXasYPCqSys=", + "lastModified": 1780024773, + "narHash": "sha256-aU9nlrS9S+IJ2EiCzsaxzOXUhggogqTrJojBicE6Oeg=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "672fa5fc5608d5cd82286a6f69aaf84a40b4fe41", + "rev": "40b0a3a193e0840c76174b4a322874c8f6dd0a63", "type": "github" }, "original": { From 220132f8e383e8ac98f3d50a14815602250b98de Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 29 May 2026 12:36:57 +0200 Subject: [PATCH 10/34] abi test --- crates/contract/tests/snapshots/abi__abi_has_not_changed.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 629cfa9a78..274572a9fe 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -4560,6 +4560,7 @@ expression: abi "$ref": "#/definitions/Participants" }, "per_domain_thresholds": { + "default": {}, "type": "object", "additionalProperties": { "type": "integer", From a0258928736d42f1c8c3a2e4882792efc62ce011 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:13:57 +0200 Subject: [PATCH 11/34] Conversion Error --- crates/contract/src/state/running.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index 8a23d64833..836296ee64 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -1,7 +1,7 @@ use super::initializing::InitializingContractState; use super::key_event::KeyEvent; use super::resharing::ResharingContractState; -use crate::errors::{DomainError, Error, InvalidParameters, VoteError}; +use crate::errors::{ConversionError, DomainError, Error, InvalidParameters, VoteError}; use crate::primitives::{ domain::{AddDomainsVotes, DomainRegistry, validate_domain_threshold}, key_state::{AuthenticatedAccountId, AuthenticatedParticipantId, EpochId, Keyset}, @@ -141,7 +141,12 @@ impl RunningContractState { // participant count. Domains not present in the overlay keep their // existing threshold; overlay entries override. An overlay entry // referencing an unknown domain ID is rejected. - let new_num_participants = proposal.participants().len() as u64; + let new_num_participants = + u64::try_from(proposal.participants().len()).map_err(|e| { + ConversionError::DataConversion { + reason: format!("participant count does not fit in u64: {e}"), + } + })?; let overlay = proposal.per_domain_thresholds(); for id in overlay.keys() { if self.domains.get_domain_by_domain_id(*id).is_none() { @@ -176,7 +181,13 @@ impl RunningContractState { // finally, vote. let n_votes = self.parameters_votes.vote(proposal, candidate); - Ok(proposal.participants().len() as u64 == n_votes) + let num_participants = + u64::try_from(proposal.participants().len()).map_err(|e| { + ConversionError::DataConversion { + reason: format!("participant count does not fit in u64: {e}"), + } + })?; + Ok(num_participants == n_votes) } /// Casts a vote for the signer participant to add new domains, replacing any previous vote. From 274cb6ae515556adc23f584b4960d84310180db5 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:19:30 +0200 Subject: [PATCH 12/34] cargo fmt --- crates/contract/src/state/running.rs | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index 836296ee64..4618fb739e 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -141,13 +141,18 @@ impl RunningContractState { // participant count. Domains not present in the overlay keep their // existing threshold; overlay entries override. An overlay entry // referencing an unknown domain ID is rejected. - let new_num_participants = - u64::try_from(proposal.participants().len()).map_err(|e| { - ConversionError::DataConversion { - reason: format!("participant count does not fit in u64: {e}"), - } - })?; + let new_num_participants = u64::try_from(proposal.participants().len()).map_err(|e| { + ConversionError::DataConversion { + reason: format!("participant count does not fit in u64: {e}"), + } + })?; let overlay = proposal.per_domain_thresholds(); + // `with_overlaid_thresholds` re-runs this same guard at the final + // resharing transition, so this is intentionally redundant. We check it + // here too to fail fast at vote acceptance: the threshold-validation + // loop below iterates the existing domains (not the overlay keys), so + // without this guard an entry for an unknown domain ID would be + // silently ignored now and only rejected at transition time. for id in overlay.keys() { if self.domains.get_domain_by_domain_id(*id).is_none() { return Err(DomainError::UnknownDomainInProposal { domain_id: *id }.into()); @@ -181,12 +186,11 @@ impl RunningContractState { // finally, vote. let n_votes = self.parameters_votes.vote(proposal, candidate); - let num_participants = - u64::try_from(proposal.participants().len()).map_err(|e| { - ConversionError::DataConversion { - reason: format!("participant count does not fit in u64: {e}"), - } - })?; + let num_participants = u64::try_from(proposal.participants().len()).map_err(|e| { + ConversionError::DataConversion { + reason: format!("participant count does not fit in u64: {e}"), + } + })?; Ok(num_participants == n_votes) } From 59a1fae54f98ad377318b00a9e696b1cc2f0c6c2 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:20:19 +0200 Subject: [PATCH 13/34] comment --- crates/contract/src/dto_mapping.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index 16e1b36068..7f7c70b5f3 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -214,6 +214,13 @@ impl IntoContractType for dtos::Participants { impl IntoContractType for dtos::ThresholdParameters { fn into_contract_type(self) -> ThresholdParameters { + // This conversion is intentionally infallible: `new_unvalidated` skips the + // absolute/relative (>= 60%) threshold checks. Validation is not the job of the + // DTO mapping — every contract entry point that accepts these parameters + // (`init`, `init_running`, `vote_new_parameters`) calls `validate()` / + // `validate_incoming_proposal` downstream, so production proposals are still + // rejected if they violate the threshold bounds. Deferring validation here also + // lets tests construct parameters with sub-production thresholds. ThresholdParameters::new_unvalidated(self.participants.into_contract_type(), self.threshold) .with_per_domain_thresholds(self.per_domain_thresholds) } From fca347bcc887db0c833edf13b573cfb889bcb252 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:34:03 +0200 Subject: [PATCH 14/34] reject proposals that would leave CaitSith domains with differing reconstruction_thresholds --- crates/contract/src/primitives/domain.rs | 113 ++++++++++++++++++++++- crates/contract/src/state/running.rs | 69 +++++++------- 2 files changed, 149 insertions(+), 33 deletions(-) diff --git a/crates/contract/src/primitives/domain.rs b/crates/contract/src/primitives/domain.rs index f21e7a7485..8bd3844555 100644 --- a/crates/contract/src/primitives/domain.rs +++ b/crates/contract/src/primitives/domain.rs @@ -71,6 +71,30 @@ pub fn validate_domain_threshold( Ok(()) } +/// 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> { + let mut expected: Option = None; + for domain in domains { + if domain.protocol != Protocol::CaitSith { + continue; + } + let found = domain.reconstruction_threshold.inner(); + match expected { + None => expected = Some(found), + Some(expected) if expected != found => { + return Err(DomainError::CaitsithThresholdMismatch { expected, found }.into()); + } + Some(_) => {} + } + } + Ok(()) +} + /// 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.) @@ -186,6 +210,13 @@ impl DomainRegistry { /// [`DomainError::UnknownDomainInProposal`]. Domains absent from /// `overlay` retain their existing threshold. An empty overlay returns a /// structurally identical clone. + /// + /// The resulting registry is re-checked against the 3.11-transition lock + /// (see [`validate_caitsith_uniform_threshold`]): because the overlay can + /// rewrite per-domain thresholds, it could otherwise leave CaitSith + /// domains with differing thresholds. This is the authoritative chokepoint + /// — no resharing transition can produce a registry that violates the + /// invariant. pub fn with_overlaid_thresholds( &self, overlay: &BTreeMap, @@ -195,7 +226,7 @@ impl DomainRegistry { return Err(DomainError::UnknownDomainInProposal { domain_id: *id }.into()); } } - let domains = self + let domains: Vec = self .domains .iter() .map(|d| { @@ -209,6 +240,7 @@ impl DomainRegistry { } }) .collect(); + validate_caitsith_uniform_threshold(&domains)?; Ok(DomainRegistry { domains, next_domain_id: self.next_domain_id, @@ -696,5 +728,84 @@ pub mod tests { "Expected UnknownDomainInProposal, got: {err}" ); } + + #[test] + fn with_overlaid_thresholds__should_reject_overlay_that_diverges_caitsith_thresholds() { + // Given a registry with two CaitSith domains sharing one threshold + // (the 3.11-transition lock invariant) and an overlay that rewrites + // only one of them to a different value. + let registry = registry_of(vec![ + DomainConfig { + id: DomainId(0), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(3), + purpose: DomainPurpose::Sign, + }, + DomainConfig { + id: DomainId(1), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(3), + purpose: DomainPurpose::Sign, + }, + ]); + let mut overlay = BTreeMap::new(); + overlay.insert(DomainId(0), ReconstructionThreshold::new(5)); + + // When applying the overlay + let err = registry.with_overlaid_thresholds(&overlay).unwrap_err(); + + // Then the 3.11-transition lock rejects the divergence + assert!( + err.to_string().contains("CaitSith threshold mismatch"), + "Expected CaitsithThresholdMismatch, got: {err}" + ); + } + + #[test] + fn with_overlaid_thresholds__should_accept_overlay_that_keeps_caitsith_thresholds_uniform() + { + // Given two CaitSith domains and a Frost domain, with an overlay + // that moves both CaitSith domains to the same new threshold. + let registry = registry_of(vec![ + DomainConfig { + id: DomainId(0), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(3), + purpose: DomainPurpose::Sign, + }, + DomainConfig { + id: DomainId(1), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(3), + purpose: DomainPurpose::Sign, + }, + DomainConfig { + id: DomainId(2), + protocol: Protocol::Frost, + reconstruction_threshold: ReconstructionThreshold::new(2), + purpose: DomainPurpose::Sign, + }, + ]); + let mut overlay = BTreeMap::new(); + overlay.insert(DomainId(0), ReconstructionThreshold::new(5)); + overlay.insert(DomainId(1), ReconstructionThreshold::new(5)); + + // When applying the overlay + let result = registry.with_overlaid_thresholds(&overlay).unwrap(); + + // Then both CaitSith domains move together and Frost is untouched + assert_eq!( + result.domains()[0].reconstruction_threshold, + ReconstructionThreshold::new(5) + ); + assert_eq!( + result.domains()[1].reconstruction_threshold, + ReconstructionThreshold::new(5) + ); + assert_eq!( + result.domains()[2].reconstruction_threshold, + ReconstructionThreshold::new(2) + ); + } } } diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index 4618fb739e..12fdfab85f 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -3,13 +3,16 @@ use super::key_event::KeyEvent; use super::resharing::ResharingContractState; use crate::errors::{ConversionError, DomainError, Error, InvalidParameters, VoteError}; use crate::primitives::{ - domain::{AddDomainsVotes, DomainRegistry, validate_domain_threshold}, + domain::{ + AddDomainsVotes, DomainRegistry, validate_caitsith_uniform_threshold, + validate_domain_threshold, + }, key_state::{AuthenticatedAccountId, AuthenticatedParticipantId, EpochId, Keyset}, threshold_votes::ThresholdParametersVotes, thresholds::ThresholdParameters, }; use near_account_id::AccountId; -use near_mpc_contract_interface::types::{DomainConfig, Protocol}; +use near_mpc_contract_interface::types::DomainConfig; use near_sdk::near; use std::collections::{BTreeSet, HashSet}; @@ -158,17 +161,30 @@ impl RunningContractState { return Err(DomainError::UnknownDomainInProposal { domain_id: *id }.into()); } } - for domain in self.domains.domains() { - let effective_threshold = overlay - .get(&domain.id) - .copied() - .unwrap_or(domain.reconstruction_threshold); - let proposed = DomainConfig { - reconstruction_threshold: effective_threshold, - ..domain.clone() - }; - validate_domain_threshold(&proposed, new_num_participants)?; + let effective_domains: Vec = self + .domains + .domains() + .iter() + .map(|domain| { + let effective_threshold = overlay + .get(&domain.id) + .copied() + .unwrap_or(domain.reconstruction_threshold); + DomainConfig { + reconstruction_threshold: effective_threshold, + ..domain.clone() + } + }) + .collect(); + for domain in &effective_domains { + validate_domain_threshold(domain, new_num_participants)?; } + // 3.11-transition lock: the overlay can rewrite per-domain thresholds, + // so it could leave CaitSith domains with differing thresholds — which + // `vote_add_domains` forbids and the legacy `DBCol::Triple` mirror + // (#3292) requires. Fail fast here; `with_overlaid_thresholds` re-runs + // this same check at the final resharing transition. + validate_caitsith_uniform_threshold(&effective_domains)?; // ensure the signer is a proposed participant let candidate = AuthenticatedAccountId::new(proposal.participants())?; @@ -212,29 +228,18 @@ impl RunningContractState { } // 3.11-transition lock: all CaitSith domains (ForeignTx included) must // share one `reconstruction_threshold` so the legacy unprefixed - // `DBCol::Triple` mirror (#3292) can't collide. If no CaitSith - // domain exists yet the first one is free to pick any valid `t`; - // any later CaitSith — in the same proposal or in future calls — - // must match it. Remove once #3298 drops the mirror. - let mut expected_caitsith_t = self + // `DBCol::Triple` mirror (#3292) can't collide. If no CaitSith domain + // exists yet the first one is free to pick any valid `t`; any later + // CaitSith — already present or in this proposal — must match it. + // Remove once #3298 drops the mirror. + let existing_and_new: Vec = self .domains .domains() .iter() - .find(|d| d.protocol == Protocol::CaitSith) - .map(|d| d.reconstruction_threshold.inner()); - for domain in &domains { - if domain.protocol != Protocol::CaitSith { - continue; - } - let found = domain.reconstruction_threshold.inner(); - match expected_caitsith_t { - None => expected_caitsith_t = Some(found), - Some(expected) if expected != found => { - return Err(DomainError::CaitsithThresholdMismatch { expected, found }.into()); - } - Some(_) => {} - } - } + .cloned() + .chain(domains.iter().cloned()) + .collect(); + validate_caitsith_uniform_threshold(&existing_and_new)?; let participant = AuthenticatedParticipantId::new(self.parameters.participants())?; let n_votes = self.add_domains_votes.vote(domains.clone(), &participant); if self.parameters.participants().len() as u64 == n_votes { From cdae919012d0d23ebf10d28168c44fe4ffbc6049 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:00:29 +0200 Subject: [PATCH 15/34] Less flaky tests --- crates/contract/src/state/resharing.rs | 61 ++++++++++++++++---------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/crates/contract/src/state/resharing.rs b/crates/contract/src/state/resharing.rs index feadb972e1..c479567649 100644 --- a/crates/contract/src/state/resharing.rs +++ b/crates/contract/src/state/resharing.rs @@ -476,6 +476,8 @@ pub mod tests { #[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; @@ -483,35 +485,46 @@ pub mod tests { /// `per_domain_thresholds` overlay must be applied to the new /// `DomainRegistry`, and the running-state `parameters` overlay /// must be cleared. + /// + /// The fixture is deterministic on purpose: it keeps the participant + /// set unchanged (a key-refresh resharing) so the proposed participant + /// count equals the running count (`n >= 3`, guaranteed by + /// `gen_running_state`). That guarantees `n` is itself a valid + /// reconstruction threshold and always differs from the default `2` — + /// avoiding the flakiness of deriving the new value from the random + /// cluster threshold, which lands on `2` whenever the proposal has 2 or + /// 3 participants. #[test] fn vote_reshared__final_transition__should_apply_overlay_to_registry() { - // Given a resharing state whose proposal carries a per-domain overlay - // setting domain 0's threshold to a new value distinct from the - // existing one. - let (mut env, mut state) = gen_resharing_state(1); - let original_threshold = - state.previous_running_state.domains.domains()[0].reconstruction_threshold; - // Pick a new value that is achievable for the proposed participant - // count: the current proposal's threshold is a safe lower-bound. - let new_value = state - .resharing_key - .proposed_parameters() - .threshold() - .value(); - assert!(new_value >= 2); - let new_threshold = ReconstructionThreshold::new(new_value); + // Given a running state with a single CaitSith domain at the default + // reconstruction threshold (2), and a resharing proposal over the + // same participant set carrying an overlay that moves that domain to + // `n` — a value valid for `n` participants and distinct from 2. + let mut env = Environment::new(Some(100), None, None); + let mut running = gen_running_state(1); + let current_params = running.parameters.clone(); + let n = current_params.participants().len() as u64; + assert!(n >= 3, "gen_running_state guarantees at least 3 participants"); + let domain_id = running.domains.domains()[0].id; + let original_threshold = running.domains.domains()[0].reconstruction_threshold; + let new_threshold = ReconstructionThreshold::new(n); assert_ne!(new_threshold, original_threshold); - let domain_id = state.previous_running_state.domains.domains()[0].id; let mut overlay = BTreeMap::new(); overlay.insert(domain_id, new_threshold); - let mut proposal_with_overlay = state.resharing_key.proposed_parameters().clone(); - proposal_with_overlay = - proposal_with_overlay.with_per_domain_thresholds(overlay.clone()); - state.resharing_key = crate::state::key_event::KeyEvent::new( - state.prospective_epoch_id(), - state.previous_running_state.domains.domains()[0].clone(), - proposal_with_overlay, - ); + let proposal = current_params.with_per_domain_thresholds(overlay); + + // Drive the proposal to acceptance so we transition into Resharing + // through the real vote path (which also exercises the fail-fast + // overlay validation in `process_new_parameters_proposal`). + let prospective_epoch_id = running.prospective_epoch_id(); + let mut state = None; + for (account, _, _) in proposal.participants().participants() { + env.set_signer(account); + state = running + .vote_new_parameters(prospective_epoch_id, &proposal) + .unwrap(); + } + let mut state = state.expect("Should've transitioned into resharing"); // When all candidates vote-reshared for the (single) domain let leader = find_leader(&state.resharing_key); From 34ba83caab1e5c886c9b64504ee4b47512c654fd Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:05:39 +0200 Subject: [PATCH 16/34] cargo fmt --- crates/contract/src/state/resharing.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/contract/src/state/resharing.rs b/crates/contract/src/state/resharing.rs index c479567649..eb1888988b 100644 --- a/crates/contract/src/state/resharing.rs +++ b/crates/contract/src/state/resharing.rs @@ -504,7 +504,10 @@ pub mod tests { let mut running = gen_running_state(1); let current_params = running.parameters.clone(); let n = current_params.participants().len() as u64; - assert!(n >= 3, "gen_running_state guarantees at least 3 participants"); + assert!( + n >= 3, + "gen_running_state guarantees at least 3 participants" + ); let domain_id = running.domains.domains()[0].id; let original_threshold = running.domains.domains()[0].reconstruction_threshold; let new_threshold = ReconstructionThreshold::new(n); From af28d3022d2c8f62e0dda4e44f60c45f5dec820d Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:59:44 +0200 Subject: [PATCH 17/34] ProposedThresholdParameters type-split --- crates/contract/src/dto_mapping.rs | 29 ++++- crates/contract/src/lib.rs | 18 ++- crates/contract/src/primitives/test_utils.rs | 8 +- .../src/primitives/threshold_votes.rs | 26 ++-- crates/contract/src/primitives/thresholds.rs | 114 +++++++++++------- ...contract_borsh_schema_has_not_changed.snap | 32 +++-- crates/contract/src/state.rs | 4 +- crates/contract/src/state/resharing.rs | 54 +++++---- crates/contract/src/state/running.rs | 59 +++++++-- crates/contract/src/state/test_utils.rs | 10 +- crates/contract/src/v3_10_state.rs | 80 ++++++------ .../tests/inprocess/attestation_submission.rs | 7 +- .../snapshots/abi__abi_has_not_changed.snap | 50 ++++++-- crates/devnet/src/mpc.rs | 15 +-- crates/e2e-tests/src/cluster.rs | 14 ++- crates/near-mpc-contract-interface/src/lib.rs | 7 +- .../src/types/state.rs | 45 +++++-- crates/node/src/indexer/fake.rs | 1 + crates/node/src/indexer/participants.rs | 3 - 19 files changed, 373 insertions(+), 203 deletions(-) diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index 7f7c70b5f3..7f74659068 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -25,7 +25,7 @@ use crate::{ key_state::{AuthenticatedAccountId, AuthenticatedParticipantId, KeyForDomain, Keyset}, participants::{ParticipantInfo, Participants}, threshold_votes::ThresholdParametersVotes, - thresholds::ThresholdParameters, + thresholds::{ProposedThresholdParameters, ThresholdParameters}, }, state::{ ProtocolContractState, @@ -222,7 +222,17 @@ impl IntoContractType for dtos::ThresholdParameters { // rejected if they violate the threshold bounds. Deferring validation here also // lets tests construct parameters with sub-production thresholds. ThresholdParameters::new_unvalidated(self.participants.into_contract_type(), self.threshold) - .with_per_domain_thresholds(self.per_domain_thresholds) + } +} + +impl IntoContractType for dtos::ProposedThresholdParameters { + fn into_contract_type(self) -> ProposedThresholdParameters { + // Infallible for the same reason as `ThresholdParameters` above: the + // proposal is validated downstream in `process_new_parameters_proposal`. + ProposedThresholdParameters::new( + self.parameters.into_contract_type(), + self.per_domain_thresholds, + ) } } @@ -644,6 +654,12 @@ mod to_dto { } } + impl From for dtos::ProposedThresholdParameters { + fn from(params: ProposedThresholdParameters) -> Self { + (¶ms).into_dto_type() + } + } + impl From for dtos::ParticipantInfo { fn from(info: ParticipantInfo) -> Self { dtos::ParticipantInfo { @@ -733,6 +749,14 @@ impl IntoInterfaceType for &ThresholdParameters { dtos::ThresholdParameters { participants: self.participants().into_dto_type(), threshold: self.threshold(), + } + } +} + +impl IntoInterfaceType for &ProposedThresholdParameters { + fn into_dto_type(self) -> dtos::ProposedThresholdParameters { + dtos::ProposedThresholdParameters { + parameters: self.parameters().into_dto_type(), per_domain_thresholds: self.per_domain_thresholds().clone(), } } @@ -862,6 +886,7 @@ impl IntoInterfaceType for &ResharingContractState .iter() .map(|a| a.into_dto_type()) .collect(), + per_domain_thresholds: self.per_domain_thresholds.clone(), } } } diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 3ba752bf05..d98324b0bd 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -79,7 +79,7 @@ use primitives::{ domain::DomainRegistry, key_state::{AuthenticatedAccountId, AuthenticatedParticipantId, EpochId, KeyEventId, Keyset}, signature::{SignRequestArgs, SignatureRequest, YieldIndex}, - thresholds::{Threshold, ThresholdParameters}, + thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters}, }; use tee::measurements::{ContractExpectedMeasurements, MeasurementVoteAction, MeasurementVotes}; use tee::proposal::{CodeHashesVotes, LauncherHashVotes}; @@ -868,9 +868,9 @@ impl MpcContract { pub fn vote_new_parameters( &mut self, prospective_epoch_id: EpochId, - proposal: dtos::ThresholdParameters, + proposal: dtos::ProposedThresholdParameters, ) -> Result<(), Error> { - let proposal: ThresholdParameters = proposal.into_contract_type(); + let proposal: ProposedThresholdParameters = proposal.into_contract_type(); log!( "vote_new_parameters: signer={}, proposal={:?}", env::signer_account_id(), @@ -1592,7 +1592,11 @@ impl MpcContract { ) .expect("Require valid threshold parameters"); // this should never happen. current_params.validate_incoming_proposal(&threshold_parameters)?; - let res = running_state.transition_to_resharing_no_checks(&threshold_parameters); + // TEE-driven resharing only changes the participant set, so the + // per-domain reconstruction-threshold overlay is empty. + let proposed_parameters = + ProposedThresholdParameters::new(threshold_parameters, BTreeMap::new()); + let res = running_state.transition_to_resharing_no_checks(&proposed_parameters); if let Some(resharing) = res { self.protocol_state = ProtocolContractState::Resharing(resharing); } @@ -3648,7 +3652,10 @@ mod tests { .build(); testing_env!(voting_context); - let proposal = ThresholdParameters::new(participants, threshold).unwrap(); + let proposal = ProposedThresholdParameters::new( + ThresholdParameters::new(participants, threshold).unwrap(), + BTreeMap::new(), + ); contract.vote_new_parameters(EpochId::new(1), (&proposal).into_dto_type()) } @@ -5062,6 +5069,7 @@ mod tests { expected_params, ), cancellation_requests: HashSet::new(), + per_domain_thresholds: BTreeMap::new(), }; assert_eq!(*resharing_state, expected_resharing_state); diff --git a/crates/contract/src/primitives/test_utils.rs b/crates/contract/src/primitives/test_utils.rs index a1c6dda459..43d6bd312c 100644 --- a/crates/contract/src/primitives/test_utils.rs +++ b/crates/contract/src/primitives/test_utils.rs @@ -3,7 +3,7 @@ use crate::{ crypto_shared::types::{PublicKeyExtended, serializable::SerializableEdwardsPoint}, primitives::{ participants::{ParticipantInfo, Participants}, - thresholds::{Threshold, ThresholdParameters}, + thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters}, }, }; use curve25519_dalek::edwards::CompressedEdwardsY; @@ -163,6 +163,12 @@ pub fn gen_threshold_params(max_n: usize) -> ThresholdParameters { ThresholdParameters::new(gen_participants(n), Threshold::new(k as u64)).unwrap() } +/// Like [`gen_threshold_params`] but wrapped as a proposal with an empty +/// (no-change) per-domain overlay — the shape `vote_new_parameters` accepts. +pub fn gen_proposed_threshold_params(max_n: usize) -> ProposedThresholdParameters { + ProposedThresholdParameters::new(gen_threshold_params(max_n), BTreeMap::new()) +} + /// Infer a default purpose from the protocol. /// Used during migration from old state that lacks the `purpose` field. pub fn infer_purpose_from_protocol(protocol: Protocol) -> DomainPurpose { diff --git a/crates/contract/src/primitives/threshold_votes.rs b/crates/contract/src/primitives/threshold_votes.rs index a56ed562a7..a56a1e0614 100644 --- a/crates/contract/src/primitives/threshold_votes.rs +++ b/crates/contract/src/primitives/threshold_votes.rs @@ -1,21 +1,25 @@ -use crate::primitives::thresholds::ThresholdParameters; +use crate::primitives::thresholds::ProposedThresholdParameters; use crate::primitives::{key_state::AuthenticatedAccountId, participants::Participants}; use near_sdk::{log, near}; use std::collections::BTreeMap; -/// Tracks votes for ThresholdParameters (new participants and threshold). -/// Each current participant can maintain one vote. +/// Tracks votes for proposed threshold parameters (new participants, threshold, +/// and per-domain overlay). Each current participant can maintain one vote. // TODO(#2825): Replace with Votes from votes.rs // once this type is moved out of RunningContractState (which requires Clone + PartialEq + JSON). #[near(serializers=[borsh, json])] #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ThresholdParametersVotes { - pub(crate) proposal_by_account: BTreeMap, + pub(crate) proposal_by_account: BTreeMap, } impl ThresholdParametersVotes { /// return the number of votes for `proposal` cast by members of `participants` - pub fn n_votes(&self, proposal: &ThresholdParameters, participants: &Participants) -> u64 { + pub fn n_votes( + &self, + proposal: &ProposedThresholdParameters, + participants: &Participants, + ) -> u64 { u64::try_from( self.proposal_by_account .iter() @@ -36,7 +40,7 @@ impl ThresholdParametersVotes { /// vote). pub fn vote( &mut self, - proposal: &ThresholdParameters, + proposal: &ProposedThresholdParameters, participant: AuthenticatedAccountId, ) -> u64 { if self @@ -62,7 +66,7 @@ mod tests { use crate::primitives::{ key_state::AuthenticatedAccountId, participants::Participants, - test_utils::{gen_participant, gen_threshold_params}, + test_utils::{gen_participant, gen_proposed_threshold_params}, }; use near_mpc_contract_interface::types::{DomainId, ReconstructionThreshold}; use near_sdk::{test_utils::VMContextBuilder, testing_env}; @@ -78,11 +82,11 @@ mod tests { testing_env!(ctx.build()); let participant = AuthenticatedAccountId::new(&participants).expect("expected authentication"); - let params = gen_threshold_params(30); + let params = gen_proposed_threshold_params(30); let mut votes = ThresholdParametersVotes::default(); assert_eq!(votes.vote(¶ms, participant.clone()), 1); assert_eq!(votes.n_votes(¶ms, &participants), 1); - let params2 = gen_threshold_params(30); + let params2 = gen_proposed_threshold_params(30); assert_eq!(votes.vote(¶ms2, participant), 1); assert_eq!(votes.n_votes(¶ms2, &participants), 1); assert_eq!(votes.n_votes(¶ms, &participants), 0); @@ -123,7 +127,7 @@ mod tests { AuthenticatedAccountId::new(&participants).unwrap() }; - let base = gen_threshold_params(30); + let base = gen_proposed_threshold_params(30); let mut overlay_a = BTreeMap::new(); overlay_a.insert(DomainId(0), ReconstructionThreshold::new(2)); let mut overlay_b = BTreeMap::new(); @@ -161,7 +165,7 @@ mod tests { AuthenticatedAccountId::new(&old_participants).unwrap() }; - let params = gen_threshold_params(30); + let params = gen_proposed_threshold_params(30); let mut votes = ThresholdParametersVotes::default(); votes.vote(¶ms, auth_p0); votes.vote(¶ms, auth_p1); diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index 1663b12156..162b3f2aca 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -10,68 +10,31 @@ pub use near_mpc_contract_interface::types::Threshold; /// Minimum absolute threshold required. const MIN_THRESHOLD_ABSOLUTE: u64 = 2; -/// Stores information about the threshold key parameters: -/// - owners of key shares -/// - cryptographic threshold -/// - per-domain reconstruction thresholds (only populated when this struct -/// carries a resharing proposal; empty otherwise) +/// Stores the threshold key parameters: the owners of key shares +/// (`participants`) and the cryptographic `threshold`. This is the stored, +/// always-current shape. Per-domain reconstruction-threshold *proposals* are +/// carried separately by [`ProposedThresholdParameters`], so this type can +/// never hold a meaningless overlay. #[near(serializers=[borsh, json])] #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] pub struct ThresholdParameters { participants: Participants, threshold: Threshold, - /// Proposed per-domain `ReconstructionThreshold` updates for this - /// resharing. Empty map means "keep current per-domain thresholds"; - /// populated map must cover every existing domain (validated in - /// [`super::super::state::running::RunningContractState::process_new_parameters_proposal`]). - /// `serde(default)` keeps JSON decodes tolerant of the pre-#3169 shape; the - /// public wire (`state()`, `vote_new_parameters`) goes through the interface - /// DTO, while on-chain storage is borsh — neither depends on this attribute. - #[serde(default)] - per_domain_thresholds: BTreeMap, } impl ThresholdParameters { /// Constructs Threshold parameters from `participants` and `threshold` if the - /// threshold meets the absolute and relative validation criteria. The - /// `per_domain_thresholds` map is initialized empty — populate it on - /// resharing proposals via [`Self::with_per_domain_thresholds`]. + /// threshold meets the absolute and relative validation criteria. pub fn new(participants: Participants, threshold: Threshold) -> Result { match Self::validate_threshold(participants.len() as u64, threshold) { Ok(_) => Ok(ThresholdParameters { participants, threshold, - per_domain_thresholds: BTreeMap::new(), }), Err(err) => Err(err), } } - /// Builder-style helper: attach a per-domain threshold overlay. Used by - /// resharing proposal callers; non-proposal sites leave the map empty. - pub fn with_per_domain_thresholds( - mut self, - per_domain_thresholds: BTreeMap, - ) -> Self { - self.per_domain_thresholds = per_domain_thresholds; - self - } - - /// Returns the per-domain threshold overlay. Empty in non-proposal - /// contexts and on stored running-state parameters (after resharing - /// completes the overlay is consumed into the - /// [`super::domain::DomainRegistry`]). - pub fn per_domain_thresholds(&self) -> &BTreeMap { - &self.per_domain_thresholds - } - - /// 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(); - } - /// Ensures that the threshold `k` is sensible and meets the absolute and minimum requirements. /// That is: /// - threshold must be at least `MIN_THRESHOLD_ABSOLUTE` @@ -187,7 +150,6 @@ impl ThresholdParameters { ThresholdParameters { participants, threshold, - per_domain_thresholds: BTreeMap::new(), } } @@ -206,6 +168,68 @@ impl ThresholdParameters { } } +/// A proposed set of threshold parameters submitted to `vote_new_parameters`: +/// the new [`ThresholdParameters`] plus an optional per-domain +/// `ReconstructionThreshold` overlay for the resharing it would trigger. +/// +/// The overlay is meaningful only while a proposal is in flight — it is applied +/// to the [`super::domain::DomainRegistry`] when resharing completes and the +/// stored [`ThresholdParameters`] is taken from [`Self::parameters`], so the +/// overlay is structurally dropped rather than scrubbed at runtime. +/// +/// An empty overlay means "keep current per-domain thresholds"; a populated map +/// must reference only existing domains (validated in +/// [`super::super::state::running::RunningContractState::process_new_parameters_proposal`]). +#[near(serializers=[borsh, json])] +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] +pub struct ProposedThresholdParameters { + parameters: ThresholdParameters, + #[serde(default)] + per_domain_thresholds: BTreeMap, +} + +impl ProposedThresholdParameters { + pub fn new( + parameters: ThresholdParameters, + per_domain_thresholds: BTreeMap, + ) -> Self { + ProposedThresholdParameters { + parameters, + per_domain_thresholds, + } + } + + /// 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, + ) -> Self { + self.per_domain_thresholds = per_domain_thresholds; + self + } + + /// The proposed stored parameters (participants + threshold). + pub fn parameters(&self) -> &ThresholdParameters { + &self.parameters + } + + /// The proposed per-domain reconstruction-threshold overlay. + pub fn per_domain_thresholds(&self) -> &BTreeMap { + &self.per_domain_thresholds + } + + /// Delegates to the proposed parameters' participants. + pub fn participants(&self) -> &Participants { + self.parameters.participants() + } + + /// Delegates to the proposed parameters' threshold. + pub fn threshold(&self) -> Threshold { + self.parameters.threshold() + } +} + #[cfg(test)] mod tests { use crate::{ @@ -281,7 +305,7 @@ mod tests { let params = gen_threshold_params(10); let proposal = gen_valid_params_proposal(¶ms); params - .validate_incoming_proposal(&proposal) + .validate_incoming_proposal(proposal.parameters()) .expect("Valid proposal should validate"); // Random proposals should not validate. diff --git a/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap b/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap index c5513e4231..de815783d2 100644 --- a/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap +++ b/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap @@ -15,10 +15,10 @@ BorshSchemaContainer { "ParticipantInfo", ], }, - "(AuthenticatedAccountId, ThresholdParameters)": Tuple { + "(AuthenticatedAccountId, ProposedThresholdParameters)": Tuple { elements: [ "AuthenticatedAccountId", - "ThresholdParameters", + "ProposedThresholdParameters", ], }, "(AuthenticatedParticipantId, DockerImageHash)": Tuple { @@ -157,10 +157,10 @@ BorshSchemaContainer { ], ), }, - "BTreeMap": Sequence { + "BTreeMap": Sequence { length_width: 4, length_range: 0..=4294967295, - elements: "(AuthenticatedAccountId, ThresholdParameters)", + elements: "(AuthenticatedAccountId, ProposedThresholdParameters)", }, "BTreeMap": Sequence { length_width: 4, @@ -837,6 +837,20 @@ BorshSchemaContainer { ], ), }, + "ProposedThresholdParameters": Struct { + fields: NamedFields( + [ + ( + "parameters", + "ThresholdParameters", + ), + ( + "per_domain_thresholds", + "BTreeMap", + ), + ], + ), + }, "ProposedUpdates": Struct { fields: NamedFields( [ @@ -1041,6 +1055,10 @@ BorshSchemaContainer { "cancellation_requests", "HashSet", ), + ( + "per_domain_thresholds", + "BTreeMap", + ), ], ), }, @@ -1176,10 +1194,6 @@ BorshSchemaContainer { "threshold", "Threshold", ), - ( - "per_domain_thresholds", - "BTreeMap", - ), ], ), }, @@ -1188,7 +1202,7 @@ BorshSchemaContainer { [ ( "proposal_by_account", - "BTreeMap", + "BTreeMap", ), ], ), diff --git a/crates/contract/src/state.rs b/crates/contract/src/state.rs index e4cb3810a3..4fb03837f1 100644 --- a/crates/contract/src/state.rs +++ b/crates/contract/src/state.rs @@ -11,7 +11,7 @@ use crate::primitives::{ domain::DomainRegistry, key_state::{AuthenticatedParticipantId, EpochId, KeyEventId}, participants::Participants, - thresholds::{Threshold, ThresholdParameters}, + thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters}, }; use initializing::InitializingContractState; use near_account_id::AccountId; @@ -126,7 +126,7 @@ impl ProtocolContractState { pub fn vote_new_parameters( &mut self, prospective_epoch_id: EpochId, - proposed_parameters: &ThresholdParameters, + proposed_parameters: &ProposedThresholdParameters, ) -> Result, Error> { match self { ProtocolContractState::Running(state) => { diff --git a/crates/contract/src/state/resharing.rs b/crates/contract/src/state/resharing.rs index eb1888988b..d92438b3b1 100644 --- a/crates/contract/src/state/resharing.rs +++ b/crates/contract/src/state/resharing.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use super::key_event::KeyEvent; use super::running::RunningContractState; @@ -6,8 +6,9 @@ use crate::errors::{Error, InvalidParameters}; use crate::primitives::key_state::{ AuthenticatedAccountId, EpochId, KeyEventId, KeyForDomain, Keyset, }; -use crate::primitives::thresholds::ThresholdParameters; +use crate::primitives::thresholds::ProposedThresholdParameters; use near_account_id::AccountId; +use near_mpc_contract_interface::types::{DomainId, ReconstructionThreshold}; use near_sdk::near; /// In this state, we reshare the key of every domain onto a new set of participants and threshold. @@ -31,6 +32,10 @@ pub struct ResharingContractState { pub reshared_keys: Vec, pub resharing_key: KeyEvent, pub cancellation_requests: HashSet, + /// Per-domain `ReconstructionThreshold` overlay carried from the accepted + /// proposal. Applied to the [`DomainRegistry`](crate::primitives::domain::DomainRegistry) + /// when resharing completes; empty means "keep current per-domain thresholds". + pub per_domain_thresholds: BTreeMap, } impl ResharingContractState { @@ -51,7 +56,7 @@ impl ResharingContractState { pub fn vote_new_parameters( &mut self, prospective_epoch_id: EpochId, - proposal: &ThresholdParameters, + proposal: &ProposedThresholdParameters, ) -> Result, Error> { let expected_prospective_epoch_id = self.prospective_epoch_id().next(); if prospective_epoch_id != expected_prospective_epoch_id { @@ -80,9 +85,10 @@ impl ResharingContractState { .get_domain_by_index(0) .unwrap() .clone(), - proposal.clone(), + proposal.parameters().clone(), ), cancellation_requests: HashSet::new(), + per_domain_thresholds: proposal.per_domain_thresholds().clone(), })); } Ok(None) @@ -138,17 +144,18 @@ impl ResharingContractState { self.resharing_key.proposed_parameters().clone(), ); } else { - let proposed = self.resharing_key.proposed_parameters(); + // Resharing complete: fold the per-domain overlay into the + // registry and store the proposed parameters. The overlay lives + // only on this resharing state, so it is structurally dropped + // here rather than scrubbed off the stored parameters. let new_domains = self .previous_running_state .domains - .with_overlaid_thresholds(proposed.per_domain_thresholds())?; - let mut new_parameters = proposed.clone(); - new_parameters.clear_per_domain_thresholds(); + .with_overlaid_thresholds(&self.per_domain_thresholds)?; return Ok(Some(RunningContractState::new( new_domains, Keyset::new(self.prospective_epoch_id(), self.reshared_keys.clone()), - new_parameters, + self.resharing_key.proposed_parameters().clone(), self.previous_running_state.add_domains_votes.clone(), ))); } @@ -208,14 +215,14 @@ pub mod tests { key_state::{AttemptId, KeyEventId}, test_utils::gen_account_id, threshold_votes::ThresholdParametersVotes, - thresholds::{Threshold, ThresholdParameters}, + thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters}, }, state::test_utils::gen_resharing_state, }; use near_account_id::AccountId; use near_mpc_contract_interface::types::DomainId; use rstest::rstest; - use std::collections::BTreeSet; + use std::collections::{BTreeMap, BTreeSet}; fn test_resharing_contract_state_for(num_domains: usize) { println!("Testing with {} domains", num_domains); @@ -412,6 +419,9 @@ pub mod tests { .subset(new_participants_1.len() - old_participants.len()..new_participants_1.len()); let new_params_1 = ThresholdParameters::new(new_participants_1, new_threshold).unwrap(); let new_params_2 = ThresholdParameters::new(new_participants_2, new_threshold).unwrap(); + // Proposals carry an empty (no-change) per-domain overlay. + let proposed_1 = ProposedThresholdParameters::new(new_params_1.clone(), BTreeMap::new()); + let proposed_2 = ProposedThresholdParameters::new(new_params_2.clone(), BTreeMap::new()); state .previous_running_state .parameters @@ -430,10 +440,10 @@ pub mod tests { { env.set_signer(&old_participants.participants()[0].0); let _ = state - .vote_new_parameters(state.prospective_epoch_id(), &new_params_1) + .vote_new_parameters(state.prospective_epoch_id(), &proposed_1) .unwrap_err(); let _ = state - .vote_new_parameters(state.prospective_epoch_id().next().next(), &new_params_1) + .vote_new_parameters(state.prospective_epoch_id().next().next(), &proposed_1) .unwrap_err(); } @@ -443,7 +453,7 @@ pub mod tests { env.set_signer(account); assert!(new_state.is_none()); new_state = state - .vote_new_parameters(state.prospective_epoch_id().next(), &new_params_1) + .vote_new_parameters(state.prospective_epoch_id().next(), &proposed_1) .unwrap(); } // We should've gotten a new resharing state. @@ -469,7 +479,7 @@ pub mod tests { // Repropose with new_params_2. That should fail. env.set_signer(&old_participants.participants()[0].0); let _ = new_state - .vote_new_parameters(new_state.prospective_epoch_id().next(), &new_params_2) + .vote_new_parameters(new_state.prospective_epoch_id().next(), &proposed_2) .unwrap_err(); } @@ -483,8 +493,9 @@ pub mod tests { /// On successful resharing transition, the proposal's /// `per_domain_thresholds` overlay must be applied to the new - /// `DomainRegistry`, and the running-state `parameters` overlay - /// must be cleared. + /// `DomainRegistry`. The overlay lives only on the proposal / + /// resharing state, so the stored `RunningContractState.parameters` + /// (a plain `ThresholdParameters`) cannot carry it at all. /// /// The fixture is deterministic on purpose: it keeps the participant /// set unchanged (a key-refresh resharing) so the proposed participant @@ -514,7 +525,7 @@ pub mod tests { assert_ne!(new_threshold, original_threshold); let mut overlay = BTreeMap::new(); overlay.insert(domain_id, new_threshold); - let proposal = current_params.with_per_domain_thresholds(overlay); + let proposal = ProposedThresholdParameters::new(current_params.clone(), overlay); // Drive the proposal to acceptance so we transition into Resharing // through the real vote path (which also exercises the fail-fast @@ -553,16 +564,13 @@ pub mod tests { } // Then the new running state's registry carries the overlay's - // threshold and its parameters overlay is cleared. + // threshold. (The stored parameters are a plain `ThresholdParameters` + // and structurally cannot carry an overlay.) let new_running = new_running.expect("resharing should have transitioned to Running"); assert_eq!( new_running.domains.domains()[0].reconstruction_threshold, new_threshold, ); - assert!( - new_running.parameters.per_domain_thresholds().is_empty(), - "stored parameters.per_domain_thresholds should be cleared after applying overlay" - ); } } } diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index 12fdfab85f..d5d1c38c70 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -9,7 +9,7 @@ use crate::primitives::{ }, key_state::{AuthenticatedAccountId, AuthenticatedParticipantId, EpochId, Keyset}, threshold_votes::ThresholdParametersVotes, - thresholds::ThresholdParameters, + thresholds::{ProposedThresholdParameters, ThresholdParameters}, }; use near_account_id::AccountId; use near_mpc_contract_interface::types::DomainConfig; @@ -65,7 +65,7 @@ impl RunningContractState { pub fn transition_to_resharing_no_checks( &mut self, - proposal: &ThresholdParameters, + proposal: &ProposedThresholdParameters, ) -> Option { if let Some(first_domain) = self.domains.get_domain_by_index(0) { let epoch_id = self.prospective_epoch_id(); @@ -78,16 +78,23 @@ impl RunningContractState { self.add_domains_votes.clone(), ), reshared_keys: Vec::new(), - resharing_key: KeyEvent::new(epoch_id, first_domain.clone(), proposal.clone()), + resharing_key: KeyEvent::new( + epoch_id, + first_domain.clone(), + proposal.parameters().clone(), + ), cancellation_requests: HashSet::new(), + per_domain_thresholds: proposal.per_domain_thresholds().clone(), }) } else { - // A new ThresholdParameters was proposed, but we have no keys, so directly - // transition into Running state but bump the EpochId. + // New parameters were proposed, but we have no keys, so directly + // transition into Running state but bump the EpochId. With no + // domains the per-domain overlay has nothing to apply to and is + // dropped. *self = RunningContractState::new( self.domains.clone(), Keyset::new(self.keyset.epoch_id.next(), Vec::new()), - proposal.clone(), + proposal.parameters().clone(), self.add_domains_votes.clone(), ); None @@ -99,7 +106,7 @@ impl RunningContractState { pub fn vote_new_parameters( &mut self, prospective_epoch_id: EpochId, - proposal: &ThresholdParameters, + proposal: &ProposedThresholdParameters, ) -> Result, Error> { let expected_prospective_epoch_id = self.prospective_epoch_id(); @@ -135,10 +142,11 @@ impl RunningContractState { /// Returns true if all participants of the proposed parameters voted for it. pub(super) fn process_new_parameters_proposal( &mut self, - proposal: &ThresholdParameters, + proposal: &ProposedThresholdParameters, ) -> Result { // ensure the proposal is valid against the current parameters - self.parameters.validate_incoming_proposal(proposal)?; + self.parameters + .validate_incoming_proposal(proposal.parameters())?; // Validate effective per-domain thresholds against the proposed new // participant count. Domains not present in the overlay keep their @@ -274,7 +282,7 @@ pub mod running_tests { use super::RunningContractState; use crate::primitives::domain::AddDomainsVotes; - use crate::primitives::test_utils::{NUM_PROTOCOLS, gen_threshold_params}; + use crate::primitives::test_utils::{NUM_PROTOCOLS, gen_proposed_threshold_params}; use crate::primitives::threshold_votes::ThresholdParametersVotes; use crate::state::key_event::tests::Environment; use crate::state::test_utils::{gen_running_state, gen_valid_params_proposal}; @@ -293,7 +301,7 @@ pub mod running_tests { let participants = state.parameters.participants().clone(); // Assert that random proposals get rejected. for (account_id, _, _) in participants.participants() { - let ksp = gen_threshold_params(30); + let ksp = gen_proposed_threshold_params(30); env.set_signer(account_id); let _ = state .vote_new_parameters(state.keyset.epoch_id.next(), &ksp) @@ -397,7 +405,14 @@ pub mod running_tests { resharing.prospective_epoch_id(), state.keyset.epoch_id.next(), ); - assert_eq!(resharing.resharing_key.proposed_parameters(), &proposal); + assert_eq!( + resharing.resharing_key.proposed_parameters(), + proposal.parameters() + ); + assert_eq!( + resharing.per_domain_thresholds, + *proposal.per_domain_thresholds() + ); } } @@ -551,8 +566,26 @@ pub mod running_tests { // proposed participant count let mut state = gen_running_state(1); let mut env = Environment::new(None, None, None); - env.set_signer(&state.parameters.participants().participants()[0].0); let proposal = gen_valid_params_proposal(&state.parameters); + // Sign as a participant present in BOTH the current and proposed sets: + // `gen_valid_params_proposal` keeps only a random subset of the current + // participants, so an arbitrary current participant may be absent from + // the proposal (rejected as a non-participant) and a freshly added one + // would be deferred as a pending newcomer. The retained overlap is + // non-empty (at least `threshold` current participants are kept). + let signer = proposal + .participants() + .participants() + .iter() + .map(|(account_id, _, _)| account_id.clone()) + .find(|account_id| { + state + .parameters + .participants() + .is_participant_given_account_id(account_id) + }) + .expect("proposal must retain at least one current participant"); + env.set_signer(&signer); // When voting with an empty per_domain_thresholds map (legacy shape) let res = state.vote_new_parameters(state.keyset.epoch_id.next(), &proposal); diff --git a/crates/contract/src/state/test_utils.rs b/crates/contract/src/state/test_utils.rs index 224e304c4c..7c2fe09c22 100644 --- a/crates/contract/src/state/test_utils.rs +++ b/crates/contract/src/state/test_utils.rs @@ -11,11 +11,12 @@ use crate::primitives::{ key_state::{EpochId, KeyForDomain, Keyset}, participants::{ParticipantId, Participants}, test_utils::{gen_participant, gen_threshold_params}, - thresholds::{Threshold, ThresholdParameters}, + thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters}, }; use rand::Rng; +use std::collections::BTreeMap; -pub fn gen_valid_params_proposal(params: &ThresholdParameters) -> ThresholdParameters { +pub fn gen_valid_params_proposal(params: &ThresholdParameters) -> ProposedThresholdParameters { let mut rng = rand::thread_rng(); let current_k = params.threshold().value() as usize; let current_n = params.participants().len(); @@ -48,7 +49,10 @@ pub fn gen_valid_params_proposal(params: &ThresholdParameters) -> ThresholdParam } let threshold = ((new_participants.len() as f64) * 0.6).ceil() as u64; - ThresholdParameters::new(new_participants, Threshold::new(threshold)).unwrap() + let parameters = ThresholdParameters::new(new_participants, Threshold::new(threshold)).unwrap(); + // Valid proposals carry an empty (no-change) per-domain overlay by default; + // tests that exercise overlays attach one via `with_per_domain_thresholds`. + ProposedThresholdParameters::new(parameters, BTreeMap::new()) } /// Generates a resharing state with the given number of domains. diff --git a/crates/contract/src/v3_10_state.rs b/crates/contract/src/v3_10_state.rs index cb901a004c..213fee523e 100644 --- a/crates/contract/src/v3_10_state.rs +++ b/crates/contract/src/v3_10_state.rs @@ -20,10 +20,9 @@ use crate::{ ckd::CKDRequest, domain::{AddDomainsVotes, DomainRegistry}, key_state::{AuthenticatedAccountId, EpochId, Keyset}, - participants::Participants, signature::{SignatureRequest, YieldIndex}, threshold_votes::ThresholdParametersVotes, - thresholds::{Threshold, ThresholdParameters}, + thresholds::{ProposedThresholdParameters, ThresholdParameters}, }, state::{ ProtocolContractState, initializing::InitializingContractState, @@ -33,30 +32,17 @@ use crate::{ update::ProposedUpdates, }; -/// `3.10.0` layout of `ThresholdParameters`. The new layout appends -/// `per_domain_thresholds: BTreeMap` -/// (see #3169). Borsh is positional, so old bytes can be decoded into this -/// shadow and then mapped to the new struct with the map defaulted to empty. -#[derive(Debug, BorshSerialize, BorshDeserialize)] -struct OldThresholdParameters { - participants: Participants, - threshold: Threshold, -} - -impl From for ThresholdParameters { - fn from(old: OldThresholdParameters) -> Self { - // Resharing votes from before this migration didn't carry per-domain - // thresholds, so the migrated proposal preserves the existing - // domains' thresholds (interpreted later as a no-change overlay). - ThresholdParameters::new_unvalidated(old.participants, old.threshold) - } -} - -/// `3.10.0` layout of `ThresholdParametersVotes` — same shape but with the -/// old `OldThresholdParameters` as the value type. +/// `3.10.0` layout of `ThresholdParametersVotes`. The stored +/// `ThresholdParameters` (`{ participants, threshold }`) is byte-identical +/// between 3.10.0 and the current layout, so no shadow is needed for it — the +/// real type decodes old bytes directly. Only the vote *value* type changed: +/// votes now carry [`ProposedThresholdParameters`], which appends a +/// `per_domain_thresholds` overlay. Borsh is positional, so old vote bytes are +/// decoded into the real `ThresholdParameters` and mapped to a proposal with an +/// empty (no-change) overlay. #[derive(Debug, BorshSerialize, BorshDeserialize)] struct OldThresholdParametersVotes { - proposal_by_account: BTreeMap, + proposal_by_account: BTreeMap, } impl From for ThresholdParametersVotes { @@ -65,19 +51,27 @@ impl From for ThresholdParametersVotes { proposal_by_account: old .proposal_by_account .into_iter() - .map(|(acc, params)| (acc, params.into())) + // Pre-migration votes didn't carry per-domain thresholds, so the + // migrated proposal gets an empty (no-change) overlay. + .map(|(acc, params)| { + ( + acc, + ProposedThresholdParameters::new(params, BTreeMap::new()), + ) + }) .collect(), } } } -/// `3.10.0` layout of `RunningContractState`. Mirrors the current shape but -/// uses the old `OldThresholdParameters` and `OldThresholdParametersVotes`. +/// `3.10.0` layout of `RunningContractState`. The stored `parameters` use the +/// real `ThresholdParameters` (byte-identical to 3.10.0); only `parameters_votes` +/// needs the [`OldThresholdParametersVotes`] shadow. #[derive(Debug, BorshSerialize, BorshDeserialize)] struct OldRunningContractState { domains: DomainRegistry, keyset: Keyset, - parameters: OldThresholdParameters, + parameters: ThresholdParameters, parameters_votes: OldThresholdParametersVotes, add_domains_votes: AddDomainsVotes, previously_cancelled_resharing_epoch_id: Option, @@ -88,7 +82,7 @@ impl From for RunningContractState { RunningContractState { domains: old.domains, keyset: old.keyset, - parameters: old.parameters.into(), + parameters: old.parameters, parameters_votes: old.parameters_votes.into(), add_domains_votes: old.add_domains_votes, previously_cancelled_resharing_epoch_id: old.previously_cancelled_resharing_epoch_id, @@ -196,26 +190,26 @@ impl From for crate::MpcContract { mod tests { use super::*; use crate::primitives::test_utils::{NUM_PROTOCOLS, gen_participants}; + use crate::primitives::thresholds::Threshold; - /// Borsh round-trip: write a `ThresholdParameters` in the OLD layout - /// (no `per_domain_thresholds` field), deserialize via the shadow, - /// convert to the new struct, and assert the overlay defaults to empty. + /// Borsh round-trip: write a `ThresholdParametersVotes` in the OLD layout + /// (vote values are bare `ThresholdParameters`, no `per_domain_thresholds`), + /// deserialize via the shadow, migrate, and assert each migrated proposal + /// gets an empty (no-change) overlay. #[test] - fn old_threshold_parameters__should_deserialize_into_empty_overlay() { - // Given old-layout bytes + fn old_threshold_parameter_votes__should_migrate_into_empty_overlay() { + // Given old-layout vote bytes: a single vote whose value is a bare + // `ThresholdParameters` (the 3.10.0 vote shape). let participants = gen_participants(NUM_PROTOCOLS); let n = participants.len() as u64; - let old = OldThresholdParameters { - participants, - threshold: Threshold::new(n), - }; - let bytes = borsh::to_vec(&old).unwrap(); + let params = ThresholdParameters::new(participants, Threshold::new(n)).unwrap(); + let bytes = borsh::to_vec(¶ms).unwrap(); - // When borsh-decoding through the shadow and migrating - let decoded: OldThresholdParameters = borsh::from_slice(&bytes).unwrap(); - let migrated: ThresholdParameters = decoded.into(); + // When borsh-decoding the value through the shadow's value type and migrating + let decoded: ThresholdParameters = borsh::from_slice(&bytes).unwrap(); + let migrated = ProposedThresholdParameters::new(decoded, BTreeMap::new()); - // Then per-domain overlay is empty and core fields round-trip + // Then the per-domain overlay is empty and core fields round-trip assert!(migrated.per_domain_thresholds().is_empty()); assert_eq!(migrated.threshold().value(), n); } diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index cb02d3e81c..937611a957 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -8,7 +8,7 @@ use mpc_contract::{ key_state::{AttemptId, EpochId, KeyForDomain, Keyset}, participants::{ParticipantId, ParticipantInfo}, test_utils::{bogus_ed25519_public_key, gen_participants}, - thresholds::{Threshold, ThresholdParameters}, + thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters}, }, tee::tee_state::NodeId, }; @@ -17,6 +17,7 @@ use near_mpc_contract_interface::types::{ ReconstructionThreshold, }; use near_mpc_contract_interface::types::{DomainConfig, DomainId, DomainPurpose}; +use std::collections::BTreeMap; use assert_matches::assert_matches; use near_account_id::AccountId; @@ -186,9 +187,11 @@ impl TestSetupBuilder { let context = create_context_for_participant(&node_id.account_id); testing_env!(context); + let proposal = + ProposedThresholdParameters::new(parameters.clone(), BTreeMap::new()); setup .contract - .vote_new_parameters(EpochId::new(6), parameters.clone().into()) + .vote_new_parameters(EpochId::new(6), proposal.into()) .unwrap(); } diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 274572a9fe..76a34ab631 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -1707,7 +1707,7 @@ expression: abi { "name": "proposal", "type_schema": { - "$ref": "#/definitions/ThresholdParameters" + "$ref": "#/definitions/ProposedThresholdParameters" } } ] @@ -3832,6 +3832,31 @@ expression: abi } } }, + "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.", + "type": "object", + "required": [ + "participants", + "threshold" + ], + "properties": { + "participants": { + "$ref": "#/definitions/Participants" + }, + "per_domain_thresholds": { + "default": {}, + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "threshold": { + "$ref": "#/definitions/Threshold" + } + } + }, "ProposedUpdates": { "type": "object", "required": [ @@ -4130,6 +4155,16 @@ expression: abi }, "uniqueItems": true }, + "per_domain_thresholds": { + "description": "Per-domain `ReconstructionThreshold` overlay carried from the accepted proposal. Applied to the `DomainRegistry` when resharing completes.", + "default": {}, + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, "previous_running_state": { "$ref": "#/definitions/RunningContractState" }, @@ -4549,7 +4584,7 @@ expression: abi "minimum": 0.0 }, "ThresholdParameters": { - "description": "Threshold parameters for distributed key operations.", + "description": "Threshold parameters for distributed key operations: the current participant set and the cryptographic threshold. This is the stored, always-current shape; per-domain reconstruction-threshold *proposals* live on [`ProposedThresholdParameters`].", "type": "object", "required": [ "participants", @@ -4559,15 +4594,6 @@ expression: abi "participants": { "$ref": "#/definitions/Participants" }, - "per_domain_thresholds": { - "default": {}, - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - }, "threshold": { "$ref": "#/definitions/Threshold" } @@ -4583,7 +4609,7 @@ expression: abi "proposal_by_account": { "type": "object", "additionalProperties": { - "$ref": "#/definitions/ThresholdParameters" + "$ref": "#/definitions/ProposedThresholdParameters" } } } diff --git a/crates/devnet/src/mpc.rs b/crates/devnet/src/mpc.rs index 245a0b5d68..7f2ad37b12 100644 --- a/crates/devnet/src/mpc.rs +++ b/crates/devnet/src/mpc.rs @@ -25,8 +25,8 @@ use near_jsonrpc_primitives::types::query::QueryResponseKind; use near_mpc_contract_interface::method_names; use near_mpc_contract_interface::types::{ DomainConfig, DomainPurpose, EpochId, NodeImageHash, ParticipantId, ParticipantInfo, - Participants, Protocol, ProtocolContractState, ReconstructionThreshold, Threshold, - ThresholdParameters, protocol_state_to_string, + Participants, ProposedThresholdParameters, Protocol, ProtocolContractState, + ReconstructionThreshold, Threshold, ThresholdParameters, protocol_state_to_string, }; use near_primitives::types::{BlockReference, Finality, FunctionArgs}; use near_primitives::views::QueryRequest; @@ -349,7 +349,6 @@ impl MpcInitContractCmd { participants: participant_entries, }, threshold: Threshold::new(self.threshold), - per_domain_thresholds: std::collections::BTreeMap::new(), }; let args = serde_json::to_vec(&InitV2Args { parameters, @@ -743,9 +742,11 @@ impl MpcVoteNewParametersCmd { ) }) .collect(); - let proposal = ThresholdParameters { - participants, - threshold, + let proposal = ProposedThresholdParameters { + parameters: ThresholdParameters { + participants, + threshold, + }, per_domain_thresholds, }; @@ -896,7 +897,7 @@ pub async fn read_contract_state( #[derive(Serialize)] struct VoteNewParametersArgs { prospective_epoch_id: EpochId, - proposal: ThresholdParameters, + proposal: ProposedThresholdParameters, } #[derive(Serialize)] diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index 63e65d18f3..f154981c25 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -9,8 +9,9 @@ use near_kit::AccountId; use near_mpc_contract_interface::method_names; use near_mpc_contract_interface::types::{ AccountId as ContractAccountId, CKDAppPublicKey, DomainConfig, DomainId, DomainPurpose, - Ed25519PublicKey, EpochId, ParticipantId, ParticipantInfo, Participants, Protocol, - ProtocolContractState, ReconstructionThreshold, Threshold, ThresholdParameters, + Ed25519PublicKey, EpochId, ParticipantId, ParticipantInfo, Participants, + ProposedThresholdParameters, Protocol, ProtocolContractState, ReconstructionThreshold, + Threshold, ThresholdParameters, }; use rand::SeedableRng; use rand::rngs::StdRng; @@ -502,9 +503,11 @@ impl MpcCluster { let participants = build_participants_from_nodes(new_participants, &self.nodes, current_participants); - let proposal = ThresholdParameters { - threshold: Threshold(new_threshold as u64), - participants, + let proposal = ProposedThresholdParameters { + parameters: ThresholdParameters { + threshold: Threshold(new_threshold as u64), + participants, + }, per_domain_thresholds: std::collections::BTreeMap::new(), }; @@ -1123,7 +1126,6 @@ async fn init_contract( let params = ThresholdParameters { threshold: Threshold(threshold as u64), participants, - per_domain_thresholds: std::collections::BTreeMap::new(), }; tracing::info!( diff --git a/crates/near-mpc-contract-interface/src/lib.rs b/crates/near-mpc-contract-interface/src/lib.rs index d5a189b4de..1b1ae9c898 100644 --- a/crates/near-mpc-contract-interface/src/lib.rs +++ b/crates/near-mpc-contract-interface/src/lib.rs @@ -22,9 +22,10 @@ pub mod types { pub use state::{ AddDomainsVotes, AttemptId, AuthenticatedAccountId, AuthenticatedParticipantId, Curve, DomainConfig, DomainPurpose, DomainRegistry, EpochId, InitializingContractState, KeyEvent, - KeyEventId, KeyEventInstance, KeyForDomain, Keyset, Protocol, ProtocolContractState, - ReconstructionThreshold, ResharingContractState, RunningContractState, Threshold, - ThresholdParameters, ThresholdParametersVotes, protocol_state_to_string, + KeyEventId, KeyEventInstance, KeyForDomain, Keyset, ProposedThresholdParameters, Protocol, + ProtocolContractState, ReconstructionThreshold, ResharingContractState, + RunningContractState, Threshold, ThresholdParameters, ThresholdParametersVotes, + protocol_state_to_string, }; pub use tee::NodeId; pub use updates::{ProposedUpdates, UpdateHash}; diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index 2331fc1713..37cf8dd876 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -130,18 +130,10 @@ pub use near_mpc_crypto_types::{KeyForDomain, Keyset}; // Threshold/Participants Types // ============================================================================= -/// Threshold parameters for distributed key operations. -// -// `per_domain_thresholds` carries a proposed update for each domain's -// `ReconstructionThreshold` when this struct flows into `vote_new_parameters`. -// An empty map means "keep current per-domain thresholds"; a populated map must -// cover every existing domain (validated by the contract). Outside of resharing -// proposals the map is empty. -// -// Input back-compat is intrinsic: `serde(default)` parses an old -// `{ participants, threshold }` payload without `per_domain_thresholds` as an -// empty map. The field is always serialized — an additive change that consumers -// which don't recognize it simply ignore. +/// Threshold parameters for distributed key operations: the current +/// participant set and the cryptographic threshold. This is the stored, +/// always-current shape; per-domain reconstruction-threshold *proposals* live +/// on [`ProposedThresholdParameters`]. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), @@ -150,6 +142,29 @@ pub use near_mpc_crypto_types::{KeyForDomain, Keyset}; pub struct ThresholdParameters { pub participants: Participants, pub threshold: Threshold, +} + +/// 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. +// +// `per_domain_thresholds` proposes an updated `ReconstructionThreshold` for the +// listed domains. An empty map means "keep current per-domain thresholds"; a +// populated map must reference only existing domains (validated by the +// contract). The overlay is applied to the `DomainRegistry` when resharing +// completes and never persists onto the stored `ThresholdParameters`. +// +// `serde(flatten)` keeps the wire shape flat — `{ participants, threshold, +// per_domain_thresholds }` — so callers submit the same JSON as before, and +// `serde(default)` parses a payload lacking `per_domain_thresholds` as empty. +#[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, } @@ -165,7 +180,7 @@ pub struct ThresholdParameters { derive(schemars::JsonSchema) )] pub struct ThresholdParametersVotes { - pub proposal_by_account: BTreeMap, + pub proposal_by_account: BTreeMap, } /// Votes for adding new domains. @@ -254,6 +269,10 @@ pub struct ResharingContractState { pub reshared_keys: Vec, pub resharing_key: KeyEvent, pub cancellation_requests: HashSet, + /// Per-domain `ReconstructionThreshold` overlay carried from the accepted + /// proposal. Applied to the `DomainRegistry` when resharing completes. + #[serde(default)] + pub per_domain_thresholds: BTreeMap, } /// The main protocol contract state enum. diff --git a/crates/node/src/indexer/fake.rs b/crates/node/src/indexer/fake.rs index a0bea32f51..24534a779a 100644 --- a/crates/node/src/indexer/fake.rs +++ b/crates/node/src/indexer/fake.rs @@ -229,6 +229,7 @@ impl FakeMpcContractState { participants_config_to_threshold_parameters(&new_participants), ), cancellation_requests: HashSet::new(), + per_domain_thresholds: std::collections::BTreeMap::new(), }); } diff --git a/crates/node/src/indexer/participants.rs b/crates/node/src/indexer/participants.rs index 1ef9641ddd..c0bf8ec592 100644 --- a/crates/node/src/indexer/participants.rs +++ b/crates/node/src/indexer/participants.rs @@ -490,7 +490,6 @@ mod tests { let params = ThresholdParameters { participants: chain_infos.clone(), threshold: Threshold(3), - per_domain_thresholds: std::collections::BTreeMap::new(), }; let converted = convert_participant_infos(params, None).unwrap(); @@ -517,7 +516,6 @@ mod tests { let params = ThresholdParameters { participants: chain_infos, threshold: Threshold(3), - per_domain_thresholds: std::collections::BTreeMap::new(), }; let converted = convert_participant_infos(params.clone(), None) .unwrap() @@ -555,7 +553,6 @@ mod tests { let params = ThresholdParameters { participants: new_infos, threshold: Threshold(3), - per_domain_thresholds: std::collections::BTreeMap::new(), }; print!("\n\nmy params: \n{:?}\n", params); let converted = convert_participant_infos(params, None); From 8e8adc8191932ece8d57892b2580c8568f28b424 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:37:09 +0200 Subject: [PATCH 18/34] fixing clippy --- crates/contract/src/primitives/thresholds.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index 162b3f2aca..cbf9f37dfd 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -179,7 +179,7 @@ impl ThresholdParameters { /// /// An empty overlay means "keep current per-domain thresholds"; a populated map /// must reference only existing domains (validated in -/// [`super::super::state::running::RunningContractState::process_new_parameters_proposal`]). +/// `RunningContractState::process_new_parameters_proposal`). #[near(serializers=[borsh, json])] #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] pub struct ProposedThresholdParameters { From caa00f1c13593ba5aa18c9a445efc844a5415dba Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:51:39 +0200 Subject: [PATCH 19/34] fixing syntax --- crates/contract/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index c5dc392bcb..78cfd6ee40 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -3788,7 +3788,10 @@ mod tests { // so signer_account_id (the participant) != predecessor_account_id (the forwarder). let (mut contract, participants, first_participant_id) = setup_tee_test_contract(3, 2); let threshold = Threshold::new(2); - let proposal = ThresholdParameters::new(participants, threshold).unwrap(); + let proposal = ProposedThresholdParameters::new( + ThresholdParameters::new(participants, threshold).unwrap(), + BTreeMap::new(), + ); let ctx = VMContextBuilder::new() .signer_account_id(first_participant_id) From 5511ec9aed83eb8eed6f0fb5b65b60238b104bbc Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:13:32 +0200 Subject: [PATCH 20/34] flatten overlaid-thresholds tests into tests module --- crates/contract/src/primitives/domain.rs | 311 +++++++++++------------ 1 file changed, 153 insertions(+), 158 deletions(-) diff --git a/crates/contract/src/primitives/domain.rs b/crates/contract/src/primitives/domain.rs index 8bd3844555..c8ccec7e95 100644 --- a/crates/contract/src/primitives/domain.rs +++ b/crates/contract/src/primitives/domain.rs @@ -298,6 +298,7 @@ impl AddDomainsVotes { } #[cfg(test)] +#[expect(non_snake_case)] pub mod tests { use super::{ AddDomainsVotes, Curve, DomainConfig, DomainId, DomainPurpose, DomainRegistry, @@ -311,6 +312,7 @@ pub mod tests { use near_sdk::test_utils::VMContextBuilder; use near_sdk::testing_env; use rstest::rstest; + use std::collections::BTreeMap; #[test] fn test_add_domains() { @@ -637,175 +639,168 @@ pub mod tests { assert_eq!(remaining.proposal_by_account[&auth_ids[1]], proposal_b); } - #[expect(non_snake_case)] - mod with_overlaid_thresholds { - use super::*; - use std::collections::BTreeMap; + fn registry_of(domains: Vec) -> DomainRegistry { + let next_domain_id = domains.iter().map(|d| d.id.0).max().map_or(0, |m| m + 1); + DomainRegistry::from_raw_validated(domains, next_domain_id).unwrap() + } - fn registry_of(domains: Vec) -> DomainRegistry { - let next_domain_id = domains.iter().map(|d| d.id.0).max().map_or(0, |m| m + 1); - DomainRegistry::from_raw_validated(domains, next_domain_id).unwrap() - } + #[test] + fn with_overlaid_thresholds__should_be_identity_when_overlay_is_empty() { + // Given a non-empty registry and an empty overlay + let registry = registry_of(vec![ + DomainConfig { + id: DomainId(0), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(3), + purpose: DomainPurpose::Sign, + }, + DomainConfig { + id: DomainId(1), + protocol: Protocol::Frost, + reconstruction_threshold: ReconstructionThreshold::new(2), + purpose: DomainPurpose::Sign, + }, + ]); + let overlay = BTreeMap::new(); - #[test] - fn with_overlaid_thresholds__should_be_identity_when_overlay_is_empty() { - // Given a non-empty registry and an empty overlay - let registry = registry_of(vec![ - DomainConfig { - id: DomainId(0), - protocol: Protocol::CaitSith, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::Sign, - }, - DomainConfig { - id: DomainId(1), - protocol: Protocol::Frost, - reconstruction_threshold: ReconstructionThreshold::new(2), - purpose: DomainPurpose::Sign, - }, - ]); - let overlay = BTreeMap::new(); + // When applying the overlay + let result = registry.with_overlaid_thresholds(&overlay).unwrap(); - // When applying the overlay - let result = registry.with_overlaid_thresholds(&overlay).unwrap(); + // Then the registry is structurally identical + assert_eq!(result, registry); + } - // Then the registry is structurally identical - assert_eq!(result, registry); - } + #[test] + fn with_overlaid_thresholds__should_apply_per_domain_updates() { + // Given a registry with two domains and an overlay targeting one + let registry = registry_of(vec![ + DomainConfig { + id: DomainId(0), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(3), + purpose: DomainPurpose::Sign, + }, + DomainConfig { + id: DomainId(1), + protocol: Protocol::Frost, + reconstruction_threshold: ReconstructionThreshold::new(2), + purpose: DomainPurpose::Sign, + }, + ]); + let mut overlay = BTreeMap::new(); + overlay.insert(DomainId(0), ReconstructionThreshold::new(5)); - #[test] - fn with_overlaid_thresholds__should_apply_per_domain_updates() { - // Given a registry with two domains and an overlay targeting one - let registry = registry_of(vec![ - DomainConfig { - id: DomainId(0), - protocol: Protocol::CaitSith, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::Sign, - }, - DomainConfig { - id: DomainId(1), - protocol: Protocol::Frost, - reconstruction_threshold: ReconstructionThreshold::new(2), - purpose: DomainPurpose::Sign, - }, - ]); - let mut overlay = BTreeMap::new(); - overlay.insert(DomainId(0), ReconstructionThreshold::new(5)); - - // When applying the overlay - let result = registry.with_overlaid_thresholds(&overlay).unwrap(); - - // Then only the targeted domain's threshold changes - assert_eq!( - result.domains()[0].reconstruction_threshold, - ReconstructionThreshold::new(5) - ); - assert_eq!( - result.domains()[1].reconstruction_threshold, - ReconstructionThreshold::new(2) - ); - } + // When applying the overlay + let result = registry.with_overlaid_thresholds(&overlay).unwrap(); - #[test] - fn with_overlaid_thresholds__should_reject_unknown_domain_id() { - // Given a registry with one domain and an overlay referencing a different ID - let registry = registry_of(vec![DomainConfig { + // Then only the targeted domain's threshold changes + assert_eq!( + result.domains()[0].reconstruction_threshold, + ReconstructionThreshold::new(5) + ); + assert_eq!( + result.domains()[1].reconstruction_threshold, + ReconstructionThreshold::new(2) + ); + } + + #[test] + fn with_overlaid_thresholds__should_reject_unknown_domain_id() { + // Given a registry with one domain and an overlay referencing a different ID + let registry = registry_of(vec![DomainConfig { + id: DomainId(0), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(3), + purpose: DomainPurpose::Sign, + }]); + let mut overlay = BTreeMap::new(); + overlay.insert(DomainId(42), ReconstructionThreshold::new(5)); + + // When applying the overlay + let err = registry.with_overlaid_thresholds(&overlay).unwrap_err(); + + // Then unknown-domain guard rejects + assert!( + err.to_string().contains("not in the current registry"), + "Expected UnknownDomainInProposal, got: {err}" + ); + } + + #[test] + fn with_overlaid_thresholds__should_reject_overlay_that_diverges_caitsith_thresholds() { + // Given a registry with two CaitSith domains sharing one threshold + // (the 3.11-transition lock invariant) and an overlay that rewrites + // only one of them to a different value. + let registry = registry_of(vec![ + DomainConfig { id: DomainId(0), protocol: Protocol::CaitSith, reconstruction_threshold: ReconstructionThreshold::new(3), purpose: DomainPurpose::Sign, - }]); - let mut overlay = BTreeMap::new(); - overlay.insert(DomainId(42), ReconstructionThreshold::new(5)); - - // When applying the overlay - let err = registry.with_overlaid_thresholds(&overlay).unwrap_err(); - - // Then unknown-domain guard rejects - assert!( - err.to_string().contains("not in the current registry"), - "Expected UnknownDomainInProposal, got: {err}" - ); - } + }, + DomainConfig { + id: DomainId(1), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(3), + purpose: DomainPurpose::Sign, + }, + ]); + let mut overlay = BTreeMap::new(); + overlay.insert(DomainId(0), ReconstructionThreshold::new(5)); - #[test] - fn with_overlaid_thresholds__should_reject_overlay_that_diverges_caitsith_thresholds() { - // Given a registry with two CaitSith domains sharing one threshold - // (the 3.11-transition lock invariant) and an overlay that rewrites - // only one of them to a different value. - let registry = registry_of(vec![ - DomainConfig { - id: DomainId(0), - protocol: Protocol::CaitSith, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::Sign, - }, - DomainConfig { - id: DomainId(1), - protocol: Protocol::CaitSith, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::Sign, - }, - ]); - let mut overlay = BTreeMap::new(); - overlay.insert(DomainId(0), ReconstructionThreshold::new(5)); - - // When applying the overlay - let err = registry.with_overlaid_thresholds(&overlay).unwrap_err(); - - // Then the 3.11-transition lock rejects the divergence - assert!( - err.to_string().contains("CaitSith threshold mismatch"), - "Expected CaitsithThresholdMismatch, got: {err}" - ); - } + // When applying the overlay + let err = registry.with_overlaid_thresholds(&overlay).unwrap_err(); - #[test] - fn with_overlaid_thresholds__should_accept_overlay_that_keeps_caitsith_thresholds_uniform() - { - // Given two CaitSith domains and a Frost domain, with an overlay - // that moves both CaitSith domains to the same new threshold. - let registry = registry_of(vec![ - DomainConfig { - id: DomainId(0), - protocol: Protocol::CaitSith, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::Sign, - }, - DomainConfig { - id: DomainId(1), - protocol: Protocol::CaitSith, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::Sign, - }, - DomainConfig { - id: DomainId(2), - protocol: Protocol::Frost, - reconstruction_threshold: ReconstructionThreshold::new(2), - purpose: DomainPurpose::Sign, - }, - ]); - let mut overlay = BTreeMap::new(); - overlay.insert(DomainId(0), ReconstructionThreshold::new(5)); - overlay.insert(DomainId(1), ReconstructionThreshold::new(5)); - - // When applying the overlay - let result = registry.with_overlaid_thresholds(&overlay).unwrap(); - - // Then both CaitSith domains move together and Frost is untouched - assert_eq!( - result.domains()[0].reconstruction_threshold, - ReconstructionThreshold::new(5) - ); - assert_eq!( - result.domains()[1].reconstruction_threshold, - ReconstructionThreshold::new(5) - ); - assert_eq!( - result.domains()[2].reconstruction_threshold, - ReconstructionThreshold::new(2) - ); - } + // Then the 3.11-transition lock rejects the divergence + assert!( + err.to_string().contains("CaitSith threshold mismatch"), + "Expected CaitsithThresholdMismatch, got: {err}" + ); + } + + #[test] + fn with_overlaid_thresholds__should_accept_overlay_that_keeps_caitsith_thresholds_uniform() { + // Given two CaitSith domains and a Frost domain, with an overlay + // that moves both CaitSith domains to the same new threshold. + let registry = registry_of(vec![ + DomainConfig { + id: DomainId(0), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(3), + purpose: DomainPurpose::Sign, + }, + DomainConfig { + id: DomainId(1), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(3), + purpose: DomainPurpose::Sign, + }, + DomainConfig { + id: DomainId(2), + protocol: Protocol::Frost, + reconstruction_threshold: ReconstructionThreshold::new(2), + purpose: DomainPurpose::Sign, + }, + ]); + let mut overlay = BTreeMap::new(); + overlay.insert(DomainId(0), ReconstructionThreshold::new(5)); + overlay.insert(DomainId(1), ReconstructionThreshold::new(5)); + + // When applying the overlay + let result = registry.with_overlaid_thresholds(&overlay).unwrap(); + + // Then both CaitSith domains move together and Frost is untouched + assert_eq!( + result.domains()[0].reconstruction_threshold, + ReconstructionThreshold::new(5) + ); + assert_eq!( + result.domains()[1].reconstruction_threshold, + ReconstructionThreshold::new(5) + ); + assert_eq!( + result.domains()[2].reconstruction_threshold, + ReconstructionThreshold::new(2) + ); } } From e804ee5717aec56ca24b3c3331c043855513dd63 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:13:32 +0200 Subject: [PATCH 21/34] gate with_per_domain_thresholds behind cfg(test) --- crates/contract/src/primitives/thresholds.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index cbf9f37dfd..97b2a3e231 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -201,6 +201,7 @@ impl ProposedThresholdParameters { /// Builder-style helper: replace the per-domain reconstruction-threshold /// overlay. Convenient for constructing proposals (notably in tests). + #[cfg(test)] pub fn with_per_domain_thresholds( mut self, per_domain_thresholds: BTreeMap, From df341029757f2da47e6e904773924e3a52bfa49d Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:13:32 +0200 Subject: [PATCH 22/34] fix vote test comment wording --- crates/contract/src/primitives/threshold_votes.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/src/primitives/threshold_votes.rs b/crates/contract/src/primitives/threshold_votes.rs index a56a1e0614..60090f3ccb 100644 --- a/crates/contract/src/primitives/threshold_votes.rs +++ b/crates/contract/src/primitives/threshold_votes.rs @@ -135,7 +135,7 @@ mod tests { let proposal_a = base.clone().with_per_domain_thresholds(overlay_a); let proposal_b = base.with_per_domain_thresholds(overlay_b); - // When each voter casts a different overlay + // When each voter casts a different proposal let mut votes = ThresholdParametersVotes::default(); votes.vote(&proposal_a, auth_p0); votes.vote(&proposal_b, auth_p1); From 76e5269ac7fba63cfce8f1d76bb80ca444f1b860 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:13:32 +0200 Subject: [PATCH 23/34] test migration through the shadow vote type --- crates/contract/src/v3_10_state.rs | 52 +++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/crates/contract/src/v3_10_state.rs b/crates/contract/src/v3_10_state.rs index 213fee523e..f7da955e27 100644 --- a/crates/contract/src/v3_10_state.rs +++ b/crates/contract/src/v3_10_state.rs @@ -191,26 +191,48 @@ mod tests { use super::*; use crate::primitives::test_utils::{NUM_PROTOCOLS, gen_participants}; use crate::primitives::thresholds::Threshold; - - /// Borsh round-trip: write a `ThresholdParametersVotes` in the OLD layout - /// (vote values are bare `ThresholdParameters`, no `per_domain_thresholds`), - /// deserialize via the shadow, migrate, and assert each migrated proposal - /// gets an empty (no-change) overlay. + use near_sdk::{test_utils::VMContextBuilder, testing_env}; + + /// Borsh round-trip *through the shadow type*: write a `ThresholdParametersVotes` + /// in the OLD 3.10.0 layout (vote values are bare `ThresholdParameters`, with no + /// `per_domain_thresholds`), decode it via [`OldThresholdParametersVotes`], run the + /// real [`From`] migration, and assert each migrated vote becomes a + /// `ProposedThresholdParameters` carrying an empty (no-change) overlay. + /// + /// This exercises both the shadow's `BorshDeserialize` and the conversion impl, so + /// it fails if either the old layout or the overlay-defaulting logic regresses. #[test] fn old_threshold_parameter_votes__should_migrate_into_empty_overlay() { - // Given old-layout vote bytes: a single vote whose value is a bare - // `ThresholdParameters` (the 3.10.0 vote shape). + // Given a participant set with one member installed as the signer, so we can + // mint an `AuthenticatedAccountId` to key the vote by. let participants = gen_participants(NUM_PROTOCOLS); let n = participants.len() as u64; - let params = ThresholdParameters::new(participants, Threshold::new(n)).unwrap(); - let bytes = borsh::to_vec(¶ms).unwrap(); + let voter_account = participants.participants()[0].0.clone(); - // When borsh-decoding the value through the shadow's value type and migrating - let decoded: ThresholdParameters = borsh::from_slice(&bytes).unwrap(); - let migrated = ProposedThresholdParameters::new(decoded, BTreeMap::new()); + let mut ctx = VMContextBuilder::new(); + ctx.signer_account_id(voter_account); + testing_env!(ctx.build()); + let voter = AuthenticatedAccountId::new(&participants).unwrap(); - // Then the per-domain overlay is empty and core fields round-trip - assert!(migrated.per_domain_thresholds().is_empty()); - assert_eq!(migrated.threshold().value(), n); + // and old-layout vote bytes: a single vote whose value is a bare + // `ThresholdParameters` (the 3.10.0 vote shape). + let params = ThresholdParameters::new(participants, Threshold::new(n)).unwrap(); + let old = OldThresholdParametersVotes { + proposal_by_account: BTreeMap::from([(voter.clone(), params)]), + }; + let bytes = borsh::to_vec(&old).unwrap(); + + // When decoding through the shadow type and running the real migration. + let decoded: OldThresholdParametersVotes = borsh::from_slice(&bytes).unwrap(); + let migrated: ThresholdParametersVotes = decoded.into(); + + // Then the single migrated vote retains the original threshold and gains an + // empty per-domain overlay. + let proposal = migrated + .proposal_by_account + .get(&voter) + .expect("migrated vote should be keyed by the original voter"); + assert!(proposal.per_domain_thresholds().is_empty()); + assert_eq!(proposal.threshold().value(), n); } } From c7b61231e44e29a4f27ff6fd014896c629ab9a65 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:54:47 +0200 Subject: [PATCH 24/34] Changing comments --- crates/contract/src/state/running.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index d5d1c38c70..e135c2d154 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -239,7 +239,7 @@ impl RunningContractState { // `DBCol::Triple` mirror (#3292) can't collide. If no CaitSith domain // exists yet the first one is free to pick any valid `t`; any later // CaitSith — already present or in this proposal — must match it. - // Remove once #3298 drops the mirror. + // Tracked for removal in #3306. let existing_and_new: Vec = self .domains .domains() From ad6b8fc6a8cf7499dd982482cb2d4559093a2a35 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:56:24 +0200 Subject: [PATCH 25/34] fixing cargo doc --- crates/contract/src/v3_11_2_state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/src/v3_11_2_state.rs b/crates/contract/src/v3_11_2_state.rs index 5ce08b54d1..790b70e64a 100644 --- a/crates/contract/src/v3_11_2_state.rs +++ b/crates/contract/src/v3_11_2_state.rs @@ -108,7 +108,7 @@ enum OldProtocolContractState { /// state written by the `3.11.2` contract still deserializes during migration. /// /// `protocol_state` carries the per-domain-threshold layout shift (#3169) and is shadowed -/// by [`OldProtocolContractState`]; every other field is byte-identical to `3.11.2`. +/// by `OldProtocolContractState`; every other field is byte-identical to `3.11.2`. #[derive(Debug, BorshSerialize, BorshDeserialize)] pub struct MpcContract { protocol_state: OldProtocolContractState, From 304a3d2a44fd8a24195077e08c9a0c68728c59a6 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:13:27 +0200 Subject: [PATCH 26/34] aligning the tests --- crates/contract/src/state/resharing.rs | 173 ++++++++++++------------- 1 file changed, 84 insertions(+), 89 deletions(-) diff --git a/crates/contract/src/state/resharing.rs b/crates/contract/src/state/resharing.rs index d92438b3b1..23f552f694 100644 --- a/crates/contract/src/state/resharing.rs +++ b/crates/contract/src/state/resharing.rs @@ -208,7 +208,10 @@ impl ResharingContractState { #[cfg(test)] pub mod tests { use crate::primitives::test_utils::NUM_PROTOCOLS; - use crate::state::{key_event::tests::find_leader, running::RunningContractState}; + use crate::state::{ + key_event::tests::{Environment, find_leader}, + running::RunningContractState, + }; use crate::{ primitives::{ domain::AddDomainsVotes, @@ -217,10 +220,10 @@ pub mod tests { threshold_votes::ThresholdParametersVotes, thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters}, }, - state::test_utils::gen_resharing_state, + state::test_utils::{gen_resharing_state, gen_running_state}, }; use near_account_id::AccountId; - use near_mpc_contract_interface::types::DomainId; + use near_mpc_contract_interface::types::{DomainId, ReconstructionThreshold}; use rstest::rstest; use std::collections::{BTreeMap, BTreeSet}; @@ -483,94 +486,86 @@ pub mod tests { .unwrap_err(); } + /// On successful resharing transition, the proposal's + /// `per_domain_thresholds` overlay must be applied to the new + /// `DomainRegistry`. The overlay lives only on the proposal / + /// resharing state, so the stored `RunningContractState.parameters` + /// (a plain `ThresholdParameters`) cannot carry it at all. + /// + /// The fixture is deterministic on purpose: it keeps the participant + /// set unchanged (a key-refresh resharing) so the proposed participant + /// count equals the running count (`n >= 3`, guaranteed by + /// `gen_running_state`). That guarantees `n` is itself a valid + /// reconstruction threshold and always differs from the default `2` — + /// avoiding the flakiness of deriving the new value from the random + /// cluster threshold, which lands on `2` whenever the proposal has 2 or + /// 3 participants. #[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; - - /// On successful resharing transition, the proposal's - /// `per_domain_thresholds` overlay must be applied to the new - /// `DomainRegistry`. The overlay lives only on the proposal / - /// resharing state, so the stored `RunningContractState.parameters` - /// (a plain `ThresholdParameters`) cannot carry it at all. - /// - /// The fixture is deterministic on purpose: it keeps the participant - /// set unchanged (a key-refresh resharing) so the proposed participant - /// count equals the running count (`n >= 3`, guaranteed by - /// `gen_running_state`). That guarantees `n` is itself a valid - /// reconstruction threshold and always differs from the default `2` — - /// avoiding the flakiness of deriving the new value from the random - /// cluster threshold, which lands on `2` whenever the proposal has 2 or - /// 3 participants. - #[test] - fn vote_reshared__final_transition__should_apply_overlay_to_registry() { - // Given a running state with a single CaitSith domain at the default - // reconstruction threshold (2), and a resharing proposal over the - // same participant set carrying an overlay that moves that domain to - // `n` — a value valid for `n` participants and distinct from 2. - let mut env = Environment::new(Some(100), None, None); - let mut running = gen_running_state(1); - let current_params = running.parameters.clone(); - let n = current_params.participants().len() as u64; - assert!( - n >= 3, - "gen_running_state guarantees at least 3 participants" - ); - let domain_id = running.domains.domains()[0].id; - let original_threshold = running.domains.domains()[0].reconstruction_threshold; - let new_threshold = ReconstructionThreshold::new(n); - assert_ne!(new_threshold, original_threshold); - let mut overlay = BTreeMap::new(); - overlay.insert(domain_id, new_threshold); - let proposal = ProposedThresholdParameters::new(current_params.clone(), overlay); - - // Drive the proposal to acceptance so we transition into Resharing - // through the real vote path (which also exercises the fail-fast - // overlay validation in `process_new_parameters_proposal`). - let prospective_epoch_id = running.prospective_epoch_id(); - let mut state = None; - for (account, _, _) in proposal.participants().participants() { - env.set_signer(account); - state = running - .vote_new_parameters(prospective_epoch_id, &proposal) - .unwrap(); - } - let mut state = state.expect("Should've transitioned into resharing"); - - // When all candidates vote-reshared for the (single) domain - let leader = find_leader(&state.resharing_key); - env.set_signer(&leader.0); - let key_event_id = KeyEventId { - attempt_id: AttemptId::new(), - domain_id, - epoch_id: state.prospective_epoch_id(), - }; - state.start(key_event_id, 0).unwrap(); - let mut new_running = None; - let candidates: Vec<_> = state - .resharing_key - .proposed_parameters() - .participants() - .participants() - .iter() - .map(|(acc, _, _)| acc.clone()) - .collect(); - for account in &candidates { - env.set_signer(account); - new_running = state.vote_reshared(key_event_id).unwrap(); - } + #[test] + fn vote_reshared__final_transition__should_apply_overlay_to_registry() { + // Given a running state with a single CaitSith domain at the default + // reconstruction threshold (2), and a resharing proposal over the + // same participant set carrying an overlay that moves that domain to + // `n` — a value valid for `n` participants and distinct from 2. + let mut env = Environment::new(Some(100), None, None); + let mut running = gen_running_state(1); + let current_params = running.parameters.clone(); + let n = current_params.participants().len() as u64; + assert!( + n >= 3, + "gen_running_state guarantees at least 3 participants" + ); + let domain_id = running.domains.domains()[0].id; + let original_threshold = running.domains.domains()[0].reconstruction_threshold; + let new_threshold = ReconstructionThreshold::new(n); + assert_ne!(new_threshold, original_threshold); + let mut overlay = BTreeMap::new(); + overlay.insert(domain_id, new_threshold); + let proposal = ProposedThresholdParameters::new(current_params.clone(), overlay); + + // Drive the proposal to acceptance so we transition into Resharing + // through the real vote path (which also exercises the fail-fast + // overlay validation in `process_new_parameters_proposal`). + let prospective_epoch_id = running.prospective_epoch_id(); + let mut state = None; + for (account, _, _) in proposal.participants().participants() { + env.set_signer(account); + state = running + .vote_new_parameters(prospective_epoch_id, &proposal) + .unwrap(); + } + let mut state = state.expect("Should've transitioned into resharing"); - // Then the new running state's registry carries the overlay's - // threshold. (The stored parameters are a plain `ThresholdParameters` - // and structurally cannot carry an overlay.) - let new_running = new_running.expect("resharing should have transitioned to Running"); - assert_eq!( - new_running.domains.domains()[0].reconstruction_threshold, - new_threshold, - ); + // When all candidates vote-reshared for the (single) domain + let leader = find_leader(&state.resharing_key); + env.set_signer(&leader.0); + let key_event_id = KeyEventId { + attempt_id: AttemptId::new(), + domain_id, + epoch_id: state.prospective_epoch_id(), + }; + state.start(key_event_id, 0).unwrap(); + let mut new_running = None; + let candidates: Vec<_> = state + .resharing_key + .proposed_parameters() + .participants() + .participants() + .iter() + .map(|(acc, _, _)| acc.clone()) + .collect(); + for account in &candidates { + env.set_signer(account); + new_running = state.vote_reshared(key_event_id).unwrap(); } + + // Then the new running state's registry carries the overlay's + // threshold. (The stored parameters are a plain `ThresholdParameters` + // and structurally cannot carry an overlay.) + let new_running = new_running.expect("resharing should have transitioned to Running"); + assert_eq!( + new_running.domains.domains()[0].reconstruction_threshold, + new_threshold, + ); } } From aff981c5d20d685edf742a70c7941c11e2941f2f Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:10:38 +0200 Subject: [PATCH 27/34] flatten strategy --- .../src/types/state.rs | 124 +++++++++++++++++- 1 file changed, 121 insertions(+), 3 deletions(-) diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index 37cf8dd876..10d0e5c8c0 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -154,9 +154,34 @@ pub struct ThresholdParameters { // contract). The overlay is applied to the `DomainRegistry` when resharing // completes and never persists onto the stored `ThresholdParameters`. // -// `serde(flatten)` keeps the wire shape flat — `{ participants, threshold, -// per_domain_thresholds }` — so callers submit the same JSON as before, and -// `serde(default)` parses a payload lacking `per_domain_thresholds` as empty. +// ## Wire contract and the `serde(flatten)` migration path +// +// The frozen wire contract is the flat object `{ participants, threshold, +// per_domain_thresholds }` (and the positional borsh layout `[participants, +// threshold, per_domain_thresholds]`). `serde(flatten)` is merely *how* that +// flat JSON is produced today — by reusing `ThresholdParameters` as a named +// sub-field — and is an implementation detail, not part of the contract. +// `serde(default)` parses a payload lacking `per_domain_thresholds` as empty, +// so pre-3.11 callers keep submitting `{ participants, threshold }` unchanged. +// +// To drop `serde(flatten)` later (e.g. after 3.12, to allow +// `#[serde(deny_unknown_fields)]` or to escape flatten's `Content`-buffering +// quirks), inline the two `parameters` fields: +// +// pub participants: Participants, +// pub threshold: Threshold, +// #[serde(default)] +// pub per_domain_thresholds: BTreeMap, +// +// That is byte-identical for JSON, borsh, and the generated ABI: borsh is +// positional and ignores serde attributes, and the inlined JSON keys match. So +// it needs no compat struct and no migration — only the conversion impls in +// `dto_mapping.rs` that read `.parameters` must follow the field move. The +// `proposed_threshold_parameters__*` wire-lock tests below pin this shape so a +// non-equivalent change fails loudly. Changing the *shape itself* (e.g. nesting +// the params under a `parameters` key) WOULD be wire-breaking and would instead +// require a compat deserializer accepting both the old flat and new nested +// forms across a transition window. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), @@ -445,4 +470,97 @@ mod tests { // Then assert_eq!(output, "Contract is not initialized\n"); } + + /// A proposal carrying a non-empty per-domain overlay, used by the + /// `ProposedThresholdParameters` wire-lock tests. + fn sample_proposal() -> ProposedThresholdParameters { + use crate::types::participants::{ParticipantId, ParticipantInfo}; + + let participants = Participants { + next_id: ParticipantId(1), + participants: vec![( + "alice.near".parse().unwrap(), + ParticipantId(0), + ParticipantInfo { + url: "https://alice.com".to_string(), + tls_public_key: "ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp" + .parse() + .unwrap(), + }, + )], + }; + ProposedThresholdParameters { + parameters: ThresholdParameters { + participants, + threshold: Threshold::new(1), + }, + per_domain_thresholds: BTreeMap::from([(DomainId(0), ReconstructionThreshold::new(1))]), + } + } + + /// Wire-format lock: the public contract for `ProposedThresholdParameters` is + /// the flat JSON object `{ participants, threshold, per_domain_thresholds }`, + /// not the `#[serde(flatten)]` mechanism that currently produces it. Pinning + /// the shape here means dropping `flatten` later (by inlining the `parameters` + /// fields) can be proven byte-identical rather than taken on faith. + #[test] + #[expect(non_snake_case)] + fn proposed_threshold_parameters__serializes_to_flat_keys() { + // Given a proposal carrying a non-empty per-domain overlay. + let proposal = sample_proposal(); + + // When serialized to JSON. + let value: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&proposal).unwrap()).unwrap(); + + // Then the object is flat: `participants`/`threshold` sit at the top level + // alongside `per_domain_thresholds`, with no nested `parameters` key. + let mut keys: Vec<&str> = value + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + assert_eq!(keys, ["participants", "per_domain_thresholds", "threshold"]); + } + + /// Pre-3.11 callers submit `{ participants, threshold }` with no + /// `per_domain_thresholds`. `serde(default)` must keep parsing that as an + /// empty (no-change) overlay — the backward-compat guarantee that let the + /// field be added without a wire break. + #[test] + #[expect(non_snake_case)] + fn proposed_threshold_parameters__legacy_payload_omitting_overlay__parses_as_empty() { + // Given a legacy proposal value with the overlay field absent. + let mut legacy = serde_json::to_value(sample_proposal()).unwrap(); + legacy + .as_object_mut() + .unwrap() + .remove("per_domain_thresholds"); + + // When deserialized. + let parsed: ProposedThresholdParameters = serde_json::from_value(legacy).unwrap(); + + // Then the overlay defaults to empty. + assert!(parsed.per_domain_thresholds.is_empty()); + } + + /// borsh is positional and ignores serde attributes, so the stored layout is + /// `[participants, threshold, per_domain_thresholds]` regardless of `flatten`. + /// A round-trip locks that the type stays borsh-stable across the eventual + /// inlining. + #[test] + #[expect(non_snake_case)] + fn proposed_threshold_parameters__borsh_round_trips() { + // Given a proposal carrying a non-empty per-domain overlay. + let proposal = sample_proposal(); + + // When borsh round-tripped. + let bytes = borsh::to_vec(&proposal).unwrap(); + let decoded: ProposedThresholdParameters = borsh::from_slice(&bytes).unwrap(); + + // Then it survives unchanged. + assert_eq!(decoded, proposal); + } } From 9f5c1dca4953b1f96a1184b8ff1c7c282d74df2c Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:39:38 +0200 Subject: [PATCH 28/34] use outside of fn --- crates/near-mpc-contract-interface/src/types/state.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index 10d0e5c8c0..72312bc8a5 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -450,6 +450,7 @@ pub fn protocol_state_to_string(contract_state: &ProtocolContractState) -> Strin #[cfg(test)] mod tests { use super::*; + use crate::types::participants::{ParticipantId, ParticipantInfo}; #[test] fn test_protocol_state_serialization() { @@ -474,8 +475,6 @@ mod tests { /// A proposal carrying a non-empty per-domain overlay, used by the /// `ProposedThresholdParameters` wire-lock tests. fn sample_proposal() -> ProposedThresholdParameters { - use crate::types::participants::{ParticipantId, ParticipantInfo}; - let participants = Participants { next_id: ParticipantId(1), participants: vec![( From c4a5a37ef849fc6f3f7ef2fd2e956c86f1d7ea36 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:43:58 +0200 Subject: [PATCH 29/34] rework comment --- .../contract/tests/snapshots/abi__abi_has_not_changed.snap | 2 +- crates/near-mpc-contract-interface/src/types/state.rs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index e33199fa92..c555a295a8 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -3863,7 +3863,7 @@ expression: abi } }, "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.", + "description": "A proposed set of threshold parameters submitted to `vote_new_parameters`. Carries the proposed [`ThresholdParameters`] (participant set and threshold) plus an optional per-domain `ReconstructionThreshold` overlay for the resharing it would trigger.", "type": "object", "required": [ "participants", diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index 72312bc8a5..5bc6dbf340 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -145,8 +145,9 @@ pub struct ThresholdParameters { } /// 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. +/// Carries the proposed [`ThresholdParameters`] (participant set and threshold) +/// plus an optional per-domain `ReconstructionThreshold` overlay for the +/// resharing it would trigger. // // `per_domain_thresholds` proposes an updated `ReconstructionThreshold` for the // listed domains. An empty map means "keep current per-domain thresholds"; a From 7ea21801873514ad6fe2e1f0273ab39bcc20ee97 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:17:20 +0200 Subject: [PATCH 30/34] change cryptographic to governance --- crates/near-mpc-contract-interface/src/types/state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index 5bc6dbf340..b6845635d9 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -131,7 +131,7 @@ pub use near_mpc_crypto_types::{KeyForDomain, Keyset}; // ============================================================================= /// Threshold parameters for distributed key operations: the current -/// participant set and the cryptographic threshold. This is the stored, +/// participant set and the governance threshold. This is the stored, /// always-current shape; per-domain reconstruction-threshold *proposals* live /// on [`ProposedThresholdParameters`]. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] From 91289529872b54e64ed88df3344fbf74089b8585 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:21:34 +0200 Subject: [PATCH 31/34] Adding TODO --- crates/near-mpc-contract-interface/src/types/state.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index b6845635d9..172ab246e0 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -191,6 +191,10 @@ pub struct ThresholdParameters { pub struct ProposedThresholdParameters { #[serde(flatten)] pub parameters: ThresholdParameters, + // TODO(#3495): drop `serde(default)` after the 3.12 release. It exists only + // so pre-3.11 `vote_new_parameters` payloads (which omit this field) still + // deserialize as an empty overlay; once all callers populate it explicitly + // the field should be mandatory so legacy/malformed payloads fail loudly. #[serde(default)] pub per_domain_thresholds: BTreeMap, } From df4ad0970b8de12792bb19d81ad43878c1838bb8 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:25:03 +0200 Subject: [PATCH 32/34] More comments --- crates/near-mpc-contract-interface/src/types/state.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index 172ab246e0..448d7125c4 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -532,7 +532,8 @@ mod tests { /// Pre-3.11 callers submit `{ participants, threshold }` with no /// `per_domain_thresholds`. `serde(default)` must keep parsing that as an /// empty (no-change) overlay — the backward-compat guarantee that let the - /// field be added without a wire break. + /// field be added without a wire break. Removed alongside the + /// `serde(default)` it guards (see `TODO(#3495)` on the field). #[test] #[expect(non_snake_case)] fn proposed_threshold_parameters__legacy_payload_omitting_overlay__parses_as_empty() { From cc40c217e453806e87c3c15e06b27969eb008882 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:26:32 +0200 Subject: [PATCH 33/34] Comma separated --- crates/devnet/src/cli.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/devnet/src/cli.rs b/crates/devnet/src/cli.rs index 4577f06032..5d49d53880 100644 --- a/crates/devnet/src/cli.rs +++ b/crates/devnet/src/cli.rs @@ -298,13 +298,13 @@ pub struct MpcVoteNewParametersCmd { /// The indices of the voters; leave empty to vote from every other participant. #[clap(long, value_delimiter = ',')] pub voters: Vec, - /// Optional per-domain reconstruction-threshold overlay, given as - /// repeated `DOMAIN_ID:THRESHOLD` pairs (e.g. - /// `--per-domain-threshold 0:6 --per-domain-threshold 1:5`). + /// Optional per-domain reconstruction-threshold overlay, given as a + /// comma-separated list of `DOMAIN_ID:THRESHOLD` pairs (e.g. + /// `--per-domain-threshold 0:6,1:5`). /// Omitting it preserves every domain's existing threshold; supplying it /// changes those listed and re-validates the rest against the new /// participant count. - #[clap(long = "per-domain-threshold", value_parser = parse_per_domain_threshold)] + #[clap(long = "per-domain-threshold", value_delimiter = ',', value_parser = parse_per_domain_threshold)] pub per_domain_thresholds: Vec<(u64, u64)>, } From 726635d5d6f03741753228a04603f9d2eb1141b4 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:05:31 +0200 Subject: [PATCH 34/34] Fixing CI --- crates/contract/tests/snapshots/abi__abi_has_not_changed.snap | 2 +- crates/near-mpc-contract-interface/src/types/state.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index c555a295a8..199b770523 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -4614,7 +4614,7 @@ expression: abi "minimum": 0.0 }, "ThresholdParameters": { - "description": "Threshold parameters for distributed key operations: the current participant set and the cryptographic threshold. This is the stored, always-current shape; per-domain reconstruction-threshold *proposals* live on [`ProposedThresholdParameters`].", + "description": "Threshold parameters for distributed key operations: the current participant set and the governance threshold. This is the stored, always-current shape; per-domain reconstruction-threshold *proposals* live on [`ProposedThresholdParameters`].", "type": "object", "required": [ "participants", diff --git a/crates/near-mpc-contract-interface/src/types/state.rs b/crates/near-mpc-contract-interface/src/types/state.rs index 448d7125c4..447bdcfd2f 100644 --- a/crates/near-mpc-contract-interface/src/types/state.rs +++ b/crates/near-mpc-contract-interface/src/types/state.rs @@ -529,11 +529,11 @@ mod tests { assert_eq!(keys, ["participants", "per_domain_thresholds", "threshold"]); } - /// Pre-3.11 callers submit `{ participants, threshold }` with no + /// TODO(#3495): Pre-3.11 callers submit `{ participants, threshold }` with no /// `per_domain_thresholds`. `serde(default)` must keep parsing that as an /// empty (no-change) overlay — the backward-compat guarantee that let the /// field be added without a wire break. Removed alongside the - /// `serde(default)` it guards (see `TODO(#3495)` on the field). + /// `serde(default)` it guards. #[test] #[expect(non_snake_case)] fn proposed_threshold_parameters__legacy_payload_omitting_overlay__parses_as_empty() {