// applied on module since near proc macro is unable to apply the expect lint #![expect(deprecated, reason = "ForeignChainConfiguration is being deprecated")] #![doc = include_str!("../README.md")] pub mod config; pub mod crypto_shared; pub mod errors; pub mod foreign_chain_rpc; pub mod foreign_chains_metadata; pub mod node_migrations; pub mod primitives; pub mod state; pub mod storage_keys; pub mod tee; pub mod update; #[cfg(feature = "dev-utils")] pub mod utils; pub mod v3_12_0_state; #[cfg(feature = "bench-contract-methods")] mod bench; mod dto_mapping; mod pending_requests; #[cfg(feature = "sandbox-test-methods")] mod sandbox_test_methods; /// Re-export of the fan-out cap so sandbox tests can lock against the same source of /// truth as the contract rather than duplicating the literal. #[cfg(feature = "sandbox-test-methods")] pub use crate::pending_requests::MAX_PENDING_REQUEST_FAN_OUT; use std::{ collections::{BTreeMap, BTreeSet}, time::Duration, }; use crate::{ dto_mapping::{ IntoContractType, IntoInterfaceType, TryIntoContractType, args_into_verify_foreign_tx_request, }, errors::{Error, RequestError}, foreign_chains_metadata::ForeignChainsMetadata, primitives::{ ckd::{CKDRequest, app_public_key_check, ckd_output_check}, domain::AddDomainsVotes, votes::ProposalHash, }, storage_keys::StorageKey, tee::tee_state::{TeeQuoteStatus, TeeState}, tee::verifier_votes::{TeeVerifierVotes, VerifierChangeProposal}, update::{ProposeUpdateArgs, ProposedUpdates, Update, UpdateId}, }; use config::Config; use crypto_shared::{ derive_key_secp256k1, kdf::derive_public_key_edwards_point_ed25519, types::{PublicKeyExtended, PublicKeyExtendedConversionError}, }; use errors::{ DomainError, InvalidParameters, InvalidState, PublicKeyError, RespondError, TeeError, }; use k256::elliptic_curve::PrimeField; use near_mpc_contract_interface::types::Ed25519PublicKey; use near_mpc_contract_interface::types::kdf::derive_tweak; use near_mpc_contract_interface::types::{ self as dtos, CKDResponse, Metrics, VerifyForeignTransactionRequest, VerifyForeignTransactionRequestArgs, VerifyForeignTransactionResponse, }; use near_mpc_contract_interface::{method_names, types::CKDRequestArgs}; use dtos::{Curve, DomainConfig, DomainId, DomainPurpose, Protocol}; use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash, TeeVerifierCodeHash}; use near_sdk::{ AccountId, CryptoHash, Gas, GasWeight, NearToken, Promise, PromiseError, PromiseOrValue, env, log, near, store::{IterableMap, Lazy, LookupMap}, }; use node_migrations::NodeMigrations; use primitives::{ domain::{DomainRegistry, max_reconstruction_threshold}, key_state::{AuthenticatedParticipantId, EpochId, KeyEventId, Keyset}, signature::{SignRequestArgs, SignatureRequest, YieldIndex}, thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters}, }; use tee::measurements::{ContractExpectedMeasurements, MeasurementVoteAction, MeasurementVotes}; use tee::proposal::{CodeHashesVotes, LauncherHashVotes}; use state::{ProtocolContractState, running::RunningContractState}; use tee::{ proposal::{LauncherVoteAction, NodeImageHash}, tee_state::{AttestationSubmissionError, NodeId, ParticipantInsertion, TeeValidationResult}, }; /// Register used to receive data id from `promise_await_data`. /// Note: This is an implementation constant, not a configurable policy value. const DATA_ID_REGISTER: u64 = 0; /// Minimum deposit required for sign requests const MINIMUM_SIGN_REQUEST_DEPOSIT: NearToken = NearToken::from_yoctonear(1); /// Minimum deposit required for CKD requests const MINIMUM_CKD_REQUEST_DEPOSIT: NearToken = NearToken::from_yoctonear(1); /// Entries to scan in the post-reshare `clean_invalid_attestations` sweep. External /// callers may pick a different value; this only governs the automatic invocation. const RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN: u32 = 100; /// Checks that the caller attached at least `minimum_deposit` and refunds any excess. /// /// A non-zero deposit is required so that the transaction must be signed by a /// full-access key (or a function-call access key whose `deposit` allowance is /// explicitly set). This prevents a **malicious frontend** from silently /// submitting signature requests on behalf of a user via a restricted /// function-call access key, because such keys cannot attach deposits by /// default. In other words, requiring a deposit ensures the user (or their /// full-access key) explicitly authorised the call. /// /// See the "Deposit requirement" section in the contract README for more /// details. fn require_deposit(minimum_deposit: NearToken, predecessor: &AccountId) { let deposit = env::attached_deposit(); match deposit.checked_sub(minimum_deposit) { None => { env::panic_str( &InvalidParameters::InsufficientDeposit { attached: deposit.as_yoctonear(), required: minimum_deposit.as_yoctonear(), } .to_string(), ); } Some(diff) => { if diff > NearToken::from_yoctonear(0) { log!("refund excess deposit {diff} to {predecessor}"); Promise::new(predecessor.clone()).transfer(diff).detach(); } } } } impl Default for MpcContract { fn default() -> Self { env::panic_str("Calling default not allowed."); } } #[near(contract_state)] #[derive(Debug)] pub struct MpcContract { protocol_state: ProtocolContractState, pending_signature_requests: LookupMap>, pending_ckd_requests: LookupMap>, pending_verify_foreign_tx_requests: LookupMap>, proposed_updates: ProposedUpdates, // TODO(#3475): drop this once we upgrade the contract and nodes start using // the new API. node_foreign_chain_support: SupportedForeignChainsByNode, config: Config, tee_state: TeeState, accept_requests: bool, node_migrations: NodeMigrations, // TODO(#2937): Remove via state migration. metrics: Metrics, foreign_chains: Lazy, /// The verifier contract account trusted for DCAP verification, or [`None`] /// until participants vote one in. Not yet used to dispatch verification. // TODO(#3639): once participants have voted a verifier in, make this // non-optional via a migration that requires it be set. tee_verifier_account_id: Option, tee_verifier_votes: TeeVerifierVotes, } #[near(serializers=[borsh])] #[derive(Debug)] struct SupportedForeignChainsByNode { foreign_chain_support_by_node: IterableMap, } impl Default for SupportedForeignChainsByNode { fn default() -> Self { Self { foreign_chain_support_by_node: IterableMap::new( StorageKey::SupportedForeignChainsByNode, ), } } } impl SupportedForeignChainsByNode { fn to_dto(&self) -> dtos::ForeignChainSupportByNode { let foreign_chain_configuration_by_node = self .foreign_chain_support_by_node .iter() .map(|(account_id, foreign_chains)| (account_id.clone(), foreign_chains.clone())) .collect(); dtos::ForeignChainSupportByNode { foreign_chain_support_by_node: foreign_chain_configuration_by_node, } } } impl MpcContract { pub(crate) fn public_key_extended( &self, domain_id: DomainId, ) -> Result { self.protocol_state.public_key(domain_id) } fn threshold(&self) -> Result { self.protocol_state.threshold() } fn add_signature_request(&mut self, request: SignatureRequest, data_id: CryptoHash) { pending_requests::push_pending_yield( &mut self.pending_signature_requests, request, data_id, ); } fn add_ckd_request(&mut self, request: CKDRequest, data_id: CryptoHash) { pending_requests::push_pending_yield(&mut self.pending_ckd_requests, request, data_id); } fn add_verify_foreign_tx_request( &mut self, request: VerifyForeignTransactionRequest, data_id: CryptoHash, ) { pending_requests::push_pending_yield( &mut self.pending_verify_foreign_tx_requests, request, data_id, ); } /// Common preconditions enforced on every user-facing request method (`sign`, /// `request_app_private_key`, `verify_foreign_transaction`): /// /// 1. The target domain exists and its purpose matches `expected_purpose`. /// 2. The caller attached enough prepaid gas to perform the yield/resume flow. /// 3. The caller attached at least `minimum_deposit` (excess is refunded). /// 4. The contract is currently accepting user requests. /// /// Returns the validated domain config and the caller's account id. fn check_request_preconditions( &self, domain_id: DomainId, expected_purpose: DomainPurpose, minimum_gas: Gas, minimum_deposit: NearToken, ) -> (DomainConfig, AccountId) { // 1. Look up the domain and check its purpose. let domains = match self.protocol_state.domain_registry() { Ok(domains) => domains, Err(err) => env::panic_str(&err.to_string()), }; let Some(domain_config) = domains.get_domain_by_domain_id(domain_id) else { env::panic_str( &InvalidParameters::DomainNotFound { provided: domain_id, } .to_string(), ); }; if domain_config.purpose != expected_purpose { env::panic_str( &InvalidParameters::WrongDomainPurpose { domain_id: domain_config.id, expected: expected_purpose, actual: domain_config.purpose, } .to_string(), ); } let domain_config = domain_config.clone(); // 2. Make sure the call will not run out of gas doing yield/resume logic. let prepaid_gas = env::prepaid_gas(); if prepaid_gas < minimum_gas { env::panic_str( &InvalidParameters::InsufficientGas { provided: prepaid_gas.as_gas(), required: minimum_gas.as_gas(), } .to_string(), ); } // 3. Require the minimum deposit and refund any excess. let predecessor = env::predecessor_account_id(); require_deposit(minimum_deposit, &predecessor); // 4. Refuse the request if the contract is not currently accepting requests // (e.g. because TEE validation has failed). if !self.accept_requests { env::panic_str(&TeeError::TeeValidationFailed.to_string()) } (domain_config, predecessor) } /// Creates a yield-resume promise that calls back into `callback_method` with the /// pre-serialized `callback_args`, and stores the resulting yield id via `insert`. /// /// This function calls `env::promise_return` and so must be the last operation performed /// in the enclosing contract method. fn enqueue_yield_request( &mut self, callback_method: &str, callback_args: Vec, callback_gas: Gas, insert: impl FnOnce(&mut Self, CryptoHash), ) { let promise_index = env::promise_yield_create( callback_method, callback_args, callback_gas, GasWeight(0), DATA_ID_REGISTER, ); let return_id: CryptoHash = env::read_register(DATA_ID_REGISTER) .expect("read_register failed") .try_into() .expect("conversion to CryptoHash failed"); insert(self, return_id); env::promise_return(promise_index); } } // User contract API #[near] impl MpcContract { /// `key_version` must be less than or equal to the value at `latest_key_version` /// To avoid overloading the network with too many requests, /// we ask for a small deposit for each signature request. #[handle_result] #[payable] pub fn sign(&mut self, request: SignRequestArgs) { log!( "sign: predecessor={:?}, request={:?}", env::predecessor_account_id(), request ); let (domain_config, predecessor) = self.check_request_preconditions( request.domain_id, DomainPurpose::Sign, Gas::from_tgas(self.config.sign_call_gas_attachment_requirement_tera_gas), MINIMUM_SIGN_REQUEST_DEPOSIT, ); // ensure the signer sent a valid signature request // It's important we fail here because the MPC nodes will fail in an identical way. // This allows users to get the error message match domain_config.protocol { Protocol::CaitSith | Protocol::DamgardEtAl => { let hash = *request.payload.as_ecdsa().expect("Payload is not Ecdsa"); k256::Scalar::from_repr(hash.into()) .into_option() .expect("Ecdsa payload cannot be converted to Scalar"); } Protocol::Frost => { request.payload.as_eddsa().expect("Payload is not EdDSA"); } Protocol::ConfidentialKeyDerivation => { env::panic_str( "ConfidentialKeyDerivation is not supported for signature responses", ); } } let request = SignatureRequest::new( request.domain_id, request.payload, &predecessor, &request.path, ); let callback_gas = Gas::from_tgas( self.config .return_signature_and_clean_state_on_success_call_tera_gas, ); let callback_args = serde_json::to_vec(&(&request,)).unwrap(); self.enqueue_yield_request( method_names::RETURN_SIGNATURE_AND_CLEAN_STATE_ON_SUCCESS, callback_args, callback_gas, move |this, id| this.add_signature_request(request, id), ); } /// This is the root public key combined from all the public keys of the participants. /// The domain parameter specifies which domain we're querying the public key for; /// the default is the first domain. #[handle_result] pub fn public_key(&self, domain_id: Option) -> Result { let domain_id = domain_id.unwrap_or_else(DomainId::legacy_ecdsa_id); self.public_key_extended(domain_id).map(Into::into) } /// This is the derived public key of the caller given path and predecessor /// if predecessor is not provided, it will be the caller of the contract. /// /// The domain parameter specifies which domain we're deriving the public key for; /// the default is the first domain. #[handle_result] pub fn derived_public_key( &self, path: String, predecessor: Option, domain_id: Option, ) -> Result { let predecessor: AccountId = predecessor.unwrap_or_else(env::predecessor_account_id); let tweak = derive_tweak(&predecessor, &path); let domain = domain_id.unwrap_or_else(DomainId::legacy_ecdsa_id); let public_key = self.public_key_extended(domain)?; let derived_public_key: dtos::PublicKey = match public_key { PublicKeyExtended::Secp256k1 { near_public_key } => { let secp_pk = dtos::Secp256k1PublicKey::try_from(&near_public_key) .expect("Secp256k1 variant always has a secp256k1 key"); let affine = *k256::PublicKey::try_from(&secp_pk) .expect("stored key is always valid") .as_affine(); let derived_public_key = derive_key_secp256k1(&affine, &tweak).map_err(PublicKeyError::from)?; derived_public_key.into() } PublicKeyExtended::Ed25519 { edwards_point, .. } => { let derived_public_key_edwards_point = derive_public_key_edwards_point_ed25519(&edwards_point, &tweak); dtos::Ed25519PublicKey::from(derived_public_key_edwards_point.compress()).into() } PublicKeyExtended::Bls12381 { public_key } => public_key, }; Ok(derived_public_key) } /// Key versions refer new versions of the root key that we may choose to generate on cohort /// changes. Older key versions will always work but newer key versions were never held by /// older signers. Newer key versions may also add new security features, like only existing /// within a secure enclave. The signature_scheme parameter specifies which protocol /// we're querying the latest version for. The default is Secp256k1. The default is **NOT** /// to query across all protocols. pub fn latest_key_version(&self, signature_scheme: Option) -> u32 { self.protocol_state .most_recent_domain_for_curve(signature_scheme.unwrap_or_default()) .unwrap() .0 as u32 } /// To avoid overloading the network with too many requests, /// we ask for a small deposit for each ckd request. /// /// Note: identity points are accepted in `AppPublicKeyPV` to support use cases /// where the derived key is intentionally public (no encryption). #[handle_result] #[payable] pub fn request_app_private_key(&mut self, request: CKDRequestArgs) { log!( "request_app_private_key: predecessor={:?}, request={:?}", env::predecessor_account_id(), request ); let domain_id: DomainId = request.domain_id; let (_, predecessor) = self.check_request_preconditions( domain_id, DomainPurpose::CKD, Gas::from_tgas(self.config.ckd_call_gas_attachment_requirement_tera_gas), MINIMUM_CKD_REQUEST_DEPOSIT, ); match &request.app_public_key { dtos::CKDAppPublicKey::AppPublicKey(_) => {} dtos::CKDAppPublicKey::AppPublicKeyPV(pk) => { if !app_public_key_check(pk) { env::panic_str("app public key check failed") } } } let request = CKDRequest::new( request.app_public_key, domain_id, &predecessor, &request.derivation_path, ); let callback_gas = Gas::from_tgas( self.config .return_ck_and_clean_state_on_success_call_tera_gas, ); let callback_args = serde_json::to_vec(&(&request,)).unwrap(); self.enqueue_yield_request( method_names::RETURN_CK_AND_CLEAN_STATE_ON_SUCCESS, callback_args, callback_gas, move |this, id| this.add_ckd_request(request, id), ); } /// Submit a verification + signing request for a foreign chain transaction. /// MPC nodes will verify the transaction on the foreign chain before signing. /// The signed payload is derived from the transaction ID (hash of tx_id). #[handle_result] #[payable] pub fn verify_foreign_transaction(&mut self, request: VerifyForeignTransactionRequestArgs) { log!( "verify_foreign_transaction: predecessor={:?}, request={:?}", env::predecessor_account_id(), request ); self.check_request_preconditions( request.domain_id, DomainPurpose::ForeignTx, Gas::from_tgas(self.config.sign_call_gas_attachment_requirement_tera_gas), MINIMUM_SIGN_REQUEST_DEPOSIT, ); let requested_chain = request.request.chain(); let supported_chains = self.get_supported_foreign_chains(); if !supported_chains.contains(&requested_chain) { env::panic_str( &InvalidParameters::ForeignChainNotSupported { requested: requested_chain, } .to_string(), ); } let callback_gas = Gas::from_tgas( self.config .return_signature_and_clean_state_on_success_call_tera_gas, ); let request = args_into_verify_foreign_tx_request(request); let callback_args = serde_json::to_vec(&(&request,)).unwrap(); self.enqueue_yield_request( method_names::RETURN_VERIFY_FOREIGN_TX_AND_CLEAN_STATE_ON_SUCCESS, callback_args, callback_gas, move |this, id| this.add_verify_foreign_tx_request(request, id), ); } } // Node API #[near] impl MpcContract { #[handle_result] pub fn respond( &mut self, request: SignatureRequest, response: dtos::SignatureResponse, ) -> Result<(), Error> { let signer = Self::assert_caller_is_signer(); log!("respond: signer={}, request={:?}", &signer, &request); self.assert_caller_is_attested_participant_and_protocol_active(); if !self.protocol_state.is_running_or_resharing() { return Err(InvalidState::ProtocolStateNotRunning.into()); } if !self.accept_requests { return Err(TeeError::TeeValidationFailed.into()); } let domain = request.domain_id; let public_key = self.public_key_extended(domain)?; let signature_is_valid = match (&response, public_key) { ( dtos::SignatureResponse::Secp256k1(signature_response), PublicKeyExtended::Secp256k1 { near_public_key }, ) => { // generate the expected public key let secp_pk = dtos::Secp256k1PublicKey::try_from(&near_public_key) .expect("Secp256k1 variant always has a secp256k1 key"); let affine = *k256::PublicKey::try_from(&secp_pk) .expect("stored key is always valid") .as_affine(); let expected_public_key = derive_key_secp256k1(&affine, &request.tweak).map_err(RespondError::from)?; let payload_hash = request.payload.as_ecdsa().expect("Payload is not ECDSA"); // Check the signature is correct near_mpc_signature_verifier::verify_ecdsa_signature( signature_response, payload_hash, &expected_public_key, ) .is_ok() } ( dtos::SignatureResponse::Ed25519 { signature }, PublicKeyExtended::Ed25519 { edwards_point: public_key_edwards_point, .. }, ) => { let derived_public_key_edwards_point = derive_public_key_edwards_point_ed25519( &public_key_edwards_point, &request.tweak, ); let derived_public_key_32_bytes = dtos::Ed25519PublicKey::from(derived_public_key_edwards_point.compress()); let message = request.payload.as_eddsa().expect("Payload is not EdDSA"); near_mpc_signature_verifier::verify_eddsa_signature( signature, message, &derived_public_key_32_bytes, ) .is_ok() } (signature_response, public_key_requested) => { return Err(RespondError::SignatureSchemeMismatch { mpc_scheme: Box::new(signature_response.clone()), user_scheme: Box::new(public_key_requested), } .into()); } }; if !signature_is_valid { return Err(RespondError::InvalidSignature.into()); } pending_requests::resolve_yields_for( &mut self.pending_signature_requests, &request, serde_json::to_vec(&response).unwrap(), ) } #[handle_result] pub fn respond_ckd(&mut self, request: CKDRequest, response: CKDResponse) -> Result<(), Error> { let signer = Self::assert_caller_is_signer(); log!("respond_ckd: signer={}, request={:?}", &signer, &request); if !self.protocol_state.is_running_or_resharing() { return Err(InvalidState::ProtocolStateNotRunning.into()); } if !self.accept_requests { return Err(TeeError::TeeValidationFailed.into()); } self.assert_caller_is_attested_participant_and_protocol_active(); let PublicKeyExtended::Bls12381 { public_key: dtos::PublicKey::Bls12381(public_key), } = self.public_key_extended(request.domain_id)? else { env::panic_str("Domain is not compatible with CKD (expected Bls12381 curve)"); }; match &request.app_public_key { dtos::CKDAppPublicKey::AppPublicKey(_) => {} dtos::CKDAppPublicKey::AppPublicKeyPV(app_pk) => { if !ckd_output_check(&request.app_id, &response, app_pk, &public_key) { env::panic_str("CKD output check failed"); } } } pending_requests::resolve_yields_for( &mut self.pending_ckd_requests, &request, serde_json::to_vec(&response).unwrap(), ) } #[handle_result] pub fn respond_verify_foreign_tx( &mut self, request: VerifyForeignTransactionRequest, response: VerifyForeignTransactionResponse, ) -> Result<(), Error> { let signer = Self::assert_caller_is_signer(); log!( "respond_verify_foreign_tx: signer={}, request={:?}", &signer, &request ); self.assert_caller_is_attested_participant_and_protocol_active(); if !self.protocol_state.is_running_or_resharing() { return Err(InvalidState::ProtocolStateNotRunning.into()); } if !self.accept_requests { return Err(TeeError::TeeValidationFailed.into()); } let domain = request.domain_id; let public_key = self.public_key_extended(domain.0.into())?; let signature_is_valid = match (&response.signature, public_key) { ( dtos::SignatureResponse::Secp256k1(signature_response), PublicKeyExtended::Secp256k1 { near_public_key }, ) => { let secp_pk = dtos::Secp256k1PublicKey::try_from(&near_public_key) .expect("Secp256k1 variant always has a secp256k1 key"); let payload_hash: [u8; 32] = response.payload_hash.0; // Check the signature is correct against the root public key near_mpc_signature_verifier::verify_ecdsa_signature( signature_response, &payload_hash, &secp_pk, ) .is_ok() } (signature_response, public_key_requested) => { return Err(RespondError::SignatureSchemeMismatch { mpc_scheme: Box::new(signature_response.clone()), user_scheme: Box::new(public_key_requested), } .into()); } }; if !signature_is_valid { return Err(RespondError::InvalidSignature.into()); } pending_requests::resolve_yields_for( &mut self.pending_verify_foreign_tx_requests, &request, serde_json::to_vec(&response).unwrap(), ) } /// (Prospective) Participants can submit their tee participant information through this /// endpoint. #[payable] #[handle_result] pub fn submit_participant_info( &mut self, proposed_participant_attestation: dtos::Attestation, tls_public_key: dtos::Ed25519PublicKey, ) -> Result<(), Error> { let proposed_participant_attestation = proposed_participant_attestation.try_into_contract_type()?; let account_key = env::signer_account_pk(); let account_id = Self::assert_caller_is_signer(); log!( "submit_participant_info: signer={}, proposed_participant_attestation={:?}, account_key={:?}", account_id, proposed_participant_attestation, account_key ); // Save the initial storage usage to know how much to charge the proposer for the storage // used let initial_storage = env::storage_usage(); let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); // The node always signs submissions with an Ed25519 key // (`near_signer_key`), so the signer key here is Ed25519 in practice. // Reject non-Ed25519 signer keys rather than silently storing a value // we could never match against in `is_caller_an_attested_participant`. let account_public_key = dtos::Ed25519PublicKey::try_from(&account_key).map_err(|_| { InvalidParameters::InvalidTeeRemoteAttestation { reason: "signer account key must be Ed25519".to_string(), } })?; // Add the participant information to the contract state let attestation_insertion_result = self .tee_state .add_participant( NodeId { account_id: account_id.clone(), tls_public_key, account_public_key, }, proposed_participant_attestation, tee_upgrade_deadline_duration, ) .map_err(|err| { let reason = match &err { AttestationSubmissionError::InvalidAttestation(_) => { format!("TeeQuoteStatus is invalid: {err}") } AttestationSubmissionError::TlsKeyOwnedByOtherAccount => err.to_string(), }; InvalidParameters::InvalidTeeRemoteAttestation { reason } })?; let caller_is_not_participant = self.voter_account().is_err(); let is_new_attestation = matches!( attestation_insertion_result, ParticipantInsertion::NewlyInsertedParticipant ); let attestation_storage_must_be_paid_by_caller = is_new_attestation || caller_is_not_participant; if attestation_storage_must_be_paid_by_caller { // `saturating_sub`: if a re-submission shrinks the entry, charge nothing // rather than underflow. Intentional asymmetry: we do not refund freed bytes // either — the caller already paid for the larger entry, and we'd rather // accept that asymmetry than open a refund path for payload-shrinking games. let storage_used = env::storage_usage().saturating_sub(initial_storage); let cost = env::storage_byte_cost().saturating_mul(storage_used as u128); let attached = env::attached_deposit(); if attached < cost { return Err(InvalidParameters::InsufficientDeposit { attached: attached.as_yoctonear(), required: cost.as_yoctonear(), } .into()); } // Refund the difference if the proposer attached more than required if let Some(diff) = attached.checked_sub(cost) && diff > NearToken::from_yoctonear(0) { Promise::new(account_id).transfer(diff).detach(); } } Ok(()) } #[handle_result] pub fn get_attestation( &self, tls_public_key: dtos::Ed25519PublicKey, ) -> Result, Error> { Ok(self .tee_state .stored_attestations .get(&tls_public_key) .map(|node_attestation| { node_attestation .verified_attestation .clone() .into_dto_type() })) } /// Propose new parameters for the MPC network: participants, governance /// threshold, and optional per-domain `ReconstructionThreshold` updates /// (empty map keeps the current ones), applied on resharing completion. /// If a threshold number of votes are reached on the exact same proposal, this will transition /// the contract into the Resharing state. /// /// The epoch_id must be equal to 1 plus the current epoch ID (if Running) or prospective epoch /// ID (if Resharing). Otherwise the vote is ignored. This is to prevent late transactions from /// accidentally voting on outdated proposals. /// /// Like the other governance voting methods, this must be called directly from the /// participant's own NEAR account: `assert_caller_is_signer()` requires /// `signer_account_id == predecessor_account_id`, so calls forwarded through another /// contract are rejected. #[handle_result] pub fn vote_new_parameters( &mut self, prospective_epoch_id: EpochId, proposal: dtos::ProposedThresholdParameters, ) -> Result<(), Error> { Self::assert_caller_is_signer(); let proposal: ProposedThresholdParameters = proposal.try_into_contract_type()?; log!( "vote_new_parameters: signer={}, proposal={:?}", env::signer_account_id(), proposal, ); let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); let validation_result = self.tee_state.reverify_and_cleanup_participants( proposal.participants(), tee_upgrade_deadline_duration, ); let proposed_participants = proposal.participants(); match validation_result { TeeValidationResult::Full => { if let Some(new_state) = self .protocol_state .vote_new_parameters(prospective_epoch_id, &proposal)? { self.protocol_state = new_state; } Ok(()) } TeeValidationResult::Partial { participants_with_valid_attestation, } => { let invalid_participants: Vec<_> = proposed_participants .participants() .iter() .filter(|(account_id, _, _)| { !participants_with_valid_attestation .is_participant_given_account_id(account_id) }) .collect(); Err(InvalidParameters::InvalidTeeRemoteAttestation { reason: format!( "The following participants have invalid TEE status: {:?}", invalid_participants ), } .into()) } } } /// Propose adding a new set of domains for the MPC network. /// If a threshold number of votes are reached on the exact same proposal, this will transition /// the contract into the Initializing state to generate keys for the new domains. /// /// The specified list of domains must have increasing and contiguous IDs, and the first ID /// must be the same as the `next_domain_id` returned by state(). #[handle_result] pub fn vote_add_domains(&mut self, domains: Vec) -> Result<(), Error> { Self::assert_caller_is_signer(); log!( "vote_add_domains: signer={}, domains={:?}", env::signer_account_id(), domains, ); if let Some(new_state) = self.protocol_state.vote_add_domains(domains)? { self.protocol_state = new_state; } Ok(()) } /// Registers the set of foreign chains the calling node supports. /// /// Must be called directly from the participant's own NEAR account /// (`voter_or_panic` requires `signer == predecessor`, blocking calls forwarded /// through another contract). Callable by a participant in any active protocol phase /// (Initializing, Running, or Resharing — authenticated against that phase's participant /// set); panics in `NotInitialized` or when the caller is not a participant. Entries for /// accounts that are no longer participants are pruned after resharing by /// [`Self::clean_foreign_chain_data`]. #[handle_result] pub fn register_foreign_chain_support( &mut self, foreign_chain_support: dtos::SupportedForeignChains, ) -> Result<(), Error> { let account_id = self.voter_or_panic(); self.node_foreign_chain_support .foreign_chain_support_by_node .insert(account_id, foreign_chain_support); Ok(()) } /// (Re)registers the foreign chains this node currently covers. #[handle_result] pub fn register_foreign_chains_config( &mut self, foreign_chains_config: dtos::ForeignChainsConfig, ) -> Result<(), Error> { Self::assert_caller_is_signer(); let signer_account_id = env::signer_account_id(); let signer_account_pk = env::signer_account_pk(); let signer_account_ed25519_pk = Ed25519PublicKey::try_from(&signer_account_pk) .unwrap_or_else(|_| env::panic_str("signer account key must be Ed25519")); let node_id = self .tee_state .lookup_node_id_by_signer_pk(&signer_account_ed25519_pk) .map_err(|_| InvalidState::NotParticipant { account_id: signer_account_id.clone(), })?; if node_id.account_id != signer_account_id { return Err(InvalidState::NotParticipant { account_id: signer_account_id, } .into()); } let is_participant = self .protocol_state .is_existing_or_prospective_participant(&node_id.account_id)?; if !is_participant { return Err(InvalidState::NotParticipant { account_id: node_id.account_id.clone(), } .into()); } let tls_key = node_id.tls_public_key.clone(); self.foreign_chains .get_mut() .register(tls_key, foreign_chains_config); self.recompute_available_foreign_chains(); Ok(()) } /// No-op when outside [`ProtocolContractState::Running`] and [`ProtocolContractState::Resharing`]. fn recompute_available_foreign_chains(&mut self) { let Ok(params) = self.protocol_state.threshold_parameters() else { return; }; // TODO(#3556): replace this with a per-scheme // `required_active_signers(protocol, reconstruction_threshold)`. let Some(threshold) = self.protocol_state.domain_registry().ok().and_then(|r| { r.domains() .iter() .filter(|d| d.purpose == DomainPurpose::ForeignTx) .map(|d| d.reconstruction_threshold.inner()) .max() }) else { // No op if contract isn't in Running or Resharing state, or // there is no foreign tx domain registered. // Not panicking is intentional. log!("Skipping available foreign chains recomputation"); return; }; let active_tls_keys: BTreeSet<_> = params .participants() .participants() .iter() .map(|(_, _, info)| info.tls_public_key.clone()) .collect(); self.foreign_chains .get_mut() .update_available_chains_config_cache(&active_tls_keys, threshold); } #[deprecated( note = "https://github.com/near/mpc/issues/3079. Node will be upgraded to use register_foreign_chain_support instead" )] #[handle_result] pub fn register_foreign_chain_config( &mut self, foreign_chain_configuration: dtos::ForeignChainConfiguration, ) -> Result<(), Error> { let foreign_chain_support: dtos::SupportedForeignChains = foreign_chain_configuration .keys() .copied() .collect::>() .into(); self.register_foreign_chain_support(foreign_chain_support) } /// Starts a new attempt to generate a key for the current domain. /// This only succeeds if the signer is the leader (the participant with the lowest ID). #[handle_result] pub fn start_keygen_instance(&mut self, key_event_id: KeyEventId) -> Result<(), Error> { log!("start_keygen_instance: signer={}", env::signer_account_id(),); self.assert_caller_is_attested_participant_and_protocol_active(); self.protocol_state .start_keygen_instance(key_event_id, self.config.key_event_timeout_blocks) } /// Casts a vote for `public_key` for the attempt identified by `key_event_id`. /// /// The effect of this method is either: /// - Returns error (which aborts with no changes), if there is no active key generation /// attempt (including if the attempt timed out), if the signer is not a participant, or if /// the key_event_id corresponds to a different domain, different epoch, or different attempt /// from the current key generation attempt. /// - Returns Ok(()), with one of the following changes: /// - A vote has been collected but we don't have enough votes yet. /// - This vote is for a public key that disagrees from an earlier voted public key, causing /// the attempt to abort; another call to `start` is then necessary. /// - Everyone has now voted for the same public key; the state transitions into generating a /// key for the next domain. /// - Same as the last case, except that all domains have a generated key now, and the state /// transitions into Running with the newly generated keys. #[handle_result] pub fn vote_pk( &mut self, key_event_id: KeyEventId, public_key: dtos::PublicKey, ) -> Result<(), Error> { log!( "vote_pk: signer={}, key_event_id={:?}, public_key={:?}", env::signer_account_id(), key_event_id, public_key, ); self.assert_caller_is_attested_participant_and_protocol_active(); let extended_key = public_key .try_into() .map_err(|err: PublicKeyExtendedConversionError| { InvalidParameters::MalformedPayload { reason: err.to_string(), } })?; if let Some(new_state) = self.protocol_state.vote_pk(key_event_id, extended_key)? { self.protocol_state = new_state; } Ok(()) } /// Starts a new attempt to reshare the key for the current domain. /// This only succeeds if the signer is the leader (the participant with the lowest ID). #[handle_result] pub fn start_reshare_instance(&mut self, key_event_id: KeyEventId) -> Result<(), Error> { log!( "start_reshare_instance: signer={}", env::signer_account_id() ); self.assert_caller_is_attested_participant_and_protocol_active(); self.protocol_state .start_reshare_instance(key_event_id, self.config.key_event_timeout_blocks) } /// Casts a vote for the successful resharing of the attempt identified by `key_event_id`. /// /// The effect of this method is either: /// - Returns error (which aborts with no changes), if there is no active key resharing attempt /// (including if the attempt timed out), if the signer is not a participant, or if the /// key_event_id corresponds to a different domain, different epoch, or different attempt /// from the current key resharing attempt. /// - Returns Ok(()), with one of the following changes: /// - A vote has been collected but we don't have enough votes yet. /// - Everyone has now voted; the state transitions into resharing the key for the next /// domain. /// - Same as the last case, except that all domains' keys have been reshared now, and the /// state transitions into Running with the newly reshared keys. #[handle_result] pub fn vote_reshared(&mut self, key_event_id: KeyEventId) -> Result<(), Error> { log!( "vote_reshared: signer={}, resharing_id={:?}", env::signer_account_id(), key_event_id, ); self.assert_caller_is_attested_participant_and_protocol_active(); if let Some(new_state) = self.protocol_state.vote_reshared(key_event_id)? { // Resharing has concluded, transition to running state self.protocol_state = new_state; self.recompute_available_foreign_chains(); // Spawn a promise to clean up votes from non-participants. // Note: MpcContract::vote_update uses filtering to ensure correctness even if this cleanup fails. Promise::new(env::current_account_id()) .function_call( method_names::REMOVE_NON_PARTICIPANT_UPDATE_VOTES.to_string(), vec![], NearToken::from_yoctonear(0), Gas::from_tgas(self.config.remove_non_participant_update_votes_tera_gas), ) .detach(); // Spawn a promise to drop votes cast by non-participants. Promise::new(env::current_account_id()) .function_call( method_names::CLEAN_TEE_STATUS.to_string(), vec![], NearToken::from_yoctonear(0), Gas::from_tgas(self.config.clean_tee_status_tera_gas), ) .detach(); // Spawn a bounded sweep over stored attestations to prune invalid / expired entries. Promise::new(env::current_account_id()) .function_call( method_names::CLEAN_INVALID_ATTESTATIONS.to_string(), serde_json::to_vec(&serde_json::json!({ "max_scan": RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN })) .unwrap(), NearToken::from_yoctonear(0), Gas::from_tgas(self.config.clean_invalid_attestations_tera_gas), ) .detach(); // Spawn a promise to clean up orphaned node migrations for non-participants Promise::new(env::current_account_id()) .function_call( method_names::CLEANUP_ORPHANED_NODE_MIGRATIONS.to_string(), vec![], NearToken::from_yoctonear(0), Gas::from_tgas(self.config.cleanup_orphaned_node_migrations_tera_gas), ) .detach(); // Spawn a promise to clean up foreign chain data for non-participants Promise::new(env::current_account_id()) .function_call( method_names::CLEAN_FOREIGN_CHAIN_DATA.to_string(), vec![], NearToken::from_yoctonear(0), Gas::from_tgas(self.config.clean_foreign_chain_data_tera_gas), ) .detach(); // Spawn a promise to drop verifier-change votes cast by non-participants Promise::new(env::current_account_id()) .function_call( method_names::REMOVE_NON_PARTICIPANT_TEE_VERIFIER_VOTES.to_string(), vec![], NearToken::from_yoctonear(0), Gas::from_tgas( self.config .remove_non_participant_tee_verifier_votes_tera_gas, ), ) .detach(); } Ok(()) } /// Casts a vote to cancel the current key resharing. If a threshold number of unique /// votes are collected to cancel the resharing, the contract state will revert back to the /// previous running state. /// /// - This method is idempotent, meaning a single account can not make more than one vote. /// - Only nodes from the previous running state are allowed to vote. /// /// Return value: /// - [Ok] if the vote was successfully collected. /// - [Err] if: /// - The signer is not a participant in the previous running state. /// - The contract is not in a resharing state. #[handle_result] pub fn vote_cancel_resharing(&mut self) -> Result<(), Error> { Self::assert_caller_is_signer(); log!("vote_cancel_resharing: signer={}", env::signer_account_id()); if let Some(new_state) = self.protocol_state.vote_cancel_resharing()? { self.protocol_state = new_state; } Ok(()) } /// Casts a vote to cancel key generation. Any keys that have already been generated /// are kept and we transition into Running state; remaining domains are permanently deleted. /// Deleted domain IDs cannot be reused again in future calls to vote_add_domains. /// /// A next_domain_id that matches that in the state's domains struct must be passed in. This is /// to prevent stale requests from accidentally cancelling a future key generation state. #[handle_result] pub fn vote_cancel_keygen(&mut self, next_domain_id: u64) -> Result<(), Error> { Self::assert_caller_is_signer(); log!("vote_cancel_keygen: signer={}", env::signer_account_id()); if let Some(new_state) = self.protocol_state.vote_cancel_keygen(next_domain_id)? { self.protocol_state = new_state; } Ok(()) } /// Casts a vote to abort the current key event instance. If succesful, the contract aborts the /// instance and a new instance with the next attempt_id can be started. #[handle_result] pub fn vote_abort_key_event_instance(&mut self, key_event_id: KeyEventId) -> Result<(), Error> { log!( "vote_abort_key_event_instance: signer={}", env::signer_account_id() ); self.assert_caller_is_attested_participant_and_protocol_active(); self.protocol_state .vote_abort_key_event_instance(key_event_id) } /// Propose update to either code or config, but not both of them at the same time. #[payable] #[handle_result] pub fn propose_update( &mut self, #[serializer(borsh)] args: ProposeUpdateArgs, ) -> Result { // Only voters can propose updates: let proposer = self.voter_or_panic(); let update: Update = args.try_into()?; let attached = env::attached_deposit(); let required = ProposedUpdates::required_deposit(&update); if attached < required { return Err(InvalidParameters::InsufficientDeposit { attached: attached.as_yoctonear(), required: required.as_yoctonear(), } .into()); } let id = self.proposed_updates.propose(update); log!( "propose_update: signer={}, id={:?}", env::signer_account_id(), id, ); // Refund the difference if the proposer attached more than required. if let Some(diff) = attached.checked_sub(required) && diff > NearToken::from_yoctonear(0) { Promise::new(proposer).transfer(diff).detach(); } Ok(id) } /// Vote for a proposed update given the [`UpdateId`] of the update. /// /// Returns `Ok(true)` if the amount of voters surpassed the threshold and the update was /// executed. Returns `Ok(false)` if the amount of voters did not surpass the threshold. /// Returns [`Error`] if the update was not found or if the voter is not a participant /// in the protocol. #[handle_result] pub fn vote_update(&mut self, id: UpdateId) -> Result { log!( "vote_update: signer={}, id={:?}", env::signer_account_id(), id, ); let ProtocolContractState::Running(running_state) = &self.protocol_state else { env::panic_str("protocol must be in running state"); }; let threshold = self.threshold()?; let voter = self.voter_or_panic(); if self.proposed_updates.vote(&id, voter).is_none() { return Err(InvalidParameters::UpdateNotFound.into()); } // Filter votes to only count current participants voting for this specific update. // This ensures correctness even if the cleanup promise in MpcContract::vote_reshared() fails. let valid_votes_count = running_state .parameters .participants() .participants() .iter() .filter(|(account_id, _, _)| { self.proposed_updates .vote_by_participant .get(account_id) .is_some_and(|voted_id| *voted_id == id) }) .count(); // Not enough votes from current participants, wait for more. if (valid_votes_count as u64) < threshold.value() { return Ok(false); } let update_gas_deposit = Gas::from_tgas(self.config.contract_upgrade_deposit_tera_gas); let Some(_promise) = self.proposed_updates.do_update(&id, update_gas_deposit) else { return Err(InvalidParameters::UpdateNotFound.into()); }; Ok(true) } /// returns all proposed updates pub fn proposed_updates(&self) -> dtos::ProposedUpdates { self.proposed_updates.into_dto_type() } /// Removes an update vote by the caller /// panics if the contract is not in a running state or if the caller is not a participant pub fn remove_update_vote(&mut self) { log!("remove_update_vote: signer={}", env::signer_account_id(),); let ProtocolContractState::Running(_running_state) = &self.protocol_state else { env::panic_str("protocol must be in running state"); }; let voter = self.voter_or_panic(); self.proposed_updates.remove_vote(&voter); } #[handle_result] pub fn vote_code_hash(&mut self, code_hash: NodeImageHash) -> Result<(), Error> { log!( "vote_code_hash: signer={}, code_hash={:?}", env::signer_account_id(), code_hash, ); self.voter_or_panic(); let threshold_parameters = self.protocol_state.threshold_parameters_or_panic(); let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; let votes = self.tee_state.vote(code_hash, &participant); let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); // If the vote threshold is met and the new Docker hash is allowed by the TEE's RTMR3, // update the state if votes >= self.threshold()?.value() { self.tee_state .whitelist_tee_proposal(code_hash, tee_upgrade_deadline_duration); } Ok(()) } /// Vote to add a new launcher image hash to the allowed set. Requires threshold votes. /// When the threshold is reached, compose hashes are automatically derived for all /// currently allowed MPC image hashes. #[handle_result] pub fn vote_add_launcher_hash( &mut self, launcher_hash: LauncherImageHash, ) -> Result<(), Error> { log!( "vote_add_launcher_hash: signer={}, launcher_hash={:?}", env::signer_account_id(), launcher_hash, ); self.voter_or_panic(); let threshold_parameters = self.protocol_state.threshold_parameters_or_panic(); let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; let action = LauncherVoteAction::Add(launcher_hash); let votes = self.tee_state.vote_launcher(action, &participant); let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); if votes >= self.threshold()?.value() { let added = self .tee_state .add_launcher_image(launcher_hash, tee_upgrade_deadline_duration); log!("launcher hash add result: {}", added); } Ok(()) } /// Vote to remove a launcher image hash from the allowed set. Requires ALL participants /// to vote for removal, since this invalidates attestations of nodes running that launcher. #[handle_result] pub fn vote_remove_launcher_hash( &mut self, launcher_hash: LauncherImageHash, ) -> Result<(), Error> { log!( "vote_remove_launcher_hash: signer={}, launcher_hash={:?}", env::signer_account_id(), launcher_hash, ); self.voter_or_panic(); let threshold_parameters = self.protocol_state.threshold_parameters_or_panic(); let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; let action = LauncherVoteAction::Remove(launcher_hash); let votes = self.tee_state.vote_launcher(action, &participant); // Removal requires ALL participants to vote let total_participants = threshold_parameters.participants().len() as u64; if votes >= total_participants { let removed = self.tee_state.remove_launcher_image(&launcher_hash); log!("launcher hash remove result: {}", removed); } Ok(()) } /// Vote to add a new OS measurement set to the allowed list. Requires threshold votes. #[handle_result] pub fn vote_add_os_measurement( &mut self, measurement: ContractExpectedMeasurements, ) -> Result<(), Error> { log!( "vote_add_os_measurement: signer={}, measurement={:?}", env::signer_account_id(), measurement, ); self.voter_or_panic(); let threshold_parameters = self.protocol_state.threshold_parameters_or_panic(); let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; let action = MeasurementVoteAction::Add(measurement.clone()); let votes = self.tee_state.vote_measurement(action, &participant); if votes >= self.threshold()?.value() { let added = self.tee_state.add_measurement(measurement); log!("OS measurement add result: {}", added); } Ok(()) } /// Vote to remove an OS measurement set from the allowed list. Requires ALL participants /// to vote for removal. #[handle_result] pub fn vote_remove_os_measurement( &mut self, measurement: ContractExpectedMeasurements, ) -> Result<(), Error> { log!( "vote_remove_os_measurement: signer={}, measurement={:?}", env::signer_account_id(), measurement, ); self.voter_or_panic(); let threshold_parameters = self.protocol_state.threshold_parameters_or_panic(); let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; let action = MeasurementVoteAction::Remove(measurement.clone()); let votes = self.tee_state.vote_measurement(action, &participant); // Removal requires ALL participants to vote let total_participants = threshold_parameters.participants().len() as u64; if votes >= total_participants { let removed = self.tee_state.remove_measurement(&measurement); log!("OS measurement remove result: {}", removed); } Ok(()) } /// Returns the current OS measurement votes, showing each participant's vote. pub fn os_measurement_votes(&self) -> MeasurementVotes { log!("os_measurement_votes"); self.tee_state.measurement_votes.clone() } /// Returns all currently allowed OS measurements. pub fn allowed_os_measurements(&self) -> Vec { log!("allowed_os_measurements"); self.tee_state.get_allowed_measurements() } /// Vote on per-chain RPC provider whitelist state. The input is keyed by /// `ForeignChain`; each `ChainEntry` value carries the proposed full provider list /// and the RPC response quorum for that chain. The chain's stored state is replaced /// once the protocol's signing threshold of participants has voted the same /// `(providers, quorum)` pair. `NonEmptyBTreeMap` enforces a non-empty batch and /// at-most-one entry per chain at borsh-deserialize time. #[handle_result] pub fn vote_update_foreign_chain_providers( &mut self, #[serializer(borsh)] votes: near_mpc_bounded_collections::NonEmptyBTreeMap< dtos::ForeignChain, dtos::ChainEntry, >, ) -> Result, Error> { let batch_hash = env::sha256_array( borsh::to_vec(&votes).expect("borsh serialization of votes batch must succeed"), ); log!( "vote_update_foreign_chain_providers: signer={}, n_votes={}, batch_hash={}", env::signer_account_id(), votes.len(), hex::encode(batch_hash), ); self.voter_or_panic(); let threshold_parameters = self .protocol_state .threshold_parameters() .expect("voter_or_panic() above already errors on NotInitialized"); let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; let applied = self.foreign_chains.get_mut().rpc_whitelist.vote( participant, votes, threshold_parameters, )?; log!( "vote_update_foreign_chain_providers: applied chains={:?}", applied, ); if !applied.is_empty() { self.recompute_available_foreign_chains(); } Ok(applied) } /// Vote for a candidate account to become the trusted verifier contract /// account, committing to the code hash the voter audited. When the proposal /// crosses the signing threshold, the trusted verifier account is updated /// and all pending verifier-change votes are cleared. #[handle_result] pub fn vote_tee_verifier_change( &mut self, candidate_account_id: AccountId, expected_code_hash: TeeVerifierCodeHash, ) -> Result<(), Error> { log!( "vote_tee_verifier_change: signer={}, candidate={}, expected_code_hash={}", env::signer_account_id(), candidate_account_id, expected_code_hash, ); self.voter_or_panic(); // Voting in the already-current verifier is a no-op if self.tee_verifier_account_id.as_ref() == Some(&candidate_account_id) { return Ok(()); } let threshold_parameters = self.protocol_state.threshold_parameters_or_panic(); let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; let proposal = VerifierChangeProposal { candidate_account_id, expected_code_hash, }; if let Some(new_verifier) = self.tee_verifier_votes .vote(proposal, participant, threshold_parameters)? { log!("vote_tee_verifier_change: new verifier = {}", new_verifier); self.tee_verifier_account_id = Some(new_verifier); } Ok(()) } /// Withdraw the caller's current vote on any pending verifier-change /// proposal. No-op if the caller has not voted. #[handle_result] pub fn withdraw_tee_verifier_vote(&mut self) -> Result<(), Error> { log!( "withdraw_tee_verifier_vote: signer={}", env::signer_account_id(), ); self.voter_or_panic(); let threshold_parameters = self.protocol_state.threshold_parameters_or_panic(); let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; self.tee_verifier_votes.withdraw(&participant); Ok(()) } /// On-chain RPC provider whitelist keyed by `ForeignChain`. Nodes read this at /// startup to validate their local `foreign_chains.yaml`. Borsh-encoded result. #[result_serializer(borsh)] pub fn allowed_foreign_chain_providers( &self, ) -> std::collections::BTreeMap { log!("allowed_foreign_chain_providers"); self.foreign_chains.get().rpc_whitelist.entries.snapshot() } /// Returns all accounts that have TEE attestations stored in the contract. /// Note: This includes both current protocol participants and accounts that may have /// submitted TEE information but are not currently part of the active participant set. pub fn get_tee_accounts(&self) -> Vec { log!("get_tee_accounts"); self.tee_state.get_tee_accounts() } /// Verifies if all current participants have an accepted TEE state. /// Automatically enters a resharing, in case one or more participants do not have an accepted /// TEE state. /// Returns `false` and stops the contract from accepting new signature requests or responses, /// in case less than `threshold` participants run in an accepted TEE State. #[handle_result] pub fn verify_tee(&mut self) -> Result { log!("verify_tee: signer={}", env::signer_account_id()); // Caller must be a participant (node or operator). self.voter_or_panic(); let ProtocolContractState::Running(running_state) = &mut self.protocol_state else { return Err(InvalidState::ProtocolStateNotRunning.into()); }; let current_params = running_state.parameters.clone(); let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); match self.tee_state.reverify_and_cleanup_participants( current_params.participants(), tee_upgrade_deadline_duration, ) { TeeValidationResult::Full => { self.accept_requests = true; log!("All participants have an accepted Tee status"); Ok(true) } TeeValidationResult::Partial { participants_with_valid_attestation, } => { let remaining = participants_with_valid_attestation.len(); // Defense in depth: the surviving participant set must keep the full // threshold relation intact — the GovernanceThreshold must still sit // within its bounds for the smaller set (in particular it must not // exceed the remaining participant count or the upper cap) and must // remain at least every domain's ReconstructionThreshold (the kickout // keeps the existing per-domain thresholds). Otherwise we refuse and // wait for manual intervention. let max_reconstruction_threshold = max_reconstruction_threshold(running_state.domains.domains()); if let Err(err) = ThresholdParameters::validate_governance_against_reconstruction( u64::try_from(remaining).expect("participant count fits in u64"), current_params.threshold(), max_reconstruction_threshold, ) { log!( "Kicking out participants with an invalid TEE status would break the threshold relation ({:?}); {} participants remain with a valid TEE status. This requires manual intervention. We will not accept new signature requests as a safety precaution.", err, remaining, ); self.accept_requests = false; return Ok(false); } // here, we set it to true, because at this point, we have at least `threshold` // number of participants with an accepted Tee status. self.accept_requests = true; // do we want to adjust the threshold? //let n_participants_new = new_participants.len(); //let new_threshold = (3 * n_participants_new + 4) / 5; // minimum 60% //let new_threshold = new_threshold.max(2); // but also minimum 2 let new_threshold = usize::try_from(current_params.threshold().value()) .expect("threshold value fits in usize"); let threshold_parameters = ThresholdParameters::new( participants_with_valid_attestation, Threshold::new(new_threshold as u64), ) .expect("Require valid threshold parameters"); // this should never happen. current_params.validate_incoming_proposal(&threshold_parameters)?; // This resharing only changes the participant set, so the // per-domain reconstruction-threshold updates map 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); } Ok(true) } } } /// Cleans update votes from non-participants after resharing. /// Can only be called by participants or by the contract itself. #[handle_result] pub fn remove_non_participant_update_votes(&mut self) -> Result<(), Error> { log!( "remove_non_participant_update_votes: signer={}", env::signer_account_id() ); let participants = match &self.protocol_state { ProtocolContractState::Running(state) => state.parameters.participants(), _ => { return Err(InvalidState::ProtocolStateNotRunning.into()); } }; // Authorize the caller: allow self-calls (the cleanup promise spawned after a // successful resharing, where the predecessor is the contract account) and // direct calls from a current participant. Reject everyone else so that // non-participants cannot drive this cleanup. let caller = env::predecessor_account_id(); let is_self_call = caller == env::current_account_id(); if !is_self_call && !participants.is_participant_given_account_id(&caller) { return Err(InvalidState::NotParticipant { account_id: caller }.into()); } self.proposed_updates .remove_non_participant_votes(participants); Ok(()) } /// Private endpoint to drop votes cast by non-participants after resharing. /// Attestation cleanup is handled separately by [`MpcContract::clean_invalid_attestations`]. #[private] #[handle_result] pub fn clean_tee_status(&mut self) -> Result<(), Error> { log!("clean_tee_status: signer={}", env::signer_account_id()); let participants = match &self.protocol_state { ProtocolContractState::Running(state) => state.parameters.participants(), _ => { return Err(InvalidState::ProtocolStateNotRunning.into()); } }; self.tee_state.clean_non_participant_votes(participants); Ok(()) } /// Prunes up to `max_scan` stored attestations that fail re-verification (expired or /// referencing stale whitelists). Returns the number of entries removed. Callable by /// anyone while the protocol is in `Running`. #[handle_result] pub fn clean_invalid_attestations(&mut self, max_scan: u32) -> Result { log!( "clean_invalid_attestations: signer={}, max_scan={}", env::signer_account_id(), max_scan ); // Running-only: keygen / resharing may reference attestations that have not yet // been activated, so cleanup is off-limits during those phases. if !matches!(self.protocol_state, ProtocolContractState::Running(_)) { return Err(InvalidState::ProtocolStateNotRunning.into()); } let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); Ok(self .tee_state .clean_invalid_attestations(tee_upgrade_deadline_duration, max_scan as usize)) } /// Private endpoint to clean up foreign chain policy votes and node configurations /// for non-participants after resharing. #[private] #[handle_result] pub fn clean_foreign_chain_data(&mut self) -> Result<(), Error> { log!( "clean_foreign_chain_data: signer={}", env::signer_account_id() ); let participants = match &self.protocol_state { ProtocolContractState::Running(state) => state.parameters.participants(), _ => { return Err(InvalidState::ProtocolStateNotRunning.into()); } }; let participant_accounts: std::collections::HashSet = participants .participants() .iter() .map(|(account_id, _, _)| account_id.clone()) .collect(); let active_tls_keys: std::collections::BTreeSet = participants .participants() .iter() .map(|(_, _, info)| info.tls_public_key.clone()) .collect(); let non_participant_configs: Vec = self .node_foreign_chain_support .foreign_chain_support_by_node .keys() .filter(|account| !participant_accounts.contains(*account)) .cloned() .collect(); for account in &non_participant_configs { self.node_foreign_chain_support .foreign_chain_support_by_node .remove(account); } self.foreign_chains .get_mut() .remove_stale_configs(&active_tls_keys); self.foreign_chains .get_mut() .rpc_whitelist .votes .retain(participants); Ok(()) } /// Private endpoint to drop verifier-change votes cast by non-participants /// after resharing. #[private] #[handle_result] pub fn remove_non_participant_tee_verifier_votes(&mut self) -> Result<(), Error> { log!( "remove_non_participant_tee_verifier_votes: signer={}", env::signer_account_id() ); let participants = match &self.protocol_state { ProtocolContractState::Running(state) => state.parameters.participants(), _ => { return Err(InvalidState::ProtocolStateNotRunning.into()); } }; self.tee_verifier_votes.retain(participants); Ok(()) } } // Contract developer helper API #[near] impl MpcContract { #[handle_result] #[init] pub fn init( parameters: dtos::ThresholdParameters, init_config: Option, ) -> Result { let parameters: ThresholdParameters = parameters.try_into_contract_type()?; // Log participant count and hash - full parameters exceed NEAR's 16KB log limit at ~100 participants let params_hash = env::sha256_array(borsh::to_vec(¶meters).unwrap()); log!( "init: signer={}, num_participants={}, parameters_hash={:?}, init_config={:?}", env::signer_account_id(), parameters.participants().len(), params_hash, init_config, ); parameters.validate()?; // TODO(#1087): Every participant must have a valid attestation, otherwise we risk // participants being immediately kicked out once contract transitions into running. let initial_participants = parameters.participants(); let tee_state = TeeState::with_mocked_participant_attestations(initial_participants); Ok(Self { protocol_state: ProtocolContractState::Running(RunningContractState::new( DomainRegistry::default(), Keyset::new(EpochId::new(0), Vec::new()), parameters, AddDomainsVotes::default(), )), pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV4), pending_ckd_requests: LookupMap::new(StorageKey::PendingCKDRequestsV3), pending_verify_foreign_tx_requests: LookupMap::new( StorageKey::PendingVerifyForeignTxRequestsV2, ), proposed_updates: ProposedUpdates::default(), config: init_config.map(Into::into).unwrap_or_default(), tee_state, accept_requests: true, node_migrations: NodeMigrations::default(), metrics: Default::default(), node_foreign_chain_support: Default::default(), foreign_chains: Lazy::new( StorageKey::ForeignChainMetadata, ForeignChainsMetadata::default(), ), tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), }) } // This function can be used to transfer the MPC network to a new contract. #[private] #[init] #[handle_result] pub fn init_running( domains: Vec, next_domain_id: u64, keyset: Keyset, parameters: dtos::ThresholdParameters, init_config: Option, ) -> Result { let parameters: ThresholdParameters = parameters.try_into_contract_type()?; // Log participant count and hash - full parameters exceed NEAR's 16KB log limit at ~100 participants let params_hash = env::sha256_array(borsh::to_vec(¶meters).unwrap()); log!( "init_running: signer={}, domains={:?}, keyset={:?}, num_participants={}, threshold={}, parameters_hash={:?}, init_config={:?}", env::signer_account_id(), domains, keyset, parameters.participants().len(), parameters.threshold().value(), params_hash, init_config, ); parameters.validate()?; let domains = DomainRegistry::from_raw_validated(domains, next_domain_id)?; let num_participants = parameters.participants().len() as u64; for domain in domains.domains() { crate::primitives::domain::validate_domain_threshold(domain, num_participants)?; } // Keep the GovernanceThreshold at least as large as the largest ReconstructionThreshold. ThresholdParameters::validate_governance_against_reconstruction( num_participants, parameters.threshold(), max_reconstruction_threshold(domains.domains()), )?; // Check that the domains match exactly those in the keyset. let domain_ids_from_domains = domains.domains().iter().map(|d| d.id).collect::>(); let domain_ids_from_keyset = keyset .domains .iter() .map(|k| k.domain_id) .collect::>(); if domain_ids_from_domains != domain_ids_from_keyset { return Err(DomainError::DomainsMismatch.into()); } let initial_participants = parameters.participants(); let tee_state = TeeState::with_mocked_participant_attestations(initial_participants); Ok(MpcContract { config: init_config.map(Into::into).unwrap_or_default(), protocol_state: ProtocolContractState::Running(RunningContractState::new( domains, keyset, parameters, AddDomainsVotes::default(), )), pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV4), pending_ckd_requests: LookupMap::new(StorageKey::PendingCKDRequestsV3), pending_verify_foreign_tx_requests: LookupMap::new( StorageKey::PendingVerifyForeignTxRequestsV2, ), proposed_updates: Default::default(), tee_state, accept_requests: true, node_migrations: NodeMigrations::default(), metrics: Default::default(), node_foreign_chain_support: Default::default(), foreign_chains: Lazy::new( StorageKey::ForeignChainMetadata, ForeignChainsMetadata::default(), ), tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), }) } /// This will be called internally by the contract to migrate the state when a new contract /// is deployed. This function should be changed every time state is changed to do the proper /// migrate flow. /// /// If nothing is changed, then this function will just return the current state. If it fails /// to read the state, then it will return an error. #[private] #[init(ignore_state)] #[handle_result] pub fn migrate() -> Result { log!("migrating contract"); match try_state_read::() { Ok(Some(state)) => return Ok(state.into()), Ok(None) => return Err(InvalidState::ContractStateIsMissing.into()), Err(err) => { log!("failed to deserialize state into 3.12.0 state: {:?}", err); } }; match try_state_read::() { Ok(Some(state)) => Ok(state), Ok(None) => Err(InvalidState::ContractStateIsMissing.into()), Err(err) => env::panic_str(&format!("could not deserialize contract state: {err}")), } } pub fn state(&self) -> near_mpc_contract_interface::types::ProtocolContractState { (&self.protocol_state).into_dto_type() } pub fn metrics(&self) -> near_mpc_contract_interface::types::Metrics { self.metrics.clone() } /// 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. pub fn allowed_docker_image_hashes(&self) -> Vec { let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); let mut hashes: Vec = self .tee_state .get_allowed_mpc_docker_images(tee_upgrade_deadline_duration) .into_iter() .map(|allowed_image_hash| allowed_image_hash.image_hash) .collect(); hashes.reverse(); hashes } pub fn allowed_launcher_compose_hashes(&self) -> Vec { self.tee_state.get_allowed_launcher_compose_hashes() } pub fn allowed_launcher_image_hashes(&self) -> Vec { self.tee_state.get_allowed_launcher_hashes() } /// Returns the current launcher hash votes, showing each participant's vote. pub fn launcher_hash_votes(&self) -> LauncherHashVotes { self.tee_state.launcher_votes.clone() } /// Returns the current code hash votes, showing each participant's vote. pub fn code_hash_votes(&self) -> CodeHashesVotes { self.tee_state.votes.clone() } /// Returns the pending TEE verifier-change votes, keyed by proposal. pub fn tee_verifier_votes( &self, ) -> BTreeMap> { self.tee_verifier_votes.pending() } /// Presence check for a pending signature request, exposed as a view call. /// /// **The returned `YieldIndex` is an arbitrary representative, not "the" yield /// for this request.** Since the duplicate-request fan-out feature (PR #3187), /// a single request key can have N queued yields; this method returns the head of the /// queue. Callers that need to act on the full set are wrong to use this. The /// only correct interpretation is presence: `Some(_)` vs `None`. /// /// The `Option` shape is retained for JSON wire compatibility with /// out-of-tree consumers; the in-tree caller (`tx_sender::observe_tx_result`) /// only matches on presence. Prefer a `bool`-shaped accessor if one is added. pub fn get_pending_request(&self, request: &SignatureRequest) -> Option { self.pending_signature_requests .get(request) .and_then(|q| q.first().cloned()) } /// Presence check for a pending CKD request, exposed as a view call. /// /// See [`Self::get_pending_request`] for the contract: the returned `YieldIndex` /// is an arbitrary representative of a fan-out queue, not "the" yield. Only the /// `Some`/`None` distinction is meaningful. pub fn get_pending_ckd_request(&self, request: &CKDRequest) -> Option { self.pending_ckd_requests .get(request) .and_then(|q| q.first().cloned()) } /// Presence check for a pending foreign-tx verification request, exposed as a /// view call. /// /// See [`Self::get_pending_request`] for the contract: the returned `YieldIndex` /// is an arbitrary representative of a fan-out queue, not "the" yield. Only the /// `Some`/`None` distinction is meaningful. pub fn get_pending_verify_foreign_tx_request( &self, request: &VerifyForeignTransactionRequest, ) -> Option { self.pending_verify_foreign_tx_requests .get(request) .and_then(|q| q.first().cloned()) } pub fn config(&self) -> dtos::Config { dtos::Config::from(&self.config) } pub fn get_supported_foreign_chains(&self) -> dtos::SupportedForeignChains { let active_participant_account_ids = self .protocol_state .active_participants() .participants() .iter() .map(|(account_id, _, _)| account_id.clone()) .collect::>(); let mut foreign_chain_to_node_mapping: BTreeMap< &dtos::ForeignChain, BTreeSet, > = BTreeMap::new(); for (account_id, chains) in self .node_foreign_chain_support .foreign_chain_support_by_node .iter() { for chain in chains.iter() { foreign_chain_to_node_mapping .entry(chain) .or_default() .insert(account_id.clone()); } } foreign_chain_to_node_mapping .into_iter() .filter_map(|(foreign_chain, nodes_supporting_chain)| { let all_active_nodes_supports_chain = nodes_supporting_chain.is_superset(&active_participant_account_ids); if all_active_nodes_supports_chain { Some(foreign_chain) } else { None } }) .cloned() .collect::>() .into() } pub fn get_foreign_chain_support_by_node(&self) -> dtos::ForeignChainSupportByNode { self.node_foreign_chain_support.to_dto() } /// The **available** foreign chains: whitelisted chains that are supported /// by at least the signing threshold of active participants. pub fn get_available_foreign_chains(&self) -> dtos::AvailableForeignChains { self.foreign_chains.get().available_foreign_chains.clone() } /// Per-participant view of which foreign chains each node currently covers. Feeds the /// available-set computation ([`Self::get_available_foreign_chains`]) and coverage alerting. pub fn get_foreign_chains_configs(&self) -> dtos::ForeignChainsConfigs { self.foreign_chains.get().snapshot_by_node() } // contract version pub fn version() -> String { env!("CARGO_PKG_VERSION").to_string() } /// Yield-resume callback for a single queued `sign` request. /// /// On success, returns the signature to the original caller. On timeout, pops this /// yield's slot (the head of the FIFO fan-out queue) from the pending-request map /// and fires `fail_on_timeout` to fail the original transaction. Sibling yields /// queued under the same request key remain pending and are cleaned up by their /// own timeouts (or drained together by a subsequent `respond`). #[private] pub fn return_signature_and_clean_state_on_success( &mut self, request: SignatureRequest, #[callback_result] signature: Result, ) -> PromiseOrValue { match signature { Ok(signature) => PromiseOrValue::Value(signature), Err(_) => { pending_requests::pop_oldest_pending_yield( &mut self.pending_signature_requests, &request, ); let fail_on_timeout_gas = Gas::from_tgas(self.config.fail_on_timeout_tera_gas); let promise = Promise::new(env::current_account_id()).function_call( method_names::FAIL_ON_TIMEOUT.to_string(), vec![], NearToken::from_near(0), fail_on_timeout_gas, ); near_sdk::PromiseOrValue::Promise(promise.as_return()) } } } /// Yield-resume callback for a single queued CKD request. /// /// On success, returns the confidential key to the original caller. On timeout, /// pops this yield's slot (the head of the FIFO fan-out queue) from the /// pending-request map and fires `fail_on_timeout` to fail the original /// transaction. Sibling yields queued under the same request key remain pending /// and are cleaned up by their own timeouts (or drained together by a subsequent /// `respond_ckd`). #[private] pub fn return_ck_and_clean_state_on_success( &mut self, request: CKDRequest, #[callback_result] ck: Result, ) -> PromiseOrValue { match ck { Ok(ck) => PromiseOrValue::Value(ck), Err(_) => { pending_requests::pop_oldest_pending_yield( &mut self.pending_ckd_requests, &request, ); let fail_on_timeout_gas = Gas::from_tgas(self.config.fail_on_timeout_tera_gas); let promise = Promise::new(env::current_account_id()).function_call( method_names::FAIL_ON_TIMEOUT.to_string(), vec![], NearToken::from_near(0), fail_on_timeout_gas, ); near_sdk::PromiseOrValue::Promise(promise.as_return()) } } } /// Yield-resume callback for a single queued foreign-tx verification request. /// /// On success, returns the verification response to the original caller. On /// timeout, pops this yield's slot (the head of the FIFO fan-out queue) from the /// pending-request map and fires `fail_on_timeout` to fail the original /// transaction. Sibling yields queued under the same request key remain pending /// and are cleaned up by their own timeouts (or drained together by a subsequent /// `respond_verify_foreign_tx`). #[private] pub fn return_verify_foreign_tx_and_clean_state_on_success( &mut self, request: VerifyForeignTransactionRequest, #[callback_result] response: Result, ) -> PromiseOrValue { match response { Ok(response) => PromiseOrValue::Value(response), Err(_) => { pending_requests::pop_oldest_pending_yield( &mut self.pending_verify_foreign_tx_requests, &request, ); let fail_on_timeout_gas = Gas::from_tgas(self.config.fail_on_timeout_tera_gas); let promise = Promise::new(env::current_account_id()).function_call( method_names::FAIL_ON_TIMEOUT.to_string(), vec![], NearToken::from_near(0), fail_on_timeout_gas, ); near_sdk::PromiseOrValue::Promise(promise.as_return()) } } } #[private] pub fn fail_on_timeout() { // To stay consistent with the old version of the timeout error env::panic_str(&RequestError::Timeout.to_string()); } #[private] pub fn update_config(&mut self, config: dtos::Config) { self.config = config.into(); } /// Get our own account id as a voter. Returns an error if we are not a participant. fn voter_account(&self) -> Result { if !Self::caller_is_signer() { return Err(InvalidParameters::CallerNotSigner.into()); } let voter = env::signer_account_id(); self.protocol_state.authenticate_update_vote()?; Ok(voter) } /// Returns true if the caller is the signer account. fn caller_is_signer() -> bool { let signer = env::signer_account_id(); let predecessor = env::predecessor_account_id(); signer == predecessor } /// Get our own account id as a voter. If we are not a participant, panic. /// also ensures that the caller is the signer account. fn voter_or_panic(&self) -> AccountId { Self::assert_caller_is_signer(); match self.voter_account() { Ok(voter) => voter, Err(err) => env::panic_str(&format!("not a voter, {:?}", err)), } } /// Ensures that the caller is an attested participant /// in the currently active protocol phase. /// /// Active phases: /// - `Initializing` → uses proposed participants from generating_key /// - `Running` → uses current active participants /// - `Resharing` → uses new participants from resharing proposal /// /// Panics if: /// - The protocol is not active (e.g., NotInitialized) /// - The caller is not attested or not in the relevant participants set /// - The caller is not the signer account fn assert_caller_is_attested_participant_and_protocol_active(&self) { let participants = self.protocol_state.active_participants(); Self::assert_caller_is_signer(); let attestation_check = self .tee_state .is_caller_an_attested_participant(participants); assert_matches::assert_matches!( attestation_check, Ok(()), "Caller must be an attested participant" ); } /// Ensures the current call originates from the signer account itself. /// Panics if `signer_account_id` and `predecessor_account_id` differ. /// /// This enforces the network-wide policy that **all governance methods must be called /// directly from the participant's own NEAR account**, never forwarded through another /// contract such as a multisig. /// /// This check reaches every signer-authenticated mutating method through one of three /// paths (the list below is illustrative, not exhaustive): /// - Called directly: `vote_new_parameters`, `vote_add_domains`, `vote_cancel_resharing`, /// `vote_cancel_keygen`, `register_foreign_chain_support`, `submit_participant_info`, /// and the node-migration methods. /// - Via [`Self::voter_or_panic`]: `propose_update`, `vote_update`, `remove_update_vote`, /// `vote_code_hash`, the launcher/OS-measurement votes, /// `vote_update_foreign_chain_providers`, and `verify_tee`. /// - Via [`Self::assert_caller_is_attested_participant_and_protocol_active`]: the key-event /// votes `vote_pk`, `vote_reshared`, `vote_abort_key_event_instance`, and the leader-only /// `start_keygen_instance` / `start_reshare_instance`, plus the `respond*` callbacks. fn assert_caller_is_signer() -> AccountId { let signer_id = env::signer_account_id(); let predecessor_id = env::predecessor_account_id(); assert_eq!( signer_id, predecessor_id, "Caller must be the signer account (signer: {}, predecessor: {})", signer_id, predecessor_id ); signer_id } } /// Methods for Migration service #[near] impl MpcContract { pub fn migration_info( &self, ) -> BTreeMap< AccountId, ( Option, Option, ), > { log!("migration_info"); self.node_migrations.get_all() } /// Registers or updates the backup service information for the caller account. /// /// The caller (`signer_account_id`) must be an existing or prospective participant. /// Otherwise, the transaction will fail. /// /// # Notes /// - A deposit requirement may be added in the future. #[handle_result] pub fn register_backup_service( &mut self, backup_service_info: dtos::BackupServiceInfo, ) -> Result<(), Error> { let account_id = Self::assert_caller_is_signer(); log!( "register_backup_service: signer={:?}, backup_service_info={:?}", account_id, backup_service_info ); if !self .protocol_state .is_existing_or_prospective_participant(&account_id)? { return Err(errors::InvalidState::NotParticipant { account_id: account_id.clone(), } .into()); } self.node_migrations .set_backup_service_info(account_id, backup_service_info); Ok(()) } /// Sets the destination node for the calling account. /// /// This function can only be called while the protocol is in a `Running` state. /// The signer must be a current participant of the current epoch, otherwise an error is returned. /// On success, the provided `DestinationNodeInfo` is stored in the contract state /// under the signer’s account ID. /// /// # Errors /// - [`InvalidState::ProtocolStateNotRunning`] if the protocol is not in the `Running` state. /// - [`InvalidState::NotParticipant`] if the signer is not a current participant. /// # Note: /// - might require a deposit #[handle_result] pub fn start_node_migration( &mut self, destination_node_info: dtos::DestinationNodeInfo, ) -> Result<(), Error> { // TODO(#1163): require a deposit let account_id = Self::assert_caller_is_signer(); log!( "start_node_migration: signer={:?}, destination_node_info={:?}", account_id, destination_node_info ); let ProtocolContractState::Running(running_state) = &self.protocol_state else { return Err(errors::InvalidState::ProtocolStateNotRunning.into()); }; if !running_state.is_participant_given_account_id(&account_id) { return Err(errors::InvalidState::NotParticipant { account_id: account_id.clone(), } .into()); } self.node_migrations .set_destination_node_info(account_id, destination_node_info); Ok(()) } /// Finalizes a node migration for the calling account. /// /// This method can only be called while the protocol is in a `Running` state /// and by an existing participant. On success, the participant’s information is /// updated to the new destination node. /// /// # Errors /// Returns the following errors: /// - `InvalidState::ProtocolStateNotRunning`: if protocol is not in `Running` state /// - `InvalidState::NotParticipant`: if caller is not a current participant /// - `NodeMigrationError::KeysetMismatch`: if provided keyset does not match the expected keyset /// - `NodeMigrationError::MigrationNotFound`: if no migration record exists for the caller /// - `NodeMigrationError::AccountPublicKeyMismatch`: if caller’s public key does not match the expected destination node /// - `InvalidParameters::InvalidTeeRemoteAttestation`: if destination node’s TEE quote is invalid #[handle_result] pub fn conclude_node_migration(&mut self, keyset: &Keyset) -> Result<(), Error> { let account_id = Self::assert_caller_is_signer(); let signer_pk = env::signer_account_pk(); log!( "conclude_node_migration: signer={:?}, signer_pk={:?} keyset={:?}", account_id, signer_pk, keyset ); let ProtocolContractState::Running(running_state) = &mut self.protocol_state else { return Err(errors::InvalidState::ProtocolStateNotRunning.into()); }; if !running_state.is_participant_given_account_id(&account_id) { return Err(errors::InvalidState::NotParticipant { account_id: account_id.clone(), } .into()); } let expected_keyset = &running_state.keyset; if expected_keyset != keyset { return Err(errors::NodeMigrationError::KeysetMismatch { found: keyset.clone(), expected: expected_keyset.clone(), } .into()); } let Some(expected_destination_node) = self.node_migrations.remove_migration(&account_id) else { return Err(errors::NodeMigrationError::MigrationNotFound.into()); }; let expected_signer_pk = near_sdk::PublicKey::from(expected_destination_node.signer_account_pk.clone()); if expected_signer_pk != signer_pk { return Err(errors::NodeMigrationError::AccountPublicKeyMismatch { found: signer_pk, expected: expected_signer_pk, } .into()); } // ensure that this node has a valid TEE quote. let node_id = NodeId { account_id: account_id.clone(), account_public_key: expected_destination_node.signer_account_pk, tls_public_key: expected_destination_node .destination_node_info .tls_public_key .clone(), }; if !(matches!( self.tee_state.reverify_participants( &node_id, Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds), ), TeeQuoteStatus::Valid )) { return Err(errors::InvalidParameters::InvalidTeeRemoteAttestation { reason: "destination node TEE quote is invalid".into(), } .into()); }; let old_tls_key = running_state .parameters .participants() .info(&account_id) .map(|info| info.tls_public_key.clone()); let contract_participant_info = expected_destination_node .destination_node_info .into_contract_type(); log!( "Moving Account {:?} to {:?}", account_id, contract_participant_info ); running_state .parameters .update_info(account_id, contract_participant_info)?; if let Some(old_key) = old_tls_key { self.foreign_chains .get_mut() .foreign_chains_configs .remove(&old_key); } self.recompute_available_foreign_chains(); Ok(()) } #[private] #[handle_result] pub fn cleanup_orphaned_node_migrations(&mut self) -> Result<(), Error> { log!( "cleanup_orphaned_node_migrations signer={:?}", env::signer_account_id(), ); let backup_services: Vec = self .node_migrations .backup_services_info() .keys() .cloned() .collect(); for account_id in &backup_services { if !self .protocol_state .is_existing_or_prospective_participant(account_id)? { self.node_migrations.remove_account_data(account_id); } } Ok(()) } } fn try_state_read() -> Result, std::io::Error> { env::storage_read(b"STATE") .map(|data| T::try_from_slice(&data)) .transpose() } #[cfg(not(target_arch = "wasm32"))] #[cfg(test)] #[expect(non_snake_case)] mod tests { use std::{ collections::{BTreeMap, HashSet}, panic, str::FromStr, }; use super::*; use crate::errors::{InvalidCandidateSet, InvalidThreshold, NodeMigrationError}; use crate::pending_requests::MAX_PENDING_REQUEST_FAN_OUT; use crate::primitives::participants::{ParticipantId, ParticipantInfo, Participants}; use crate::primitives::test_utils::{ NUM_PROTOCOLS, bogus_ed25519_near_public_key, bogus_ed25519_public_key, gen_account_id, gen_participant, gen_participants, infer_purpose_from_protocol, }; use crate::state::key_event::KeyEvent; use crate::state::key_event::tests::Environment; use crate::state::resharing::ResharingContractState; use crate::state::test_utils::{ gen_initializing_state, gen_resharing_state, gen_running_state, }; use crate::tee::measurements::{ KeyProviderEventDigest, MrtdHash, Rtmr0Hash, Rtmr1Hash, Rtmr2Hash, }; use crate::tee::proposal::{LauncherVoteAction, get_docker_compose_hash}; use crate::tee::tee_state::{NodeAttestation, NodeId}; use assert_matches::assert_matches; use dtos::{Attestation, Ed25519PublicKey, ForeignTxSignPayload, MockAttestation}; use dtos::{Curve, DomainConfig, DomainId, Payload, Protocol, ReconstructionThreshold, Tweak}; use elliptic_curve::Field as _; use elliptic_curve::Group; use k256::{self, Secp256k1, ecdsa::SigningKey, elliptic_curve}; use mpc_attestation::attestation::{ Attestation as MpcAttestation, MockAttestation as MpcMockAttestation, VerifiedAttestation, }; use mpc_primitives::hash::DockerImageHash; use near_mpc_bounded_collections::{NonEmptyBTreeMap, NonEmptyBTreeSet}; use near_mpc_contract_interface::types::BackupServiceInfo; use near_mpc_contract_interface::types::CKDAppPublicKey; use near_mpc_contract_interface::types::DestinationNodeInfo; use near_mpc_contract_interface::types::{ BitcoinExtractedValue, BitcoinExtractor, BitcoinRpcRequest, ExtractedValue, ForeignTxPayloadVersion, ForeignTxSignPayloadV1, }; use near_sdk::{NearToken, VMContext, test_utils::VMContextBuilder, testing_env}; use primitives::key_state::{AttemptId, KeyForDomain}; use rand::SeedableRng; use rand::seq::SliceRandom; use rand::{RngCore, rngs::OsRng}; use rand_core::CryptoRngCore; use rstest::rstest; use sha2::{Digest, Sha256}; use test_utils::attestation::{ VALID_ATTESTATION_TIMESTAMP, image_digest, launcher_image_hash, mock_dto_dstack_attestation, near_account_key, p2p_tls_key, }; use test_utils::contract_types::dummy_config; use threshold_signatures::confidential_key_derivation as ckd; use threshold_signatures::frost_core::Group as _; use threshold_signatures::frost_ed25519::Ed25519Group; use threshold_signatures::frost_secp256k1::Secp256K1Group; pub fn migration_info( contract_state: &MpcContract, account_id: &AccountId, ) -> ( AccountId, Option, Option, ) { contract_state.node_migrations.get_for_account(account_id) } #[derive(Debug)] pub enum SharedSecretKey { Secp256k1(k256::Scalar), #[expect(dead_code)] Ed25519(curve25519_dalek::Scalar), Bls12381(ckd::Scalar), } pub fn derive_secret_key(secret_key: &k256::SecretKey, tweak: &Tweak) -> k256::SecretKey { let tweak = k256::Scalar::from_repr(tweak.as_bytes().into()).unwrap(); k256::SecretKey::new((tweak + secret_key.to_nonzero_scalar().as_ref()).into()) } pub fn new_secp256k1(rng: &mut impl CryptoRngCore) -> (dtos::Secp256k1PublicKey, k256::Scalar) { let scalar = k256::Scalar::random(rng); let public_key_element = Secp256K1Group::generator() * scalar; let pk = dtos::Secp256k1PublicKey::try_from(public_key_element.to_affine()) .expect("non-identity group element is a valid public key"); (pk, scalar) } pub fn new_ed25519( rng: &mut impl CryptoRngCore, ) -> (dtos::Ed25519PublicKey, curve25519_dalek::Scalar) { let scalar = curve25519_dalek::Scalar::random(rng); let public_key_element = Ed25519Group::generator() * scalar; let pk = dtos::Ed25519PublicKey::from(public_key_element.compress()); (pk, scalar) } pub fn new_bls12381g2( rng: &mut impl CryptoRngCore, ) -> (dtos::Bls12381G2PublicKey, ckd::Scalar) { let scalar = ckd::Scalar::random(rng); let public_key_element = ckd::ElementG2::generator() * scalar; let pk = dtos::Bls12381G2PublicKey::from(&public_key_element); (pk, scalar) } pub fn new_ckd_pv_app_pk( rng: &mut impl CryptoRngCore, ) -> (ckd::Scalar, dtos::CKDAppPublicKeyPV) { let scalar = ckd::Scalar::random(rng); let pk2 = ckd::ElementG2::generator() * scalar; let pk1 = ckd::ElementG1::generator() * scalar; let pk2 = dtos::Bls12381G2PublicKey::from(&pk2); let pk1 = dtos::Bls12381G1PublicKey::from(&pk1); (scalar, dtos::CKDAppPublicKeyPV { pk1, pk2 }) } pub fn compute_ckd_pv_response(msk: &ckd::Scalar, request: &CKDRequest) -> CKDResponse { let public_key = ckd::ElementG2::generator() * msk; let public_key = ckd::VerifyingKey::new(public_key); let app_pk = ckd::ElementG1::try_from(request.app_public_key.g1_public_key()).unwrap(); let big_s = ckd::hash_app_id_with_pk(&public_key, request.app_id.as_ref()) * msk; let y = ckd::Scalar::random(OsRng); let big_y = ckd::ElementG1::generator() * y; let big_c = big_s + app_pk * y; CKDResponse { big_y: (&big_y).into(), big_c: (&big_c).into(), } } pub fn make_public_key_for_curve( curve: Curve, rng: &mut impl CryptoRngCore, ) -> (dtos::PublicKey, SharedSecretKey) { match curve { Curve::Secp256k1 => { let (pk, sk) = new_secp256k1(rng); (pk.into(), SharedSecretKey::Secp256k1(sk)) } Curve::Edwards25519 => { let (pk, sk) = new_ed25519(rng); (pk.into(), SharedSecretKey::Ed25519(sk)) } Curve::Bls12381 => { let (pk, sk) = new_bls12381g2(rng); (pk.into(), SharedSecretKey::Bls12381(sk)) } } } fn basic_setup( curve: Curve, rng: &mut impl CryptoRngCore, ) -> (VMContext, MpcContract, SharedSecretKey) { let protocol = match curve { Curve::Secp256k1 => Protocol::CaitSith, Curve::Edwards25519 => Protocol::Frost, Curve::Bls12381 => Protocol::ConfidentialKeyDerivation, }; basic_setup_with_protocol(protocol, infer_purpose_from_protocol(protocol), rng) } fn basic_setup_with_protocol( protocol: Protocol, purpose: DomainPurpose, rng: &mut impl CryptoRngCore, ) -> (VMContext, MpcContract, SharedSecretKey) { let curve = Curve::from(protocol); let contract_account_id = AccountId::from_str("contract_account.near").unwrap(); let context = VMContextBuilder::new() .attached_deposit(NearToken::from_yoctonear(1)) .predecessor_account_id(contract_account_id.clone()) .current_account_id(contract_account_id) .build(); testing_env!(context.clone()); let domain_id = DomainId::default(); // DamgardEtAl requires 2t - 1 <= n; with n=4, the max valid t is 2. let reconstruction_threshold = match protocol { Protocol::DamgardEtAl => ReconstructionThreshold::new(2), _ => ReconstructionThreshold::new(3), }; let domains = vec![DomainConfig { id: domain_id, protocol, reconstruction_threshold, purpose, }]; let epoch_id = EpochId::new(0); let (pk, sk) = make_public_key_for_curve(curve, rng); let key_for_domain = KeyForDomain { domain_id, key: pk.try_into().unwrap(), attempt: AttemptId::new(), }; let keyset = Keyset::new(epoch_id, vec![key_for_domain]); let parameters = ThresholdParameters::new(gen_participants(4), Threshold::new(3)).unwrap(); let contract = MpcContract::init_running(domains, 1, keyset, (¶meters).into_dto_type(), None) .unwrap(); (context, contract, sk) } /// Register the given foreign chains as supported by all active participants. fn register_supported_chains( contract: &mut MpcContract, chains: impl IntoIterator, ) { let foreign_chain_configuration: dtos::ForeignChainConfiguration = chains .into_iter() .map(|foreign_chain| { ( foreign_chain, NonEmptyBTreeSet::new(dtos::RpcProvider { rpc_url: "dummy_url.com".to_string(), }), ) }) .collect::>>() .into(); // pub struct ForeignChainConfiguration(BTreeMap>); let participants: Vec<_> = contract .protocol_state .active_participants() .participants() .iter() .map(|(account_id, _, _)| account_id.clone()) .collect(); for account_id in participants { let _env = Environment::new(None, Some(account_id), None); contract .register_foreign_chain_config(foreign_chain_configuration.clone()) .expect("register should succeed"); } } /// Temporarily sets the testing environment so that calls appear /// to come from an attested MPC node registered in the contract's `tee_state`. /// Returns the `AccountId` of the node used. pub fn with_active_participant_and_attested_context(contract: &MpcContract) -> AccountId { let active_participant_pks: Vec = contract .protocol_state .active_participants() .participants() .iter() .map(|(_, _, participant_info)| participant_info.tls_public_key.clone()) .collect(); let node_id = contract .tee_state .stored_attestations .iter() .find(|(public_key, _)| active_participant_pks.contains(public_key)) .expect("No attested participants in tee_state") .1 .node_id .clone(); // Build a new simulated environment with this node as caller. // Set signer_account_pk to match the mock attestation (account_public_key == tls_public_key). let mut ctx_builder = VMContextBuilder::new(); ctx_builder .signer_account_id(node_id.account_id.clone()) .predecessor_account_id(node_id.account_id.clone()) .signer_account_pk(near_sdk::PublicKey::from( node_id.account_public_key.clone(), )) .attached_deposit(NearToken::from_yoctonear(1)); testing_env!(ctx_builder.build()); node_id.account_id.clone() } /// Builds the valid secp256k1 signature for `payload` under the domain key derived /// from `(predecessor, path)`. Shared between `test_signature_common` and /// `sign__should_queue_duplicate_requests_and_drain_all_on_respond` because both /// need an honestly-produced response to feed into `respond`. fn secp256k1_signature_for_test( secret_key: &k256::Scalar, predecessor: &AccountId, path: &str, payload: &Payload, ) -> dtos::K256Signature { let derivation_path = derive_tweak(predecessor, path); let secret_key_ec: elliptic_curve::SecretKey = elliptic_curve::SecretKey::from_bytes(&secret_key.to_bytes()).unwrap(); let derived_secret_key = derive_secret_key(&secret_key_ec, &derivation_path); let signing_key = SigningKey::from_bytes(&derived_secret_key.to_bytes()).unwrap(); let (signature, recovery_id) = signing_key .sign_prehash_recoverable(payload.as_ecdsa().unwrap()) .unwrap(); dtos::K256Signature::from_ecdsa_recoverable(&signature, recovery_id) } fn test_signature_common(success: bool, legacy_v1_api: bool, protocol: Protocol) { let (context, mut contract, secret_key) = basic_setup_with_protocol(protocol, DomainPurpose::Sign, &mut OsRng); let SharedSecretKey::Secp256k1(secret_key) = secret_key else { unreachable!(); }; let mut payload_hash = [0u8; 32]; OsRng.fill_bytes(&mut payload_hash); let payload = Payload::from_legacy_ecdsa(payload_hash); let key_path = "m/44'\''/60'\''/0'\''/0/0".to_string(); let request: SignRequestArgs = if legacy_v1_api { serde_json::from_value(serde_json::json!({ "payload": payload_hash, "key_version": 0, "path": key_path, })) .unwrap() } else { SignRequestArgs { payload: payload.clone(), path: key_path.clone(), domain_id: DomainId::legacy_ecdsa_id(), } }; let signature_request = SignatureRequest::new( DomainId::default(), payload.clone(), &context.predecessor_account_id, &request.path, ); contract.sign(request); contract.get_pending_request(&signature_request).unwrap(); let valid_signature = secp256k1_signature_for_test( &secret_key, &context.predecessor_account_id, &key_path, &payload, ); let signature_response = if success { dtos::SignatureResponse::Secp256k1(valid_signature) } else { // submit an incorrect signature to make the respond call fail let mut bad_sig = valid_signature; bad_sig.s = dtos::K256Scalar::from([2; 32]); dtos::SignatureResponse::Secp256k1(bad_sig) }; with_active_participant_and_attested_context(&contract); match contract.respond(signature_request.clone(), signature_response.clone()) { Ok(_) => { assert!(success); contract .return_signature_and_clean_state_on_success( signature_request.clone(), Ok(signature_response), ) .detach(); assert!(contract.get_pending_request(&signature_request).is_none(),); } Err(_) => assert!(!success), } } #[test] fn respond__should_succeed_when_response_is_valid_and_request_exists() { for protocol in [Protocol::CaitSith, Protocol::DamgardEtAl] { test_signature_common(true, false, protocol); test_signature_common(false, false, protocol); } } #[test] fn respond__should_succeed_when_response_is_valid_and_request_exists_legacy() { for protocol in [Protocol::CaitSith, Protocol::DamgardEtAl] { test_signature_common(true, true, protocol); test_signature_common(false, true, protocol); } } #[test] fn test_signature_timeout() { let (context, mut contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); let payload = Payload::from_legacy_ecdsa([0u8; 32]); let key_path = "m/44'\''/60'\''/0'\''/0/0".to_string(); let request = SignRequestArgs { payload: payload.clone(), path: key_path.clone(), domain_id: DomainId::legacy_ecdsa_id(), }; let signature_request = SignatureRequest::new( DomainId::default(), payload, &context.predecessor_account_id, &request.path, ); contract.sign(request); // assert_matches! requires Debug, which PromiseOrValue doesn't implement assert!(matches!( contract.return_signature_and_clean_state_on_success( signature_request.clone(), Err(PromiseError::Failed) ), PromiseOrValue::Promise(_) )); assert!(contract.get_pending_request(&signature_request).is_none()); } #[test] fn test_signature_timeout__request_in_neither_map_still_schedules_fail() { // given let (context, mut contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); let signature_request = SignatureRequest::new( DomainId::default(), Payload::from_legacy_ecdsa([0u8; 32]), &context.predecessor_account_id, "m/44'\''/60'\''/0'\''/0/0", ); assert_matches!(contract.get_pending_request(&signature_request), None); // when let result = contract.return_signature_and_clean_state_on_success( signature_request, Err(PromiseError::Failed), ); // then // We can't assert_matches! on [near_sdk::PromiseOrValue] it is not Debug match result { PromiseOrValue::Promise(_promise) => {} PromiseOrValue::Value(_) => panic!("result should be a promise"), } } #[test] fn test_signature_success__returns_value_directly() { // given let (context, mut contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); let signature_request = SignatureRequest::new( DomainId::default(), Payload::from_legacy_ecdsa([0u8; 32]), &context.predecessor_account_id, "m/44'\''/60'\''/0'\''/0/0", ); let signature_response = dtos::SignatureResponse::Secp256k1(dtos::K256Signature { big_r: dtos::K256AffinePoint::from(k256::AffinePoint::GENERATOR), s: dtos::K256Scalar::from(k256::Scalar::ONE), recovery_id: 0, }); // when let result = contract.return_signature_and_clean_state_on_success( signature_request, Ok(signature_response.clone()), ); // then match result { PromiseOrValue::Value(resp) => assert_eq!(resp, signature_response), PromiseOrValue::Promise(_) => panic!("Expected Value, got Promise"), } } #[test] fn sign__should_queue_duplicate_requests_and_drain_all_on_respond() { // Given let (context, mut contract, secret_key) = basic_setup_with_protocol(Protocol::CaitSith, DomainPurpose::Sign, &mut OsRng); let SharedSecretKey::Secp256k1(secret_key) = secret_key else { unreachable!(); }; let payload = Payload::from_legacy_ecdsa([7u8; 32]); let path = "duplicate_requests_test".to_string(); let signature_request = SignatureRequest::new( DomainId::default(), payload.clone(), &context.predecessor_account_id, &path, ); let request_args = SignRequestArgs { payload: payload.clone(), path: path.clone(), domain_id: DomainId::legacy_ecdsa_id(), }; // When: same caller submits the same request twice in close succession. contract.sign(request_args.clone()); contract.sign(request_args); // Then: both yields are queued under one map entry. assert_eq!( contract .pending_signature_requests .get(&signature_request) .map(|q| q.len()), Some(2), "duplicate sign requests should fan out into a queue of two", ); // When: a single valid response is delivered. let signature_response = dtos::SignatureResponse::Secp256k1(secp256k1_signature_for_test( &secret_key, &context.predecessor_account_id, &path, &payload, )); with_active_participant_and_attested_context(&contract); contract .respond(signature_request.clone(), signature_response) .expect("respond should succeed"); // Then: the entire queue is drained — both queued yields received the response. assert!( contract .pending_signature_requests .get(&signature_request) .is_none() ); } #[test] fn verify_foreign_transaction__should_queue_duplicates_from_different_callers() { // Given: two different callers will submit the same foreign-tx verification request. let mut rng = rand::rngs::StdRng::from_seed([42u8; 32]); let (context, mut contract, secret_key) = basic_setup_with_protocol(Protocol::CaitSith, DomainPurpose::ForeignTx, &mut rng); register_supported_chains(&mut contract, [dtos::ForeignChain::Bitcoin]); let SharedSecretKey::Secp256k1(secret_key) = secret_key else { unreachable!(); }; let request_args = VerifyForeignTransactionRequestArgs { domain_id: DomainId::default().0.into(), payload_version: ForeignTxPayloadVersion::V1, request: dtos::ForeignChainRpcRequest::Bitcoin(BitcoinRpcRequest { tx_id: [7u8; 32].into(), confirmations: 2.into(), extractors: vec![BitcoinExtractor::BlockHash], }), }; let request = args_into_verify_foreign_tx_request(request_args.clone()); // When: caller alice submits the request. let alice = AccountId::from_str("alice.near").unwrap(); testing_env!( VMContextBuilder::new() .signer_account_id(alice.clone()) .predecessor_account_id(alice) .current_account_id(context.current_account_id.clone()) .attached_deposit(NearToken::from_yoctonear(1)) .build() ); contract.verify_foreign_transaction(request_args.clone()); // And: caller bob submits the identical request — a different account would today // be blocked from receiving a response by alice's submission. let bob = AccountId::from_str("bob.near").unwrap(); testing_env!( VMContextBuilder::new() .signer_account_id(bob.clone()) .predecessor_account_id(bob) .current_account_id(context.current_account_id.clone()) .attached_deposit(NearToken::from_yoctonear(1)) .build() ); contract.verify_foreign_transaction(request_args); // Then: both yields are queued under the single (caller-agnostic) request key. assert_eq!( contract .pending_verify_foreign_tx_requests .get(&request) .map(|q| q.len()), Some(2), "duplicate foreign-tx requests from different callers should fan out", ); // When: a single valid response is delivered. let payload = ForeignTxSignPayload::V1(ForeignTxSignPayloadV1 { request: request.request.clone(), values: vec![ExtractedValue::BitcoinExtractedValue( BitcoinExtractedValue::BlockHash([42u8; 32].into()), )], }); let payload_hash_arr = payload.compute_msg_hash().unwrap().0; let secret_key_ec: elliptic_curve::SecretKey = elliptic_curve::SecretKey::from_bytes(&secret_key.to_bytes()).unwrap(); let signing_key = SigningKey::from_bytes(&secret_key_ec.to_bytes()).unwrap(); let (signature, recovery_id) = signing_key .sign_prehash_recoverable(&payload_hash_arr) .unwrap(); let response = VerifyForeignTransactionResponse { payload_hash: payload.compute_msg_hash().unwrap(), signature: dtos::SignatureResponse::Secp256k1( dtos::K256Signature::from_ecdsa_recoverable(&signature, recovery_id), ), }; with_active_participant_and_attested_context(&contract); contract .respond_verify_foreign_tx(request.clone(), response) .expect("respond_verify_foreign_tx should succeed"); // Then: both queued yields are drained from the single map entry. assert!( contract .pending_verify_foreign_tx_requests .get(&request) .is_none() ); } #[test] fn add_signature_request__should_panic_when_pending_queue_is_full() { // Given: a contract with a queue already at the fan-out cap for some request key. let (context, mut contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); let signature_request = SignatureRequest::new( DomainId::default(), Payload::from_legacy_ecdsa([3u8; 32]), &context.predecessor_account_id, "m/44'\''/60'\''/0'\''/0/0", ); for i in 0..MAX_PENDING_REQUEST_FAN_OUT { contract.add_signature_request(signature_request.clone(), [i; 32]); } assert_eq!( contract .pending_signature_requests .get(&signature_request) .map(|q| q.len()), Some(usize::from(MAX_PENDING_REQUEST_FAN_OUT)), ); // When: one more append is attempted. let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { contract.add_signature_request(signature_request.clone(), [0xff; 32]); })); // Then: it panics with the typed cap-exceeded error and leaves the queue untouched. let err = result.expect_err("appending past the cap should panic"); let msg = err .downcast_ref::() .map(String::as_str) .or_else(|| err.downcast_ref::<&str>().copied()) .unwrap_or_default(); assert!( msg.contains("Pending-request queue is full"), "unexpected panic message: {msg}", ); assert_eq!( contract .pending_signature_requests .get(&signature_request) .map(|q| q.len()), Some(usize::from(MAX_PENDING_REQUEST_FAN_OUT)), "queue should not have grown past the cap", ); } #[test] fn return_signature_and_clean_state_on_success__should_pop_only_oldest_yield_on_timeout() { // Given: three duplicate sign requests queued for the same key. let (context, mut contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); let signature_request = SignatureRequest::new( DomainId::default(), Payload::from_legacy_ecdsa([0u8; 32]), &context.predecessor_account_id, "m/44'\''/60'\''/0'\''/0/0", ); for i in 0..3u8 { contract.add_signature_request(signature_request.clone(), [i; 32]); } assert_eq!( contract .pending_signature_requests .get(&signature_request) .map(|q| q.len()), Some(3), ); // When: the oldest yield times out. let _ = contract.return_signature_and_clean_state_on_success( signature_request.clone(), Err(PromiseError::Failed), ); // Then: the queue still contains the two remaining yields, oldest-first. let remaining = contract .pending_signature_requests .get(&signature_request) .cloned() .expect("entry must remain while sibling yields are still pending"); assert_eq!(remaining.len(), 2); assert_eq!(remaining[0].data_id, [1u8; 32]); assert_eq!(remaining[1].data_id, [2u8; 32]); // When: the last two yields time out, the map entry is fully cleaned up. let _ = contract.return_signature_and_clean_state_on_success( signature_request.clone(), Err(PromiseError::Failed), ); let _ = contract.return_signature_and_clean_state_on_success( signature_request.clone(), Err(PromiseError::Failed), ); // Then: the entry is gone. assert!( contract .pending_signature_requests .get(&signature_request) .is_none() ); } #[test] fn respond_ckd__should_succeed_when_response_is_valid_and_request_exists() { let (context, mut contract, _secret_key) = basic_setup(Curve::Bls12381, &mut OsRng); let app_public_key: dtos::Bls12381G1PublicKey = "bls12381g1:6KtVVcAAGacrjNGePN8bp3KV6fYGrw1rFsyc7cVJCqR16Zc2ZFg3HX3hSZxSfv1oH6" .parse() .unwrap(); let request = CKDRequestArgs { derivation_path: "".to_string(), app_public_key: CKDAppPublicKey::AppPublicKey(app_public_key.clone()), domain_id: dtos::DomainId::default(), }; let ckd_request = CKDRequest::new( CKDAppPublicKey::AppPublicKey(app_public_key), request.domain_id, &context.predecessor_account_id, &request.derivation_path, ); contract.request_app_private_key(request); contract.get_pending_ckd_request(&ckd_request).unwrap(); let response = CKDResponse { big_y: dtos::Bls12381G1PublicKey([1u8; 48]), big_c: dtos::Bls12381G1PublicKey([2u8; 48]), }; with_active_participant_and_attested_context(&contract); match contract.respond_ckd(ckd_request.clone(), response.clone()) { Ok(_) => { contract .return_ck_and_clean_state_on_success(ckd_request.clone(), Ok(response)) .detach(); assert!(contract.get_pending_ckd_request(&ckd_request).is_none(),); } Err(_) => panic!("respond_ckd should not fail"), } } #[test] fn respond_ckd_pv__should_succeed_when_response_is_valid_and_request_exists() { let mut rng = rand::rngs::StdRng::from_seed([42u8; 32]); let (context, mut contract, secret_key) = basic_setup(Curve::Bls12381, &mut rng); let SharedSecretKey::Bls12381(secret_key) = secret_key else { unreachable!(); }; let (_, app_public_key) = new_ckd_pv_app_pk(&mut rng); let app_public_key = CKDAppPublicKey::AppPublicKeyPV(app_public_key); let derivation_path = "my derivation path".to_string(); let request = CKDRequestArgs { derivation_path, app_public_key: app_public_key.clone(), domain_id: dtos::DomainId::default(), }; let ckd_request = CKDRequest::new( app_public_key, request.domain_id, &context.predecessor_account_id, &request.derivation_path, ); contract.request_app_private_key(request); contract.get_pending_ckd_request(&ckd_request).unwrap(); let response = compute_ckd_pv_response(&secret_key, &ckd_request); with_active_participant_and_attested_context(&contract); match contract.respond_ckd(ckd_request.clone(), response.clone()) { Ok(_) => { contract .return_ck_and_clean_state_on_success(ckd_request.clone(), Ok(response)) .detach(); assert!(contract.get_pending_ckd_request(&ckd_request).is_none(),); } Err(_) => panic!("respond_ckd should not fail"), } } fn override_context_for_preconditions(deposit: NearToken, prepaid_gas: Gas) { let predecessor: AccountId = "contract_account.near".parse().unwrap(); let context = VMContextBuilder::new() .predecessor_account_id(predecessor.clone()) .current_account_id(predecessor) .attached_deposit(deposit) .prepaid_gas(prepaid_gas) .build(); testing_env!(context); } #[test] fn check_request_preconditions__returns_domain_config_and_predecessor_on_valid_call() { let mut rng = rand::rngs::StdRng::from_seed([42u8; 32]); let (_, contract, _) = basic_setup(Curve::Secp256k1, &mut rng); let (config, predecessor) = contract.check_request_preconditions( DomainId::default(), DomainPurpose::Sign, Gas::from_tgas(1), NearToken::from_yoctonear(1), ); assert_eq!(config.id, DomainId::default()); assert_eq!(Curve::from(config.protocol), Curve::Secp256k1); assert_eq!(config.purpose, DomainPurpose::Sign); assert_eq!(predecessor.as_str(), "contract_account.near"); } #[test] #[should_panic(expected = "was not found")] fn check_request_preconditions__panics_when_domain_does_not_exist() { let mut rng = rand::rngs::StdRng::from_seed([42u8; 32]); let (_, contract, _) = basic_setup(Curve::Secp256k1, &mut rng); contract.check_request_preconditions( DomainId(999), DomainPurpose::Sign, Gas::from_tgas(1), NearToken::from_yoctonear(1), ); } #[test] #[should_panic(expected = "purpose")] fn check_request_preconditions__panics_when_domain_purpose_does_not_match() { let mut rng = rand::rngs::StdRng::from_seed([42u8; 32]); let (_, contract, _) = basic_setup(Curve::Secp256k1, &mut rng); contract.check_request_preconditions( DomainId::default(), DomainPurpose::CKD, Gas::from_tgas(1), NearToken::from_yoctonear(1), ); } #[test] #[should_panic(expected = "Provided gas is lower than required")] fn check_request_preconditions__panics_when_prepaid_gas_is_insufficient() { let (_, contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); override_context_for_preconditions(NearToken::from_yoctonear(1), Gas::from_tgas(1)); contract.check_request_preconditions( DomainId::default(), DomainPurpose::Sign, Gas::from_tgas(100), NearToken::from_yoctonear(1), ); } #[test] #[should_panic(expected = "Attached deposit is lower than required")] fn check_request_preconditions__panics_when_attached_deposit_is_insufficient() { let (_, contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); override_context_for_preconditions(NearToken::from_yoctonear(0), Gas::from_tgas(300)); contract.check_request_preconditions( DomainId::default(), DomainPurpose::Sign, Gas::from_tgas(1), NearToken::from_yoctonear(1), ); } #[test] #[should_panic(expected = "TEE validation")] fn check_request_preconditions__panics_when_contract_is_not_accepting_requests() { let mut rng = rand::rngs::StdRng::from_seed([42u8; 32]); let (_, mut contract, _) = basic_setup(Curve::Secp256k1, &mut rng); contract.accept_requests = false; contract.check_request_preconditions( DomainId::default(), DomainPurpose::Sign, Gas::from_tgas(1), NearToken::from_yoctonear(1), ); } #[test] #[should_panic(expected = "app public key check failed")] fn request_ckd_pv__should_reject_mismatched_app_public_key() { let mut rng = rand::rngs::StdRng::from_seed([42u8; 32]); let (_context, mut contract, _secret_key) = basic_setup(Curve::Bls12381, &mut rng); // Generate pk1 and pk2 from different scalars so the pairing check fails let scalar1 = ckd::Scalar::random(&mut rng); let scalar2 = ckd::Scalar::random(&mut rng); let pk1 = dtos::Bls12381G1PublicKey::from(&(ckd::ElementG1::generator() * scalar1)); let pk2 = dtos::Bls12381G2PublicKey::from(&(ckd::ElementG2::generator() * scalar2)); let request = CKDRequestArgs { derivation_path: "test".to_string(), app_public_key: CKDAppPublicKey::AppPublicKeyPV(dtos::CKDAppPublicKeyPV { pk1, pk2 }), domain_id: dtos::DomainId::default(), }; contract.request_app_private_key(request); } #[test] #[should_panic(expected = "CKD output check failed")] fn respond_ckd_pv__should_reject_invalid_response() { let mut rng = rand::rngs::StdRng::from_seed([42u8; 32]); let (context, mut contract, secret_key) = basic_setup(Curve::Bls12381, &mut rng); let SharedSecretKey::Bls12381(secret_key) = secret_key else { unreachable!(); }; let (_, app_public_key) = new_ckd_pv_app_pk(&mut rng); let app_public_key = CKDAppPublicKey::AppPublicKeyPV(app_public_key); let request = CKDRequestArgs { derivation_path: "test".to_string(), app_public_key: app_public_key.clone(), domain_id: dtos::DomainId::default(), }; let ckd_request = CKDRequest::new( app_public_key, request.domain_id, &context.predecessor_account_id, &request.derivation_path, ); contract.request_app_private_key(request); // Compute a valid response then tamper with big_c let mut response = compute_ckd_pv_response(&secret_key, &ckd_request); response.big_c = dtos::Bls12381G1PublicKey::from( &(ckd::ElementG1::generator() * ckd::Scalar::random(&mut rng)), ); with_active_participant_and_attested_context(&contract); let _ = contract.respond_ckd(ckd_request, response); } #[test] fn test_ckd_timeout() { let (context, mut contract, _secret_key) = basic_setup(Curve::Bls12381, &mut OsRng); let app_public_key: dtos::Bls12381G1PublicKey = "bls12381g1:6KtVVcAAGacrjNGePN8bp3KV6fYGrw1rFsyc7cVJCqR16Zc2ZFg3HX3hSZxSfv1oH6" .parse() .unwrap(); let request = CKDRequestArgs { derivation_path: "".to_string(), app_public_key: CKDAppPublicKey::AppPublicKey(app_public_key.clone()), domain_id: dtos::DomainId::default(), }; let ckd_request = CKDRequest::new( CKDAppPublicKey::AppPublicKey(app_public_key), request.domain_id, &context.predecessor_account_id, &request.derivation_path, ); contract.request_app_private_key(request); // assert_matches! requires Debug, which PromiseOrValue doesn't implement assert!(matches!( contract.return_ck_and_clean_state_on_success( ckd_request.clone(), Err(PromiseError::Failed) ), PromiseOrValue::Promise(_) )); assert!(contract.get_pending_ckd_request(&ckd_request).is_none()); } #[test] fn respond_verify_foreign_tx__should_succeed_when_response_is_valid_and_request_exists() { // Given let mut rng = rand::rngs::StdRng::from_seed([42u8; 32]); let (context, mut contract, secret_key) = basic_setup_with_protocol(Protocol::CaitSith, DomainPurpose::ForeignTx, &mut rng); register_supported_chains(&mut contract, [dtos::ForeignChain::Bitcoin]); testing_env!(context.clone()); let SharedSecretKey::Secp256k1(secret_key) = secret_key else { unreachable!(); }; let request_args = VerifyForeignTransactionRequestArgs { domain_id: DomainId::default().0.into(), payload_version: ForeignTxPayloadVersion::V1, request: dtos::ForeignChainRpcRequest::Bitcoin(BitcoinRpcRequest { tx_id: [7u8; 32].into(), confirmations: 2.into(), extractors: vec![BitcoinExtractor::BlockHash], }), }; // When let request = args_into_verify_foreign_tx_request(request_args.clone()); contract.verify_foreign_transaction(request_args); contract .get_pending_verify_foreign_tx_request(&request) .unwrap(); let payload = ForeignTxSignPayload::V1(ForeignTxSignPayloadV1 { request: request.request.clone(), values: vec![ExtractedValue::BitcoinExtractedValue( BitcoinExtractedValue::BlockHash([42u8; 32].into()), )], }); let payload_hash = payload.compute_msg_hash().unwrap().0; // simulate signature with the root key (no tweak for foreign tx) let secret_key_ec: elliptic_curve::SecretKey = elliptic_curve::SecretKey::from_bytes(&secret_key.to_bytes()).unwrap(); let secret_key = SigningKey::from_bytes(&secret_key_ec.to_bytes()).unwrap(); let (signature, recovery_id) = secret_key.sign_prehash_recoverable(&payload_hash).unwrap(); let signature = dtos::SignatureResponse::Secp256k1( dtos::K256Signature::from_ecdsa_recoverable(&signature, recovery_id), ); let payload_hash = payload.compute_msg_hash().unwrap(); let response = VerifyForeignTransactionResponse { payload_hash, signature, }; with_active_participant_and_attested_context(&contract); // Then match contract.respond_verify_foreign_tx(request.clone(), response.clone()) { Ok(_) => { contract .return_verify_foreign_tx_and_clean_state_on_success( request.clone(), Ok(response), ) .detach(); assert!( contract .get_pending_verify_foreign_tx_request(&request) .is_none(), ); } Err(_) => panic!("respond_verify_foreign_tx should not fail"), } } #[test] fn test_verify_foreign_tx_timeout() { // Given let mut rng = rand::rngs::StdRng::from_seed([42u8; 32]); let (context, mut contract, _secret_key) = basic_setup_with_protocol(Protocol::CaitSith, DomainPurpose::ForeignTx, &mut rng); register_supported_chains(&mut contract, [dtos::ForeignChain::Bitcoin]); testing_env!(context.clone()); let request_args = VerifyForeignTransactionRequestArgs { domain_id: DomainId::default().0.into(), payload_version: ForeignTxPayloadVersion::V1, request: dtos::ForeignChainRpcRequest::Bitcoin(BitcoinRpcRequest { tx_id: [7u8; 32].into(), confirmations: 2.into(), extractors: vec![BitcoinExtractor::BlockHash], }), }; let request = args_into_verify_foreign_tx_request(request_args.clone()); // When contract.verify_foreign_transaction(request_args); // Then // assert_matches! requires Debug, which PromiseOrValue doesn't implement assert!(matches!( contract.return_verify_foreign_tx_and_clean_state_on_success( request.clone(), Err(PromiseError::Failed) ), PromiseOrValue::Promise(_) )); assert!( contract .get_pending_verify_foreign_tx_request(&request) .is_none() ); } #[rstest] #[case(Protocol::CaitSith, DomainPurpose::ForeignTx)] #[case(Protocol::ConfidentialKeyDerivation, DomainPurpose::CKD)] #[should_panic(expected = "this method requires Sign")] fn sign__should_reject_non_sign_domain( #[case] protocol: Protocol, #[case] purpose: DomainPurpose, ) { // Given let mut rng = rand::rngs::StdRng::from_seed([42u8; 32]); let (_context, mut contract, _sk) = basic_setup_with_protocol(protocol, purpose, &mut rng); // When contract.sign(SignRequestArgs { payload: Payload::from_legacy_ecdsa([7u8; 32]), path: "test".to_string(), domain_id: DomainId::default(), }); } #[rstest] #[case(Protocol::CaitSith, DomainPurpose::Sign)] #[case(Protocol::ConfidentialKeyDerivation, DomainPurpose::CKD)] #[should_panic(expected = "this method requires ForeignTx")] fn verify_foreign_tx__should_reject_non_foreign_tx_domain( #[case] protocol: Protocol, #[case] purpose: DomainPurpose, ) { // Given let mut rng = rand::rngs::StdRng::from_seed([42u8; 32]); let (_context, mut contract, _sk) = basic_setup_with_protocol(protocol, purpose, &mut rng); // When contract.verify_foreign_transaction(VerifyForeignTransactionRequestArgs { domain_id: DomainId::default().0.into(), payload_version: ForeignTxPayloadVersion::V1, request: dtos::ForeignChainRpcRequest::Bitcoin(BitcoinRpcRequest { tx_id: [7u8; 32].into(), confirmations: 2.into(), extractors: vec![BitcoinExtractor::BlockHash], }), }); } #[test] #[should_panic(expected = "Requested foreign chain, Bitcoin, is not supported.")] fn verify_foreign_tx__should_reject_chain_not_in_policy() { // Given let mut rng = rand::rngs::StdRng::from_seed([42u8; 32]); let (context, mut contract, _sk) = basic_setup_with_protocol(Protocol::CaitSith, DomainPurpose::ForeignTx, &mut rng); // Supported chains has Solana but not Bitcoin register_supported_chains(&mut contract, [dtos::ForeignChain::Solana]); testing_env!(context.clone()); // When - requesting Bitcoin which is not in the policy contract.verify_foreign_transaction(VerifyForeignTransactionRequestArgs { domain_id: DomainId::default().0.into(), payload_version: ForeignTxPayloadVersion::V1, request: dtos::ForeignChainRpcRequest::Bitcoin(BitcoinRpcRequest { tx_id: [7u8; 32].into(), confirmations: 2.into(), extractors: vec![BitcoinExtractor::BlockHash], }), }); } #[rstest] #[case(DomainPurpose::Sign)] #[case(DomainPurpose::ForeignTx)] #[should_panic(expected = "this method requires CKD")] fn ckd__should_reject_non_ckd_domain(#[case] purpose: DomainPurpose) { // Given let mut rng = rand::rngs::StdRng::from_seed([42u8; 32]); let (_context, mut contract, _sk) = basic_setup_with_protocol(Protocol::CaitSith, purpose, &mut rng); // When contract.request_app_private_key(CKDRequestArgs { domain_id: dtos::DomainId::default(), derivation_path: "test".to_string(), app_public_key: CKDAppPublicKey::AppPublicKey(dtos::Bls12381G1PublicKey::from( [0u8; 48], )), }); } fn setup_tee_test_contract( num_participants: usize, threshold_value: u64, ) -> (MpcContract, Participants, AccountId) { let participants = primitives::test_utils::gen_participants(num_participants); let first_participant_id = participants.participants()[0].0.clone(); let context = VMContextBuilder::new() .signer_account_id(first_participant_id.clone()) .predecessor_account_id(first_participant_id.clone()) .attached_deposit(NearToken::from_near(1)) .build(); testing_env!(context); let threshold = Threshold::new(threshold_value); let parameters = ThresholdParameters::new(participants.clone(), threshold).unwrap(); let contract = MpcContract::init((¶meters).into_dto_type(), None).unwrap(); (contract, participants, first_participant_id) } #[test] #[expect(non_snake_case)] fn vote_tee_verifier_change__should_apply_candidate_when_threshold_reached() { // Given a running contract with 3 participants, signing threshold 2, // starting unconfigured. let (mut contract, participants, _) = setup_tee_test_contract(3, 2); assert_eq!(contract.tee_verifier_account_id, None); let participant_account_ids: Vec = participants .participants() .iter() .map(|(account_id, _, _)| account_id.clone()) .collect(); let candidate: AccountId = "verifier.near".parse().unwrap(); let code_hash = TeeVerifierCodeHash::new([7u8; 32]); let vote_as = |contract: &mut MpcContract, account_id: &AccountId| { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_tee_verifier_change(candidate.clone(), code_hash) .expect("vote should succeed"); }; // When the first participant votes (below threshold), the verifier is unchanged. vote_as(&mut contract, &participant_account_ids[0]); assert_eq!(contract.tee_verifier_account_id, None); // When the second participant votes, threshold is reached and the // candidate becomes the trusted verifier. vote_as(&mut contract, &participant_account_ids[1]); assert_eq!(contract.tee_verifier_account_id, Some(candidate)); } #[test] #[expect(non_snake_case)] fn remove_non_participant_tee_verifier_votes__should_drop_votes_from_dropped_participants() { // Given a running contract with 3 participants, signing threshold 3, where // two participants have cast votes for distinct candidates (neither crosses // threshold, so both stay pending). let (mut contract, participants, _) = setup_tee_test_contract(3, 3); let voters = participant_account_ids(&contract); let code_hash = TeeVerifierCodeHash::new([7u8; 32]); // Vote as `account_id` for `candidate`, returning that voter's authenticated id. let vote_as = |contract: &mut MpcContract, account_id: &AccountId, candidate: &AccountId| { Environment::new(None, Some(account_id.clone()), None); contract .vote_tee_verifier_change(candidate.clone(), code_hash) .expect("vote should succeed"); AuthenticatedParticipantId::new(&participants).unwrap() }; // The single-voter pending bucket: proposal(candidate) -> {voter}. let bucket = |candidate: &AccountId, voter: &AuthenticatedParticipantId| { let proposal = VerifierChangeProposal { candidate_account_id: candidate.clone(), expected_code_hash: code_hash, }; ( ProposalHash::from(proposal), BTreeSet::from([voter.clone()]), ) }; let candidate_a: AccountId = "verifier-a.near".parse().unwrap(); let candidate_b: AccountId = "verifier-b.near".parse().unwrap(); let auth_a = vote_as(&mut contract, &voters[0], &candidate_a); let auth_b = vote_as(&mut contract, &voters[1], &candidate_b); // Then both single-voter buckets are pending. assert_eq!( contract.tee_verifier_votes(), BTreeMap::from([bucket(&candidate_a, &auth_a), bucket(&candidate_b, &auth_b)]), ); // When resharing drops the first participant and the post-resharing cleanup runs. { let ProtocolContractState::Running(ref mut state) = contract.protocol_state else { panic!("expected Running"); }; state.parameters = ThresholdParameters::new(participants.subset(1..3), Threshold::new(2)).unwrap(); } Environment::new(None, Some(env::current_account_id()), None); contract .remove_non_participant_tee_verifier_votes() .unwrap(); // Then only the still-participant's vote (candidate B) remains. assert_eq!( contract.tee_verifier_votes(), BTreeMap::from([bucket(&candidate_b, &auth_b)]), ); } fn submit_attestation( contract: &mut MpcContract, participants: &Participants, participant_index: usize, is_valid: bool, ) -> Result<(), Error> { let participants_list = participants.participants(); let (account_id, _, participant_info) = &participants_list[participant_index]; let attestation = if is_valid { MockAttestation::Valid } else { MockAttestation::Invalid }; let dto_public_key = participant_info.tls_public_key.clone(); let participant_context = VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .attached_deposit(NearToken::from_near(1)) .build(); testing_env!(participant_context); contract.submit_participant_info(Attestation::Mock(attestation), dto_public_key) } fn submit_valid_attestations( contract: &mut MpcContract, participants: &Participants, participant_indices: &[usize], ) { for &participant_index in participant_indices { let result = submit_attestation(contract, participants, participant_index, true); assert!( result.is_ok(), "submit_participant_info should succeed with valid attestation for participant {}", participant_index ); } } /// Sets up the voting context and calls [`VersionedMpcContract::vote_new_parameters`] with the /// given parameters. fn setup_voting_context_and_vote( contract: &mut MpcContract, first_participant_id: &AccountId, participants: Participants, threshold: Threshold, ) -> Result<(), Error> { let voting_context = VMContextBuilder::new() .signer_account_id(first_participant_id.clone()) .predecessor_account_id(first_participant_id.clone()) .attached_deposit(NearToken::from_yoctonear(0)) .build(); testing_env!(voting_context); let proposal = ProposedThresholdParameters::new( ThresholdParameters::new(participants, threshold).unwrap(), BTreeMap::new(), ); contract.vote_new_parameters(EpochId::new(1), (&proposal).into_dto_type()) } /// Test that [`VersionedMpcContract::vote_new_parameters`] succeeds when all participants have /// default TEE status ([`TeeQuoteStatus::None`]). This tests the basic scenario where no /// participants have submitted attestation information, and all have the default TEE status /// of [`TeeQuoteStatus::None`], which is considered acceptable. #[test] fn test_vote_new_parameters_succeeds_with_default_tee_status() { let (mut contract, participants, first_participant_id) = setup_tee_test_contract(3, 2); let threshold = Threshold::new(2); // No attestations submitted - all participants have default TEE status None let result = setup_voting_context_and_vote( &mut contract, &first_participant_id, participants, threshold, ); assert!( result.is_ok(), "Should succeed when all participants have default TEE status None" ); } /// Test that [`MpcContract::vote_new_parameters`] succeeds when all participants /// submit valid TEE attestations. This tests the scenario where all participants successfully /// submit valid attestations through [`MpcContract::submit_participant_info`], /// resulting in [`TeeQuoteStatus::Valid`] TEE status for all participants. #[test] fn test_vote_new_parameters_succeeds_when_all_participants_have_valid_tee() { let (mut contract, participants, first_participant_id) = setup_tee_test_contract(3, 2); let threshold = Threshold::new(2); // Submit valid attestations for all participants submit_valid_attestations(&mut contract, &participants, &[0, 1, 2]); // This should succeed because all participants now have valid TEE status let result = setup_voting_context_and_vote( &mut contract, &first_participant_id, participants, threshold, ); assert!( result.is_ok(), "Should succeed when all participants have valid TEE status" ); } /// Test that attempts to submit invalid attestations are rejected by /// [`MpcContract::submit_participant_info`]. This test demonstrates that /// participants cannot have Invalid TEE status because the contract proactively rejects /// invalid attestations at submission time. The 4th participant tries to submit an invalid /// attestation but is rejected, leaving them with [`TeeQuoteStatus::Invalid`] status, which /// combined with valid participants still allows successful voting. #[test] fn test_vote_new_parameters_succeeds_after_invalid_attestation_rejected() { let (mut contract, participants, first_participant_id) = setup_tee_test_contract(4, 3); let threshold = Threshold::new(3); // Submit valid attestations for first 3 participants submit_valid_attestations(&mut contract, &participants, &[0, 1, 2]); // Try to submit invalid attestation for the 4th participant let participant_index = 3; let result = submit_attestation(&mut contract, &participants, participant_index, false); assert!( result.is_err(), "Invalid attestation should be rejected by submit_participant_info" ); if let Err(error) = result { let error_string = error.to_string(); assert!( error_string.contains("TeeQuoteStatus is invalid"), "Error should mention invalid TEE status, got: {}", error_string ); } // This should succeed because: // - 3 participants have Valid TEE status (from successful attestations) // - 1 participant has None TEE status (invalid attestation was rejected) // - Both Valid and None are allowed by the TEE validation let result = setup_voting_context_and_vote( &mut contract, &first_participant_id, participants, threshold, ); assert!( result.is_ok(), "Should succeed when participants have Valid or None TEE status (invalid attestations rejected)" ); } /// Builds a Running contract with `num_participants` participants, signing /// threshold `threshold`, and a single CaitSith `Sign` domain whose /// reconstruction threshold is `reconstruction_threshold`. fn setup_running_contract_with_domain( num_participants: usize, threshold: u64, reconstruction_threshold: u64, ) -> (MpcContract, Participants, AccountId, DomainId) { let participants = gen_participants(num_participants); let first_participant_id = participants.participants()[0].0.clone(); testing_env!( VMContextBuilder::new() .signer_account_id(first_participant_id.clone()) .predecessor_account_id(first_participant_id.clone()) .attached_deposit(NearToken::from_near(1)) .build() ); let parameters = ThresholdParameters::new(participants.clone(), Threshold::new(threshold)).unwrap(); let domain_id = DomainId::default(); let domains = vec![DomainConfig { id: domain_id, protocol: Protocol::CaitSith, reconstruction_threshold: ReconstructionThreshold::new(reconstruction_threshold), purpose: DomainPurpose::Sign, }]; let (pk, _) = make_public_key_for_curve(Curve::Secp256k1, &mut OsRng); let keyset = Keyset::new( EpochId::new(0), vec![KeyForDomain { domain_id, key: pk.try_into().unwrap(), attempt: AttemptId::new(), }], ); let contract = MpcContract::init_running(domains, 1, keyset, (¶meters).into_dto_type(), None) .unwrap(); (contract, participants, first_participant_id, domain_id) } /// Installs a voting context for `signer` and casts `proposal`. fn vote_params( contract: &mut MpcContract, signer: &AccountId, proposal: &ProposedThresholdParameters, ) -> Result<(), Error> { testing_env!( VMContextBuilder::new() .signer_account_id(signer.clone()) .predecessor_account_id(signer.clone()) .attached_deposit(NearToken::from_yoctonear(0)) .build() ); contract.vote_new_parameters(EpochId::new(1), proposal.into_dto_type()) } #[test] fn vote_new_parameters__should_reject_when_per_domain_threshold_exceeds_participants() { // Given: a Running contract with 3 participants and one domain. let (mut contract, participants, signer, domain_id) = setup_running_contract_with_domain(3, 2, 2); // ...and a proposal raising that domain's reconstruction threshold to 4. let mut per_domain = BTreeMap::new(); per_domain.insert(domain_id, ReconstructionThreshold::new(4)); let proposal = ProposedThresholdParameters::new( ThresholdParameters::new(participants, Threshold::new(2)).unwrap(), per_domain, ); // When let result = vote_params(&mut contract, &signer, &proposal); // Then: 4 > 3 participants, so the guard rejects it. assert_matches!( result.unwrap_err(), Error::DomainError(DomainError::ReconstructionThresholdExceedsParticipants { threshold: 4, participants: 3, }) ); } #[test] fn vote_new_parameters__should_reject_when_shrinking_below_governance_threshold() { // Given: a Running contract with 4 participants and a GovernanceThreshold of 3. let (mut contract, participants, signer, _domain_id) = setup_running_contract_with_domain(4, 3, 3); // ...and a proposal that shrinks the participant set to 2 without touching // the per-domain thresholds. let proposal = ProposedThresholdParameters::new( ThresholdParameters::new(participants.subset(0..2), Threshold::new(2)).unwrap(), BTreeMap::new(), ); // When let result = vote_params(&mut contract, &signer, &proposal); // Then: the candidate-set guard rejects the proposal first, because only 2 // old participants remain — fewer than the GovernanceThreshold of 3. (Under // the GovernanceThreshold >= max(ReconstructionThreshold) invariant this guard // always fires before any per-domain ReconstructionThreshold check could.) assert_matches!( result.unwrap_err(), Error::InvalidCandidateSet(InvalidCandidateSet::InsufficientOldParticipants) ); } #[test] fn vote_new_parameters__should_reject_when_signing_threshold_exceeds_participants() { // Given: a Running contract with 3 participants and one domain. let (mut contract, participants, signer, _domain_id) = setup_running_contract_with_domain(3, 2, 2); // ...and a proposal whose signing threshold (4) exceeds the participant set. let proposal = ProposedThresholdParameters::new( ThresholdParameters::new_unvalidated(participants, Threshold::new(4)), BTreeMap::new(), ); // When let result = vote_params(&mut contract, &signer, &proposal); // Then assert_matches!( result.unwrap_err(), Error::InvalidThreshold(InvalidThreshold::MaxRequirementFailed { max: 3, found: 4 }) ); } #[test] fn vote_new_parameters__should_accept_per_domain_threshold_within_participant_count() { // Given: a Running contract with 5 participants (GovernanceThreshold 4) and one domain. let (mut contract, participants, signer, domain_id) = setup_running_contract_with_domain(5, 4, 2); // ...and a proposal raising the domain's reconstruction threshold to 4, // which fits the 5 participants and does not exceed the GovernanceThreshold. let mut per_domain = BTreeMap::new(); per_domain.insert(domain_id, ReconstructionThreshold::new(4)); let proposal = ProposedThresholdParameters::new( ThresholdParameters::new(participants, Threshold::new(4)).unwrap(), per_domain, ); // When: a single participant votes (no transition yet). let result = vote_params(&mut contract, &signer, &proposal); // Then: the guard passes and the vote is recorded. assert_matches!(result, Ok(())); } #[test] fn vote_new_parameters__should_reject_governance_below_max_reconstruction() { // Given: a Running contract with 5 participants, GovernanceThreshold 4, and a // domain whose reconstruction threshold is 4. let (mut contract, participants, signer, _domain_id) = setup_running_contract_with_domain(5, 4, 4); // ...and a proposal lowering the GovernanceThreshold to 3 (valid on its own) // while the domain keeps its reconstruction threshold of 4. let proposal = ProposedThresholdParameters::new( ThresholdParameters::new(participants, Threshold::new(3)).unwrap(), BTreeMap::new(), ); // When let result = vote_params(&mut contract, &signer, &proposal); // Then: the GovernanceThreshold (3) would fall below the domain's // reconstruction threshold (4), so the proposal is rejected. assert_matches!( result.unwrap_err(), Error::InvalidThreshold(InvalidThreshold::BelowReconstructionThreshold { reconstruction_threshold: 4, governance_threshold: 3, }) ); } #[test] #[should_panic(expected = "Caller must be the signer account")] fn vote_new_parameters__should_panic_when_predecessor_differs_from_signer() { // Given: a participant whose vote is forwarded through another contract, // 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 = ProposedThresholdParameters::new( ThresholdParameters::new(participants, threshold).unwrap(), BTreeMap::new(), ); let ctx = VMContextBuilder::new() .signer_account_id(first_participant_id) .predecessor_account_id("forwarder.near".parse().unwrap()) .attached_deposit(NearToken::from_yoctonear(0)) .build(); testing_env!(ctx); // When / Then: the confused-deputy vote must be rejected before it is recorded. contract .vote_new_parameters(EpochId::new(1), (&proposal).into_dto_type()) .expect("expected panic when predecessor != signer"); } /// Builds a Running-state contract and installs a VM context where the participant is the /// signer but the call is forwarded through another contract (`predecessor != signer`). /// All governance methods gated by `assert_caller_is_signer()` run that check before any /// protocol-state logic, so Running state is sufficient to exercise the guard for every one. fn forwarded_participant_call_contract() -> MpcContract { let running_state = gen_running_state(1); let participant = running_state.parameters.participants().participants()[0] .0 .clone(); let contract = MpcContract::new_from_protocol_state(ProtocolContractState::Running(running_state)); let ctx = VMContextBuilder::new() .signer_account_id(participant) .predecessor_account_id("forwarder.near".parse().unwrap()) .build(); testing_env!(ctx); contract } #[test] #[should_panic(expected = "Caller must be the signer account")] fn vote_add_domains__should_panic_when_predecessor_differs_from_signer() { let mut contract = forwarded_participant_call_contract(); contract .vote_add_domains(vec![]) .expect("expected panic when predecessor != signer"); } #[test] #[should_panic(expected = "Caller must be the signer account")] fn vote_cancel_resharing__should_panic_when_predecessor_differs_from_signer() { let mut contract = forwarded_participant_call_contract(); contract .vote_cancel_resharing() .expect("expected panic when predecessor != signer"); } #[test] #[should_panic(expected = "Caller must be the signer account")] fn vote_cancel_keygen__should_panic_when_predecessor_differs_from_signer() { let mut contract = forwarded_participant_call_contract(); contract .vote_cancel_keygen(0) .expect("expected panic when predecessor != signer"); } #[test] #[should_panic(expected = "Caller must be the signer account")] fn register_foreign_chain_support__should_panic_when_predecessor_differs_from_signer() { let mut contract = forwarded_participant_call_contract(); contract .register_foreign_chain_support(BTreeSet::new().into()) .expect("expected panic when predecessor != signer"); } #[test] #[should_panic(expected = "Caller must be the signer account")] fn test_submit_participant_info_panics_if_predecessor_differs() { let (mut contract, participants, _first_participant_id) = setup_tee_test_contract(3, 2); submit_valid_attestations(&mut contract, &participants, &[0, 1, 2]); let (participant_id, _, participant_info) = participants .participants() .first() .expect("at least one participant") .clone(); let valid_attestation = Attestation::Mock(MockAttestation::Valid); // ❌ Case: signer != predecessor — should panic let ctx = VMContextBuilder::new() .signer_account_id(participant_id.clone()) .predecessor_account_id("outsider.near".parse().unwrap()) .attached_deposit(NearToken::from_near(1)) .build(); testing_env!(ctx); contract .submit_participant_info(valid_attestation, participant_info.tls_public_key.clone()) .expect("Expected panic if predecessor != signer"); } #[test] #[should_panic(expected = "Caller must be an attested participant")] fn test_attested_but_not_participant_panics() { let (mut contract, participants, _first_participant_id) = setup_tee_test_contract(3, 2); submit_valid_attestations(&mut contract, &participants, &[0, 1, 2]); let outsider_id: AccountId = "outsider.near".parse().unwrap(); let fake_tls_pk = bogus_ed25519_near_public_key(); // unique TLS key for outsider let dto_public_key = dtos::Ed25519PublicKey::try_from(&fake_tls_pk).unwrap(); let valid_attestation = Attestation::Mock(MockAttestation::Valid); // use outsider account to call submit_participant_info let ctx = VMContextBuilder::new() .signer_account_id(outsider_id.clone()) .predecessor_account_id(outsider_id.clone()) .attached_deposit(NearToken::from_near(1)) .build(); testing_env!(ctx); contract .submit_participant_info(valid_attestation, dto_public_key) .expect("Outsider attestation submission should succeed"); contract.assert_caller_is_attested_participant_and_protocol_active(); } #[test] fn test_respond_ckd_fails_for_attested_non_participant() { // --- Step 1: Setup standard contract with Bls domain and threshold=2 --- let (context, mut contract, _secret_key) = basic_setup(Curve::Bls12381, &mut OsRng); // Submit valid attestations for all participants (so contract is in Running state) // 2. Extract participants list (we have 4 by default) let participants = match &contract.protocol_state { ProtocolContractState::Running(state) => state.parameters.participants().clone(), _ => panic!("Contract should be in Running state"), }; submit_valid_attestations(&mut contract, &participants, &[0, 1, 2]); // --- Step 2: Create a valid CKD request by a legitimate participant --- let app_public_key: dtos::Bls12381G1PublicKey = "bls12381g1:6KtVVcAAGacrjNGePN8bp3KV6fYGrw1rFsyc7cVJCqR16Zc2ZFg3HX3hSZxSfv1oH6" .parse() .unwrap(); let request = CKDRequestArgs { derivation_path: "".to_string(), app_public_key: CKDAppPublicKey::AppPublicKey(app_public_key.clone()), domain_id: dtos::DomainId::default(), }; let ckd_request = CKDRequest::new( CKDAppPublicKey::AppPublicKey(app_public_key), request.domain_id, &context.predecessor_account_id.clone(), &request.derivation_path, ); // Legit participant makes the CKD request testing_env!( VMContextBuilder::new() .signer_account_id(context.predecessor_account_id.clone()) .predecessor_account_id(context.predecessor_account_id.clone()) .attached_deposit(NearToken::from_near(1)) .build() ); contract.request_app_private_key(request); assert!(contract.get_pending_ckd_request(&ckd_request).is_some()); // --- Step 3: Attested outsider (not a participant) joins --- let outsider_id: AccountId = "outsider.near".parse().unwrap(); let tls_key = bogus_ed25519_near_public_key(); let dto_public_key = dtos::Ed25519PublicKey::try_from(&tls_key).unwrap(); testing_env!( VMContextBuilder::new() .signer_account_id(outsider_id.clone()) .predecessor_account_id(outsider_id.clone()) .attached_deposit(NearToken::from_near(1)) .build() ); contract .submit_participant_info(Attestation::Mock(MockAttestation::Valid), dto_public_key) .unwrap(); // --- Step 4: Verify that a participant can still respond successfully --- with_active_participant_and_attested_context(&contract); // sets env to a real attested participant let valid_response = CKDResponse { big_y: dtos::Bls12381G1PublicKey([1u8; 48]), big_c: dtos::Bls12381G1PublicKey([2u8; 48]), }; // This should succeed (attested participant) contract .respond_ckd(ckd_request.clone(), valid_response.clone()) .expect("Participant should be allowed to respond_ckd"); // --- Step 5: Now switch to attested outsider and verify it panics --- testing_env!( VMContextBuilder::new() .signer_account_id(outsider_id.clone()) .predecessor_account_id(outsider_id.clone()) .attached_deposit(NearToken::from_near(1)) .build() ); let outsider_response = CKDResponse { big_y: dtos::Bls12381G1PublicKey([3u8; 48]), big_c: dtos::Bls12381G1PublicKey([4u8; 48]), }; let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { contract .respond_ckd(ckd_request.clone(), outsider_response) .unwrap(); })); assert!( result.is_err(), "Expected panic from attested non-participant" ); if let Err(err) = result { if let Some(msg) = err.downcast_ref::<&str>() { assert!( msg.contains("Caller must be an attested participant"), "Unexpected panic message: {}", msg ); } else if let Some(msg) = err.downcast_ref::() { assert!( msg.contains("Caller must be an attested participant"), "Unexpected panic message: {}", msg ); } else { panic!("Unexpected panic payload type"); } } } impl MpcContract { pub fn new_from_protocol_state(protocol_state: ProtocolContractState) -> Self { MpcContract { protocol_state, pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV4), pending_ckd_requests: LookupMap::new(StorageKey::PendingCKDRequestsV3), pending_verify_foreign_tx_requests: LookupMap::new( StorageKey::PendingVerifyForeignTxRequestsV2, ), accept_requests: true, proposed_updates: Default::default(), node_foreign_chain_support: Default::default(), config: Default::default(), tee_state: Default::default(), node_migrations: Default::default(), metrics: Default::default(), foreign_chains: Lazy::new( StorageKey::ForeignChainMetadata, ForeignChainsMetadata::default(), ), tee_verifier_account_id: None, tee_verifier_votes: Default::default(), } } } const NUM_GENERATED_DOMAINS: usize = 1; const NUM_DOMAINS: usize = 2 * NUM_PROTOCOLS; #[test] fn test_start_node_migration_failure_not_participant() { let running_state = ProtocolContractState::Running(gen_running_state(NUM_DOMAINS)); let mut contract = MpcContract::new_from_protocol_state(running_state); // sanity check assert!(contract.migration_info().is_empty()); let destination_node_info = gen_random_destination_info(); let non_participant = gen_account_id(); Environment::new(None, Some(non_participant), None); let res = contract.start_node_migration(destination_node_info.clone()); let _ = res.expect_err("Non-participants should not start node migrations"); assert!(contract.migration_info().is_empty()); } #[test] fn test_start_node_migration_success() { let running_state = ProtocolContractState::Running(gen_running_state(NUM_DOMAINS)); let mut contract = MpcContract::new_from_protocol_state(running_state); // sanity check assert!(contract.migration_info().is_empty()); let participants = { let ProtocolContractState::Running(running) = &contract.protocol_state else { panic!("expected running state"); }; running.parameters.participants().clone() }; let mut expected_migration_state = BTreeMap::new(); let mut test_env = Environment::new(None, None, None); for (account_id, _, _) in participants.participants() { test_env.set_signer(account_id); // sanity check assert_eq!( migration_info(&contract, account_id), (account_id.clone(), None, None) ); let destination_node_info = gen_random_destination_info(); let res = contract.start_node_migration(destination_node_info.clone()); res.expect("Participant should be able to start node migration"); let expected_res = (account_id.clone(), None, Some(destination_node_info)); assert_eq!(migration_info(&contract, account_id), expected_res); expected_migration_state.insert(expected_res.0, (expected_res.1, expected_res.2)); } let result = contract.migration_info(); assert_eq!(result, expected_migration_state); } fn gen_random_destination_info() -> DestinationNodeInfo { let url_id: usize = rand::random(); let (_, participant_info) = gen_participant(url_id); DestinationNodeInfo { signer_account_pk: bogus_ed25519_public_key(), destination_node_info: participant_info.into(), } } fn test_start_migration_node_failure_not_running(mut contract: MpcContract) { assert!(contract.migration_info().is_empty()); let destination_node_info = gen_random_destination_info(); let res = contract.start_node_migration(destination_node_info.clone()); assert_matches!( res.unwrap_err(), Error::InvalidState(InvalidState::ProtocolStateNotRunning) ); assert!(contract.migration_info().is_empty()); } #[test] fn test_start_node_migration_failure_initializing() { let initializing_state = ProtocolContractState::Initializing(gen_initializing_state(NUM_DOMAINS, 0).1); let contract = MpcContract::new_from_protocol_state(initializing_state); test_start_migration_node_failure_not_running(contract); } #[test] fn test_start_node_migration_failure_resharing() { let resharing_state = ProtocolContractState::Resharing(gen_resharing_state(NUM_DOMAINS).1); let contract = MpcContract::new_from_protocol_state(resharing_state); test_start_migration_node_failure_not_running(contract); } fn test_register_backup_service_fail_non_participant(mut contract: MpcContract) { // sanity check assert!(contract.migration_info().is_empty()); let backup_service_info = BackupServiceInfo { public_key: bogus_ed25519_public_key(), }; let non_participant = gen_account_id(); Environment::new(None, Some(non_participant), None); let res = contract.register_backup_service(backup_service_info); assert_matches!( res.unwrap_err(), Error::InvalidState(InvalidState::NotParticipant { .. }) ); assert!(contract.migration_info().is_empty()); } #[test] fn test_register_backup_service_fail_non_participant_running() { let running_state = ProtocolContractState::Running(gen_running_state(NUM_DOMAINS)); let contract = MpcContract::new_from_protocol_state(running_state); test_register_backup_service_fail_non_participant(contract); } #[test] fn test_register_backup_service_fail_non_participant_initializing() { let initializing_state = ProtocolContractState::Initializing(gen_initializing_state(NUM_DOMAINS, 0).1); let contract = MpcContract::new_from_protocol_state(initializing_state); test_register_backup_service_fail_non_participant(contract); } #[test] fn test_register_backup_service_fail_non_participant_resharnig() { let resharing_state = ProtocolContractState::Resharing(gen_resharing_state(NUM_DOMAINS).1); let contract = MpcContract::new_from_protocol_state(resharing_state); test_register_backup_service_fail_non_participant(contract); } fn test_register_backup_service_success( participants: &Participants, mut contract: MpcContract, ) { // sanity check assert!(contract.migration_info().is_empty()); let mut expected_migration_state = BTreeMap::new(); let mut test_env = Environment::new(None, None, None); for (account_id, _, _) in participants.participants() { test_env.set_signer(account_id); // sanity check assert_eq!( migration_info(&contract, account_id), (account_id.clone(), None, None) ); let backup_service_info = BackupServiceInfo { public_key: bogus_ed25519_public_key(), }; let res = contract.register_backup_service(backup_service_info.clone()); res.expect("Participant should be able to register backup service"); let expected_res = (account_id.clone(), Some(backup_service_info), None); assert_eq!(migration_info(&contract, account_id), expected_res); expected_migration_state.insert(expected_res.0, (expected_res.1, expected_res.2)); } let result = contract.migration_info(); assert_eq!(result, expected_migration_state); } #[test] fn test_register_backup_service_success_running() { let running_state = gen_running_state(NUM_DOMAINS); let participants = running_state.parameters.participants().clone(); let running_state = ProtocolContractState::Running(running_state); let contract = MpcContract::new_from_protocol_state(running_state); test_register_backup_service_success(&participants, contract); } #[test] fn test_register_backup_service_success_resharing() { let resharing_state = gen_resharing_state(NUM_DOMAINS).1; let participants = resharing_state .resharing_key .proposed_parameters() .participants() .clone(); let resharing_state = ProtocolContractState::Resharing(resharing_state); let contract = MpcContract::new_from_protocol_state(resharing_state); test_register_backup_service_success(&participants, contract); } #[test] fn test_register_backup_service_success_initializing() { let initializing_state = gen_initializing_state(NUM_DOMAINS, 0).1; let participants = initializing_state .generating_key .proposed_parameters() .participants() .clone(); let initializing_state = ProtocolContractState::Initializing(initializing_state); let contract = MpcContract::new_from_protocol_state(initializing_state); test_register_backup_service_success(&participants, contract); } #[test] fn test_conclude_node_migration_success() { let running_state = gen_running_state(NUM_DOMAINS); let keyset = running_state.keyset.clone(); let participants = running_state.parameters.participants().clone(); let running_state = ProtocolContractState::Running(running_state); let mut contract = MpcContract::new_from_protocol_state(running_state); for (account_id, expected_participant_id, _) in participants.participants() { let destination_node_info = gen_random_destination_info(); let setup = ConcludeNodeMigrationTestSetup { destination_node_info: Some(destination_node_info.clone()), attestation_tls_key: destination_node_info .destination_node_info .tls_public_key .clone(), signer_account_id: account_id.clone(), signer_account_pk: near_sdk::PublicKey::from( destination_node_info.signer_account_pk.clone(), ), expected_error_check: None, expected_post_call_info: Some(( *expected_participant_id, destination_node_info .destination_node_info .clone() .into_contract_type(), )), }; setup.run(&mut contract, &keyset); } assert!(contract.node_migrations.get_all().is_empty()); } #[test] fn test_conclude_node_migration_invalid_tee() { let running_state = gen_running_state(NUM_DOMAINS); let keyset = running_state.keyset.clone(); let participants = running_state.parameters.participants().clone(); let running_state = ProtocolContractState::Running(running_state); let mut contract = MpcContract::new_from_protocol_state(running_state); for (account_id, expected_participant_id, expected_participant_info) in participants.participants() { let destination_node_info = gen_random_destination_info(); let setup = ConcludeNodeMigrationTestSetup { destination_node_info: Some(destination_node_info.clone()), attestation_tls_key: bogus_ed25519_public_key(), signer_account_id: account_id.clone(), signer_account_pk: near_sdk::PublicKey::from( destination_node_info.signer_account_pk.clone(), ), expected_error_check: Some(|k| { matches!( k, Error::InvalidParameters( InvalidParameters::InvalidTeeRemoteAttestation { .. } ) ) }), expected_post_call_info: Some(( *expected_participant_id, expected_participant_info.clone(), )), }; setup.run(&mut contract, &keyset); } } #[test] fn test_conclude_node_migration_migration_not_found() { let running_state = gen_running_state(NUM_DOMAINS); let keyset = running_state.keyset.clone(); let participants = running_state.parameters.participants().clone(); let running_state = ProtocolContractState::Running(running_state); let mut contract = MpcContract::new_from_protocol_state(running_state); for (account_id, expected_participant_id, expected_participant_info) in participants.participants() { let destination_node_info = gen_random_destination_info(); let setup = ConcludeNodeMigrationTestSetup { destination_node_info: None, attestation_tls_key: destination_node_info .destination_node_info .tls_public_key .clone(), signer_account_id: account_id.clone(), signer_account_pk: near_sdk::PublicKey::from( destination_node_info.signer_account_pk.clone(), ), expected_error_check: Some(|k| { matches!( k, Error::NodeMigrationError(NodeMigrationError::MigrationNotFound) ) }), expected_post_call_info: Some(( *expected_participant_id, expected_participant_info.clone(), )), }; setup.run(&mut contract, &keyset); } } #[test] fn test_conclude_node_migration_keyset_mismatch() { let running_state = gen_running_state(NUM_DOMAINS); let mut keyset = running_state.keyset.clone(); keyset.epoch_id = keyset.epoch_id.next(); let participants = running_state.parameters.participants().clone(); let running_state = ProtocolContractState::Running(running_state); let mut contract = MpcContract::new_from_protocol_state(running_state); for (account_id, expected_participant_id, expected_participant_info) in participants.participants() { let destination_node_info = gen_random_destination_info(); let setup = ConcludeNodeMigrationTestSetup { destination_node_info: Some(destination_node_info.clone()), attestation_tls_key: destination_node_info .destination_node_info .tls_public_key .clone(), signer_account_id: account_id.clone(), signer_account_pk: near_sdk::PublicKey::from( destination_node_info.signer_account_pk.clone(), ), expected_error_check: Some(|k| { matches!( k, Error::NodeMigrationError(NodeMigrationError::KeysetMismatch { .. }) ) }), expected_post_call_info: Some(( *expected_participant_id, expected_participant_info.clone(), )), }; setup.run(&mut contract, &keyset); } } #[test] fn test_conclude_node_migration_not_participant() { let running_state = gen_running_state(NUM_DOMAINS); let keyset = running_state.keyset.clone(); let running_state = ProtocolContractState::Running(running_state); let mut contract = MpcContract::new_from_protocol_state(running_state); let non_participant_account_id = gen_account_id(); let destination_node_info = gen_random_destination_info(); let setup = ConcludeNodeMigrationTestSetup { destination_node_info: Some(destination_node_info.clone()), attestation_tls_key: destination_node_info .destination_node_info .tls_public_key .clone(), signer_account_id: non_participant_account_id.clone(), signer_account_pk: near_sdk::PublicKey::from( destination_node_info.signer_account_pk.clone(), ), expected_error_check: Some(|k| { matches!(k, Error::InvalidState(InvalidState::NotParticipant { .. })) }), expected_post_call_info: None, }; setup.run(&mut contract, &keyset); } fn test_conclude_node_migration_failure_not_running( participants: &Participants, contract: &mut MpcContract, keyset: &Keyset, ) { for (account_id, expected_participant_id, expected_participant_info) in participants.participants() { let destination_node_info = gen_random_destination_info(); let setup = ConcludeNodeMigrationTestSetup { destination_node_info: Some(destination_node_info.clone()), attestation_tls_key: destination_node_info .destination_node_info .tls_public_key .clone(), signer_account_id: account_id.clone(), signer_account_pk: near_sdk::PublicKey::from( destination_node_info.signer_account_pk.clone(), ), expected_error_check: Some(|k| { matches!( k, Error::InvalidState(InvalidState::ProtocolStateNotRunning) ) }), expected_post_call_info: Some(( *expected_participant_id, expected_participant_info.clone(), )), }; setup.run(contract, keyset); } } #[test] fn test_conclude_node_migration_failure_resharing() { let (_, resharing_state) = gen_resharing_state(NUM_DOMAINS); let keyset = resharing_state.previous_running_state.keyset.clone(); let participants = resharing_state .previous_running_state .parameters .participants() .clone(); let resharing_state = ProtocolContractState::Resharing(resharing_state); let mut contract = MpcContract::new_from_protocol_state(resharing_state); test_conclude_node_migration_failure_not_running(&participants, &mut contract, &keyset); } #[test] fn test_conclude_node_migration_failure_initializing() { let (_, initializing) = gen_initializing_state(NUM_DOMAINS, 0); let keyset = Keyset::new( initializing.generating_key.epoch_id(), initializing.generated_keys.clone(), ); let participants = initializing .generating_key .proposed_parameters() .participants() .clone(); let initializing_state = ProtocolContractState::Initializing(initializing); let mut contract = MpcContract::new_from_protocol_state(initializing_state); test_conclude_node_migration_failure_not_running(&participants, &mut contract, &keyset); } struct ConcludeNodeMigrationTestSetup { // a destination node info to store in the migration state destination_node_info: Option, // the tls key to store for the attestation attestation_tls_key: Ed25519PublicKey, signer_account_id: AccountId, signer_account_pk: near_sdk::PublicKey, expected_error_check: Option bool>, expected_post_call_info: Option<(ParticipantId, ParticipantInfo)>, } impl ConcludeNodeMigrationTestSetup { fn setup(&self, contract: &mut MpcContract) { if let Some(destination_node_info) = self.destination_node_info.clone() { contract.node_migrations.set_destination_node_info( self.signer_account_id.clone(), destination_node_info, ); } let valid_participant_attestation = mpc_attestation::attestation::Attestation::Mock( mpc_attestation::attestation::MockAttestation::Valid, ); let tee_upgrade_duration = Duration::from_secs(contract.config.tee_upgrade_deadline_duration_seconds); let insertion_result = contract.tee_state.add_participant( NodeId { account_id: self.signer_account_id.clone(), tls_public_key: self.attestation_tls_key.clone(), account_public_key: dtos::Ed25519PublicKey::try_from(&self.signer_account_pk) .expect("test signer_account_pk must be Ed25519"), }, valid_participant_attestation, tee_upgrade_duration, ); assert_matches::assert_matches!( insertion_result, Ok(ParticipantInsertion::NewlyInsertedParticipant) ); } pub fn run(&self, contract: &mut MpcContract, keyset: &Keyset) { self.setup(contract); let mut test_env = Environment::new(None, None, None); test_env.set_signer(&self.signer_account_id); test_env.set_pk(self.signer_account_pk.clone()); let res = contract.conclude_node_migration(keyset); if let Some(check) = &self.expected_error_check { let err = res.unwrap_err(); assert!(check(&err), "Unexpected error kind: {:?}", err); } else { res.expect("Concluding a valid migration should succeed"); } if let Some((expected_participant_id, expected_participant_info)) = &self.expected_post_call_info { let res_participants = { match &contract.protocol_state { ProtocolContractState::Running(running) => { running.parameters.participants() } ProtocolContractState::Resharing(resharing) => { resharing.previous_running_state.parameters.participants() } ProtocolContractState::Initializing(initializing) => initializing .generating_key .proposed_parameters() .participants(), _ => panic!("expected different state"), } }; let found_info = res_participants.info(&self.signer_account_id).unwrap(); assert_eq!(found_info, expected_participant_info); let found_id = res_participants.id(&self.signer_account_id).unwrap(); assert_eq!(&found_id, expected_participant_id); } } } #[test] pub fn test_cleanup_orphaned_node_migrations() { let running_state = gen_running_state(NUM_DOMAINS); let participants = running_state.parameters.participants().clone(); let running_state = ProtocolContractState::Running(running_state); let mut contract = MpcContract::new_from_protocol_state(running_state); let mut expected_vals = BTreeMap::new(); for (account_id, _, _) in participants.participants() { let destination_node_info = gen_random_destination_info(); contract .node_migrations .set_destination_node_info(account_id.clone(), destination_node_info.clone()); let backup_service_info = BackupServiceInfo { public_key: bogus_ed25519_public_key(), }; contract .node_migrations .set_backup_service_info(account_id.clone(), backup_service_info.clone()); expected_vals.insert( account_id.clone(), (Some(backup_service_info), Some(destination_node_info)), ); } let destination_node_info = gen_random_destination_info(); let non_participant_account_id = gen_account_id(); contract.node_migrations.set_destination_node_info( non_participant_account_id.clone(), destination_node_info.clone(), ); let backup_service_info = BackupServiceInfo { public_key: bogus_ed25519_public_key(), }; contract .node_migrations .set_backup_service_info(non_participant_account_id.clone(), backup_service_info); contract .cleanup_orphaned_node_migrations() .expect("Cleanup should succeed for valid migrations"); let result = contract.migration_info(); assert_eq!(result, expected_vals); } fn propose_and_vote( contract: &mut MpcContract, update: Update, expected_update_id: u64, ) -> Vec { let update_id = contract.proposed_updates.propose(update.clone()); assert_eq!(update_id.0, expected_update_id); // generate two accounts for voting let account_id_0 = gen_account_id(); let account_id_1 = gen_account_id(); contract .proposed_updates .vote(&update_id, account_id_0.clone()) .unwrap(); contract .proposed_updates .vote(&update_id, account_id_1.clone()) .unwrap(); let mut expected_votes = vec![account_id_0.clone(), account_id_1.clone()]; expected_votes.sort(); expected_votes } /// Test helper struct that combines update metadata with its votes for convenient comparison. /// Used to convert BTreeMap-based [`ProposedUpdates`] into a sortable vector format for assertions. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] struct TestUpdate { update_id: u64, update_hash: dtos::UpdateHash, votes: Vec, } impl TestUpdate { fn from_proposed_updates( update_id: u64, update_hash: dtos::UpdateHash, proposed_updates: &dtos::ProposedUpdates, ) -> Self { let votes: Vec = proposed_updates .votes .iter() .filter(|&(_, &uid)| uid == update_id) .map(|(account, _)| account.clone()) .collect(); TestUpdate { update_id, update_hash, votes, } } } fn propose_and_vote_code(expected_update_id: u64, contract: &mut MpcContract) -> TestUpdate { let code: [u8; 1000] = std::array::from_fn(|_| rand::random()); let hash = Sha256::digest(code); let update = Update::Contract(code.into()); let expected_update_hash = dtos::UpdateHash::Code(hash.into()); let expected_votes = propose_and_vote(contract, update, expected_update_id); TestUpdate { update_id: expected_update_id, update_hash: expected_update_hash, votes: expected_votes, } } fn assert_proposed_update_has_expected_voters( proposed_updates: &ProposedUpdates, expected_update_id: UpdateId, expected_voters: &HashSet, ) { let actual_voters: HashSet<_> = proposed_updates.voters().into_iter().collect(); assert_eq!(actual_voters, *expected_voters); let all_updates = proposed_updates.all_updates(); assert_eq!(all_updates.updates.len(), 1); assert!(all_updates.updates.contains_key(&expected_update_id)); let update = all_updates.updates.get(&expected_update_id).unwrap(); assert_matches!(update, dtos::UpdateHash::Code(_)); let actual_voters: HashSet<_> = all_updates .votes .iter() .filter(|&(_, &update_id)| update_id == expected_update_id) .map(|(account, _)| account.clone()) .collect(); assert_eq!(actual_voters, *expected_voters); } fn test_proposed_updates_case_given_state(protocol_contract_state: ProtocolContractState) { let mut contract = MpcContract::new_from_protocol_state(protocol_contract_state); let empty_result = contract.proposed_updates(); assert_eq!(empty_result.votes, BTreeMap::new()); assert_eq!(empty_result.updates, BTreeMap::new()); // Propose and vote for code update let code_update_id = 0; let mut code_update = propose_and_vote_code(code_update_id, &mut contract); // Propose and vote for config update let mut config_update = { let update_config = dummy_config(1); let config_hash = Sha256::digest(serde_json::to_vec(&update_config).unwrap()); let config_update_obj = Update::Config(update_config.clone()); let config_update_id = 1; let config_votes = propose_and_vote(&mut contract, config_update_obj, config_update_id); TestUpdate { update_id: config_update_id, update_hash: dtos::UpdateHash::Config(config_hash.into()), votes: config_votes, } }; // Sort votes for consistent comparison code_update.votes.sort(); config_update.votes.sort(); let mut expected = vec![code_update, config_update]; // sorting to have consistent order expected.sort(); let res = contract.proposed_updates(); // Convert result to vector of TestUpdate for comparison let mut actual: Vec = res .updates .iter() .map(|(update_id, update_hash)| { TestUpdate::from_proposed_updates(*update_id, update_hash.clone(), &res) }) .collect(); // Sort votes within each update actual.iter_mut().for_each(|update| update.votes.sort()); // sorting to have consistent order actual.sort(); assert_eq!(expected, actual); } #[test] pub fn test_proposed_updates_interface_running() { let protocol_contract_state = ProtocolContractState::Running(gen_running_state(NUM_DOMAINS)); test_proposed_updates_case_given_state(protocol_contract_state); } #[test] pub fn test_proposed_updates_interface_resharing() { let protocol_contract_state = ProtocolContractState::Resharing(gen_resharing_state(NUM_DOMAINS).1); test_proposed_updates_case_given_state(protocol_contract_state); } #[test] pub fn test_proposed_updates_interface_initialzing() { let protocol_contract_state = ProtocolContractState::Initializing( gen_initializing_state(NUM_DOMAINS, NUM_GENERATED_DOMAINS).1, ); test_proposed_updates_case_given_state(protocol_contract_state); } #[test] pub fn test_remove_update_vote_running() { let running_state = gen_running_state(NUM_DOMAINS); let participants = running_state.parameters.participants().clone(); let protocol_contract_state = ProtocolContractState::Running(running_state); let mut contract = MpcContract::new_from_protocol_state(protocol_contract_state); // Propose and vote for code update let update_id_u64 = 0; let test_update = propose_and_vote_code(update_id_u64, &mut contract); let update_id = UpdateId::from(update_id_u64); for (account_id, _, _) in participants.participants() { contract .proposed_updates .vote(&update_id, account_id.clone()); let proposed_updates = contract.proposed_updates(); assert_eq!(proposed_updates.updates.len(), 1); assert_eq!( *proposed_updates.updates.get(&update_id.0).unwrap(), test_update.update_hash ); // Check that participant vote was added let mut expected_voters: Vec<_> = test_update.votes.to_vec(); expected_voters.push(account_id.clone()); let actual_voters: Vec<_> = proposed_updates .votes .iter() .filter(|&(_, &uid)| uid == update_id.0) .map(|(voter, _)| voter.clone()) .collect(); assert_eq!(actual_voters.len(), expected_voters.len()); for voter in &actual_voters { assert!(expected_voters.contains(voter)); } // Remove the vote testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract.remove_update_vote(); let res = contract.proposed_updates(); assert_eq!(res.updates.len(), 1); // Check that participant vote was removed let actual_voters: Vec<_> = res .votes .iter() .filter(|&(_, &uid)| uid == update_id.0) .map(|(voter, _)| voter.clone()) .collect(); assert_eq!(actual_voters.len(), test_update.votes.len()); for voter in &actual_voters { assert!(test_update.votes.contains(voter)); } } } #[test] #[should_panic(expected = "not a voter")] fn test_remove_update_vote_panics_if_non_voter() { let running_state = gen_running_state(NUM_DOMAINS); let protocol_contract_state = ProtocolContractState::Running(running_state); let mut contract = MpcContract::new_from_protocol_state(protocol_contract_state); // Propose and vote for code update let update_id = 0; let test_update = propose_and_vote_code(update_id, &mut contract); let mut rng = rand::rngs::StdRng::seed_from_u64(42); let account_id = test_update.votes.choose(&mut rng).unwrap(); let account_id: AccountId = account_id.clone(); testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id) .build() ); contract.remove_update_vote(); } #[test] #[should_panic(expected = "protocol must be in running state")] pub fn test_remove_update_vote_resharing() { let protocol_contract_state = ProtocolContractState::Resharing(gen_resharing_state(NUM_DOMAINS).1); let mut contract = MpcContract::new_from_protocol_state(protocol_contract_state); let account_id = gen_account_id(); testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id) .build() ); contract.remove_update_vote(); } #[test] #[should_panic(expected = "protocol must be in running state")] pub fn test_remove_update_vote_initializing() { let protocol_contract_state = ProtocolContractState::Initializing( gen_initializing_state(NUM_DOMAINS, NUM_GENERATED_DOMAINS).1, ); let mut contract = MpcContract::new_from_protocol_state(protocol_contract_state); let account_id = gen_account_id(); testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id) .build() ); contract.remove_update_vote(); } /// Test that `vote_update` correctly filters out non-participant votes when checking threshold. /// /// This is a regression test for a bug where votes from accounts that were no longer /// participants (e.g., after resharing) were still counted toward the update threshold. /// /// The test verifies that only votes from current participants are counted: /// - With threshold=2 and 3 participants, we need 2 valid participant votes /// - Adding 2 non-participant votes + 1 participant vote should NOT meet threshold (returns false) /// - Adding a 2nd participant vote should meet threshold (returns true) #[test] pub fn test_vote_update_filters_non_participant_votes() { // given: a running state with 3 participants and threshold of 2 let mut running_state = gen_running_state(1); running_state.parameters = ThresholdParameters::new(gen_participants(3), Threshold::new(2)).unwrap(); let participants = running_state.parameters.participants().participants(); let participant_1 = participants[0].0.clone(); let participant_2 = participants[1].0.clone(); let mut contract = MpcContract::new_from_protocol_state(ProtocolContractState::Running(running_state)); let update_id = contract .proposed_updates .propose(Update::Contract([0; 1000].into())); // given: 2 non-participant votes + 1 participant vote (simulating old voters from before resharing) contract.proposed_updates.vote(&update_id, gen_account_id()); contract.proposed_updates.vote(&update_id, gen_account_id()); contract .proposed_updates .vote(&update_id, participant_1.clone()); // when: first participant calls vote_update (only 1 valid participant vote out of 3 total) testing_env!( VMContextBuilder::new() .signer_account_id(participant_1.clone()) .predecessor_account_id(participant_1) .build() ); // then: threshold not met (need 2 valid votes, have only 1) assert!(!contract.vote_update(update_id).unwrap()); // given: a 2nd participant vote is added contract .proposed_updates .vote(&update_id, participant_2.clone()); // when: second participant calls vote_update (2 valid participant votes out of 4 total) testing_env!( VMContextBuilder::new() .signer_account_id(participant_2.clone()) .predecessor_account_id(participant_2) .build() ); // then: threshold met (have 2 valid votes, need 2) assert!(contract.vote_update(update_id).unwrap()); } /// Callers authorized to drive `remove_non_participant_update_votes`. enum AuthorizedCaller { /// The contract calling itself (the cleanup promise spawned after resharing). ContractItself, /// A current participant calling directly. Participant, } /// An authorized caller (the contract itself or a current participant) drives the cleanup, /// leaving only the participant votes (simulating post-resharing cleanup). #[rstest] #[case::contract_itself(AuthorizedCaller::ContractItself)] #[case::participant(AuthorizedCaller::Participant)] fn remove_non_participant_update_votes__should_clean_when_called_by_authorized_caller( #[case] caller_kind: AuthorizedCaller, ) { // Given: a running state with update votes from both participants and non-participants. let running_state = gen_running_state(NUM_DOMAINS); let participants = running_state.parameters.participants().clone(); let mut contract = MpcContract::new_from_protocol_state(ProtocolContractState::Running(running_state)); // propose_and_vote_code adds 2 non-participant votes. let update_id_u64 = 0; let _ = propose_and_vote_code(update_id_u64, &mut contract); let update_id: UpdateId = update_id_u64.into(); // Add votes from 2 current participants. let participants = participants.participants(); let (p1, p2) = (participants[0].0.clone(), participants[1].0.clone()); contract.proposed_updates.vote(&update_id, p1.clone()); contract.proposed_updates.vote(&update_id, p2.clone()); // Resolve the caller account for this case. The contract account differs from any // participant account, so the self-call and participant branches are distinct. let caller = match caller_kind { AuthorizedCaller::ContractItself => env::current_account_id(), AuthorizedCaller::Participant => p1.clone(), }; // When: the authorized caller invokes the cleanup directly. testing_env!( VMContextBuilder::new() .current_account_id(env::current_account_id()) .predecessor_account_id(caller.clone()) .signer_account_id(caller) .build() ); contract.remove_non_participant_update_votes().unwrap(); // Then: only the 2 participant votes remain. let participant_voters = HashSet::from([p1, p2]); assert_proposed_update_has_expected_voters( &contract.proposed_updates, update_id, &participant_voters, ); } /// A caller that is neither the contract itself nor a current participant is rejected with /// `NotParticipant`, and the votes are left untouched. #[test] fn remove_non_participant_update_votes__should_reject_unauthorized_caller() { // Given: a running state with update votes from both participants and non-participants. let running_state = gen_running_state(NUM_DOMAINS); let participants = running_state.parameters.participants().clone(); let mut contract = MpcContract::new_from_protocol_state(ProtocolContractState::Running(running_state)); let update_id_u64 = 0; let test_update = propose_and_vote_code(update_id_u64, &mut contract); let update_id: UpdateId = update_id_u64.into(); let non_participants: HashSet = test_update.votes.iter().cloned().collect(); let participants = participants.participants(); let (p1, p2) = (participants[0].0.clone(), participants[1].0.clone()); contract.proposed_updates.vote(&update_id, p1.clone()); contract.proposed_updates.vote(&update_id, p2.clone()); let voters_before: HashSet<_> = [p1, p2].into_iter().chain(non_participants).collect(); // When: an account that is neither the contract nor a participant calls the cleanup. let outsider = gen_account_id(); testing_env!( VMContextBuilder::new() .current_account_id(env::current_account_id()) .predecessor_account_id(outsider.clone()) .signer_account_id(outsider.clone()) .build() ); let result = contract.remove_non_participant_update_votes(); // Then: the call is rejected with NotParticipant and the votes are left untouched. assert_matches!( result, Err(Error::InvalidState(InvalidState::NotParticipant { account_id })) if account_id == outsider ); assert_proposed_update_has_expected_voters( &contract.proposed_updates, update_id, &voters_before, ); } #[rstest] #[case(ProtocolContractState::Running(gen_running_state(NUM_DOMAINS)))] #[case(ProtocolContractState::Resharing(gen_resharing_state(NUM_DOMAINS).1))] #[case(ProtocolContractState::Initializing(gen_initializing_state(NUM_DOMAINS, NUM_GENERATED_DOMAINS).1))] fn test_contract_stores_allowed_hashes(#[case] protocol_state: ProtocolContractState) { const CURRENT_BLOCK_TIME_STAMP: u64 = 10; let mut contract = MpcContract::new_from_protocol_state(protocol_state); let code_hash = [9; 32]; let participant_account_ids: Vec<_> = contract .protocol_state .threshold_parameters() .unwrap() .participants() .participants() .iter() .map(|(account_id, _, _)| account_id.clone()) .collect(); for participant_account_id in participant_account_ids { testing_env!( VMContextBuilder::new() .signer_account_id(participant_account_id.clone()) .predecessor_account_id(participant_account_id.clone()) .block_timestamp(CURRENT_BLOCK_TIME_STAMP) .build() ); contract .vote_code_hash(code_hash.into()) .expect("vote succeeds"); } let allowed_docker_image_hashes: Vec = contract .tee_state .get_allowed_mpc_docker_images(Duration::from_secs(10)) .into_iter() .map(|allowed_image_hash| allowed_image_hash.image_hash) .collect(); assert_eq!( allowed_docker_image_hashes, vec![NodeImageHash::from(code_hash)] ) } /// Tests that [`MpcContract::verify_tee`] triggers resharing and kicks out a participant /// when their attestation is expired. /// /// Verifies the behavior when: /// 1. [`MpcContract::verify_tee`] returns [`TeeValidationResult::Partial`] (some attestations /// expired/invalid) /// 2. The remaining participants meet threshold requirements /// 3. The contract transitions to [`ProtocolContractState::Resharing`] state /// 4. The participant with expired attestation is excluded from the new parameters #[test] fn test_verify_tee_triggers_resharing_and_kickout_on_expired_attestation() { const PARTICIPANT_COUNT: usize = 3; const ATTESTATION_EXPIRY_SECONDS: u64 = 5; const TEE_UPGRADE_DURATION: Duration = Duration::MAX; let participants = gen_participants(PARTICIPANT_COUNT); let parameters = ThresholdParameters::new(participants.clone(), Threshold::new(2)).unwrap(); // Set up contract in Running state let domain_id = DomainId::default(); let domains = vec![DomainConfig { id: domain_id, protocol: Protocol::CaitSith, reconstruction_threshold: ReconstructionThreshold::new(2), purpose: DomainPurpose::Sign, }]; let (pk, _) = make_public_key_for_curve(Curve::Secp256k1, &mut OsRng); let key_for_domain = KeyForDomain { domain_id, key: pk.try_into().unwrap(), attempt: AttemptId::new(), }; let keyset = Keyset::new(EpochId::new(0), vec![key_for_domain.clone()]); let mut contract = MpcContract::init_running( domains.clone(), 1, keyset.clone(), (¶meters).into_dto_type(), None, ) .unwrap(); assert_matches!(contract.protocol_state, ProtocolContractState::Running(_)); // Get participant info for the target (last participant) let participant_list: Vec<_> = participants.participants().to_vec(); let (target_account_id, _, target_participant_info) = &participant_list[2]; // Replace the target's attestation with an expired one let node_id = NodeId { account_id: target_account_id.clone(), tls_public_key: target_participant_info.tls_public_key.clone(), account_public_key: bogus_ed25519_public_key(), }; let expiring_attestation = MpcAttestation::Mock(MpcMockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(ATTESTATION_EXPIRY_SECONDS), expected_measurements: None, }); contract .tee_state .add_participant(node_id, expiring_attestation, TEE_UPGRADE_DURATION) .expect("mock attestation is not yet expired and valid"); // Capture the running state before verify_tee for comparison let ProtocolContractState::Running(running_state_before) = &contract.protocol_state else { panic!("expected Running state"); }; let running_state_before = running_state_before.clone(); // Set time to exact expiry boundary let (first_account_id, _, _) = &participant_list[0]; testing_env!( VMContextBuilder::new() .signer_account_id(first_account_id.clone()) .predecessor_account_id(first_account_id.clone()) .block_timestamp(ATTESTATION_EXPIRY_SECONDS * 1_000_000_000) // nanoseconds .build() ); // Call verify_tee - should trigger resharing let result = contract.verify_tee(); assert_matches!( result, Ok(true), "verify_tee should return Ok(true) when threshold is met" ); // Verify contract transitioned to Resharing state let ProtocolContractState::Resharing(resharing_state) = &contract.protocol_state else { panic!( "expected Resharing state, got {:?}", contract.protocol_state ); }; // Build expected participants: exclude the target (participant 2) who has expired attestation let expected_participants = Participants::init( ParticipantId(PARTICIPANT_COUNT as u32), participant_list[0..2] .iter() .map(|(acc, id, info)| (acc.clone(), *id, info.clone())) .collect(), ); let expected_params = ThresholdParameters::new(expected_participants, parameters.threshold()).unwrap(); let expected_resharing_state = ResharingContractState { previous_running_state: running_state_before, reshared_keys: Vec::new(), resharing_key: KeyEvent::new( keyset.epoch_id.next(), domains[0].clone(), expected_params, ), cancellation_requests: HashSet::new(), per_domain_thresholds: BTreeMap::new(), }; assert_eq!(*resharing_state, expected_resharing_state); } /// Tests that [`MpcContract::verify_tee`] refuses to reshare when a TEE /// kickout would leave fewer participants than the threshold relation requires. /// The contract stays Running and stops accepting requests. #[test] fn verify_tee__should_refuse_kickout_when_remaining_breaks_threshold_relation() { const PARTICIPANT_COUNT: usize = 5; const ATTESTATION_EXPIRY_SECONDS: u64 = 5; const TEE_UPGRADE_DURATION: Duration = Duration::MAX; // Given: 5 participants, GovernanceThreshold 5, and one domain whose // reconstruction threshold is 5 (every participant is needed to sign). Dropping // to 4 participants would leave the GovernanceThreshold above the participant // count, breaking the threshold relation. let participants = gen_participants(PARTICIPANT_COUNT); let parameters = ThresholdParameters::new( participants.clone(), Threshold::new( u64::try_from(PARTICIPANT_COUNT).expect("participant count fits in u64"), ), ) .unwrap(); let domain_id = DomainId::default(); let domains = vec![DomainConfig { id: domain_id, protocol: Protocol::CaitSith, reconstruction_threshold: ReconstructionThreshold::new(5), purpose: DomainPurpose::Sign, }]; let (pk, _) = make_public_key_for_curve(Curve::Secp256k1, &mut OsRng); let keyset = Keyset::new( EpochId::new(0), vec![KeyForDomain { domain_id, key: pk.try_into().unwrap(), attempt: AttemptId::new(), }], ); let mut contract = MpcContract::init_running(domains, 1, keyset, (¶meters).into_dto_type(), None) .unwrap(); // Expire the last participant's attestation so a kickout drops the set to 4. let participant_list: Vec<_> = participants.participants().to_vec(); let (target_account_id, _, target_participant_info) = &participant_list[PARTICIPANT_COUNT - 1]; let node_id = NodeId { account_id: target_account_id.clone(), tls_public_key: target_participant_info.tls_public_key.clone(), account_public_key: bogus_ed25519_public_key(), }; let expiring_attestation = MpcAttestation::Mock(MpcMockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(ATTESTATION_EXPIRY_SECONDS), expected_measurements: None, }); contract .tee_state .add_participant(node_id, expiring_attestation, TEE_UPGRADE_DURATION) .expect("mock attestation is not yet expired and valid"); let (first_account_id, _, _) = &participant_list[0]; testing_env!( VMContextBuilder::new() .signer_account_id(first_account_id.clone()) .predecessor_account_id(first_account_id.clone()) .block_timestamp(ATTESTATION_EXPIRY_SECONDS * 1_000_000_000) // nanoseconds .build() ); // When let result = contract.verify_tee(); // Then: with only 4 surviving participants the GovernanceThreshold of 5 would // exceed the participant count, breaking the threshold relation, so verify_tee // refuses to reshare, stays Running, and stops accepting requests. assert_matches!(result, Ok(false)); assert_matches!(contract.protocol_state, ProtocolContractState::Running(_)); assert!(!contract.accept_requests); } /// Sets up a complete TEE test environment with contract, accounts, mock dstack attestation, TLS key and the node's near public key. /// This is a helper function that provides all the common components needed for TEE-related tests. fn setup_tee_test() -> ( MpcContract, Vec, Attestation, dtos::Ed25519PublicKey, DockerImageHash, near_sdk::PublicKey, ) { let (_context, contract, _secret_key) = basic_setup(Curve::Bls12381, &mut OsRng); let participant_account_ids: Vec<_> = contract .protocol_state .threshold_parameters() .unwrap() .participants() .participants() .iter() .map(|(account_id, _, _)| account_id.clone()) .collect(); let attestation = mock_dto_dstack_attestation(); let tls_key = p2p_tls_key().into(); let mpc_hash = image_digest(); let near_public_key = near_account_key(); ( contract, participant_account_ids, attestation, tls_key, mpc_hash, near_public_key, ) } /// Sets up a contract with an approved MPC hash by having the participants vote for it. /// Also adds the legacy launcher image hash so that compose hashes are derived correctly. /// This is a helper function commonly used in tests that require pre-approved hashes. fn setup_approved_mpc_hash( contract: &mut MpcContract, participant_account_ids: &[near_sdk::AccountId], mpc_hash: &DockerImageHash, block_timestamp_ns: u64, ) { // Add the legacy launcher image first, so that compose hashes are derived // when the MPC hash is voted in. setup_approved_launcher_hash(contract, participant_account_ids, block_timestamp_ns); for participant_account_id in participant_account_ids { testing_env!( VMContextBuilder::new() .signer_account_id(participant_account_id.clone()) .predecessor_account_id(participant_account_id.clone()) .block_timestamp(block_timestamp_ns) .build() ); contract.vote_code_hash(*mpc_hash).expect("vote succeeds"); } } /// Adds the launcher image hash from test attestation assets. /// The hash is extracted from `test-utils/assets/launcher_image_compose.yaml`. fn setup_approved_launcher_hash( contract: &mut MpcContract, participant_account_ids: &[near_sdk::AccountId], block_timestamp_ns: u64, ) { let launcher_hash = launcher_image_hash(); for participant_account_id in participant_account_ids { testing_env!( VMContextBuilder::new() .signer_account_id(participant_account_id.clone()) .predecessor_account_id(participant_account_id.clone()) .block_timestamp(block_timestamp_ns) .build() ); contract .vote_add_launcher_hash(launcher_hash) .expect("launcher vote succeeds"); } } /// Adds the default OS measurements so that Dstack attestation verification passes. fn setup_approved_measurements( contract: &mut MpcContract, participant_account_ids: &[near_sdk::AccountId], block_timestamp_ns: u64, ) { for measurement in mpc_attestation::attestation::default_measurements() { let contract_measurement = ContractExpectedMeasurements::from(*measurement); for participant_account_id in participant_account_ids { testing_env!( VMContextBuilder::new() .signer_account_id(participant_account_id.clone()) .predecessor_account_id(participant_account_id.clone()) .block_timestamp(block_timestamp_ns) .build() ); contract .vote_add_os_measurement(contract_measurement.clone()) .expect("measurement vote succeeds"); } } } /// **Test method with matching measurements** - Tests that participant info submission succeeds with the test-only method. /// Unlike the test above, this one has an approved MPC hash. It uses the test method with custom measurements that match /// the attestation data. #[test] fn test_submit_participant_info_succeeds_with_valid_dstack_attestation() { // given let ( mut contract, participant_account_ids, attestation, tls_key, mpc_hash, near_public_key, ) = setup_tee_test(); let block_timestamp_ns = VALID_ATTESTATION_TIMESTAMP * 1_000_000_000; // when setup_approved_mpc_hash( &mut contract, &participant_account_ids, &mpc_hash, block_timestamp_ns, ); setup_approved_measurements(&mut contract, &participant_account_ids, block_timestamp_ns); let account_id = participant_account_ids[0].clone(); testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .signer_account_pk(near_public_key.clone()) .attached_deposit(NearToken::from_near(1)) .block_timestamp(block_timestamp_ns) .build() ); let result = contract.submit_participant_info(attestation, tls_key); // then assert_matches::assert_matches!(result, Ok(())); } /// Note - this test uses attestation data from a real MPC node. After Any change to the expected contract measurement, /test-utils/assets need to be updated. /// see crates/test-utils/assets/README.md for details. /// **No MPC hash approval** - Tests that participant info submission fails when no MPC hash has been approved yet. /// This verifies the prerequisite step: the contract requires MPC hash approval before accepting any participant TEE information. #[test] fn test_submit_participant_info_fails_without_approved_mpc_hash() { // given let ( mut contract, participant_account_ids, attestation, tls_key, _mpc_hash, near_public_key, ) = setup_tee_test(); let block_timestamp_ns = VALID_ATTESTATION_TIMESTAMP * 1_000_000_000; // when let account_id = participant_account_ids[0].clone(); testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .signer_account_pk(near_public_key.clone()) .attached_deposit(NearToken::from_near(1)) .block_timestamp(block_timestamp_ns) .build() ); let result = contract.submit_participant_info(attestation, tls_key); // then let error_string = result.unwrap_err().to_string(); assert!(error_string .contains("Invalid TEE Remote Attestation: TeeQuoteStatus is invalid: the submitted attestation failed verification, reason: Custom(\"the allowed mpc image hashes list is empty\")"), "Got error: {}", &error_string); } /// **TLS key validation** - Tests that TEE attestation fails when TLS key doesn't match the one in report data. /// Similar to the successful test method case above, but uses a deliberately corrupted TLS key to verify /// that attestation validation properly checks the TLS key embedded in the attestation report. #[test] fn test_tee_attestation_fails_with_invalid_tls_key() { let ( mut contract, participant_account_ids, attestation, tls_key, mpc_hash, near_public_key, ) = setup_tee_test(); let block_timestamp_ns = VALID_ATTESTATION_TIMESTAMP * 1_000_000_000; // when setup_approved_mpc_hash( &mut contract, &participant_account_ids, &mpc_hash, block_timestamp_ns, ); setup_approved_measurements(&mut contract, &participant_account_ids, block_timestamp_ns); // Create invalid TLS key by flipping the last bit let mut invalid_tls_key_bytes = *tls_key.as_bytes(); let last_byte_idx = invalid_tls_key_bytes.len() - 1; invalid_tls_key_bytes[last_byte_idx] ^= 0x01; let invalid_tls_key = Ed25519PublicKey::from(invalid_tls_key_bytes); let account_id = participant_account_ids[0].clone(); testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .signer_account_pk(near_public_key.clone()) .attached_deposit(NearToken::from_near(1)) .block_timestamp(block_timestamp_ns) .build() ); let result = contract.submit_participant_info(attestation, invalid_tls_key); // then let error_string = result.unwrap_err().to_string(); assert!(error_string .contains("Invalid TEE Remote Attestation: TeeQuoteStatus is invalid: the submitted attestation failed verification, reason: WrongHash { name: \"report_data\""), "Got error: {}", &error_string); } fn make_launcher_hash(byte: u8) -> LauncherImageHash { LauncherImageHash::from([byte; 32]) } #[test] fn test_vote_add_launcher_hash_reaches_threshold() { let (mut contract, participants, _first) = setup_tee_test_contract(4, 3); let participant_list = participants.participants(); let launcher_hash = make_launcher_hash(0xAA); // First 2 votes (below threshold of 3) — launcher should NOT be added yet for (account_id, _, _) in &participant_list[0..2] { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_add_launcher_hash(launcher_hash) .expect("vote should succeed"); } assert!( contract.allowed_launcher_image_hashes().is_empty(), "launcher hash should not be added before threshold" ); // 3rd vote reaches threshold — launcher should be added let (account_id, _, _) = &participant_list[2]; testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_add_launcher_hash(launcher_hash) .expect("vote should succeed"); let allowed = contract.allowed_launcher_image_hashes(); assert_eq!(allowed.len(), 1); assert_eq!(allowed[0], launcher_hash); } #[test] fn test_vote_add_launcher_hash_duplicate_vote_is_idempotent() { let (mut contract, participants, _first) = setup_tee_test_contract(4, 3); let participant_list = participants.participants(); let launcher_hash = make_launcher_hash(0xBB); let (account_id, _, _) = &participant_list[0]; testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); // Same participant votes twice — should count as 1 vote contract .vote_add_launcher_hash(launcher_hash) .expect("vote should succeed"); contract .vote_add_launcher_hash(launcher_hash) .expect("duplicate vote should succeed"); assert!( contract.allowed_launcher_image_hashes().is_empty(), "duplicate vote should not double-count" ); } #[test] fn test_vote_remove_launcher_hash_requires_unanimity() { let (mut contract, participants, _first) = setup_tee_test_contract(4, 3); let participant_list = participants.participants(); let launcher_hash = make_launcher_hash(0xCC); let launcher_hash_2 = make_launcher_hash(0xDD); // Add two launcher hashes so removal of one doesn't hit the "last entry" guard for hash in [launcher_hash, launcher_hash_2] { for (account_id, _, _) in participant_list { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_add_launcher_hash(hash) .expect("add vote should succeed"); } } assert_eq!(contract.allowed_launcher_image_hashes().len(), 2); // Now vote to remove — first 3 votes (not all 4) should NOT remove for (account_id, _, _) in &participant_list[0..3] { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_remove_launcher_hash(launcher_hash) .expect("remove vote should succeed"); } assert_eq!( contract.allowed_launcher_image_hashes().len(), 2, "launcher hash should not be removed before unanimity" ); // 4th vote — unanimous, should remove let (account_id, _, _) = &participant_list[3]; testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_remove_launcher_hash(launcher_hash) .expect("remove vote should succeed"); assert_eq!( contract.allowed_launcher_image_hashes().len(), 1, "launcher hash should be removed after unanimity" ); } #[test] fn test_cannot_remove_last_launcher_hash() { let (mut contract, participants, _first) = setup_tee_test_contract(4, 3); let participant_list = participants.participants(); let launcher_hash = make_launcher_hash(0xCC); // Add a single launcher hash for (account_id, _, _) in participant_list { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_add_launcher_hash(launcher_hash) .expect("add vote should succeed"); } assert_eq!(contract.allowed_launcher_image_hashes().len(), 1); // All 4 vote to remove — should still not remove because it's the last one for (account_id, _, _) in participant_list { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_remove_launcher_hash(launcher_hash) .expect("remove vote should succeed"); } assert_eq!( contract.allowed_launcher_image_hashes().len(), 1, "last launcher hash should not be removable" ); } #[test] fn test_vote_add_launcher_hash_derives_compose_hashes() { let (mut contract, participants, _first) = setup_tee_test_contract(4, 3); let participant_list = participants.participants(); let launcher_hash = make_launcher_hash(0xDD); // First approve an MPC image hash so compose hashes can be derived let mpc_hash_bytes: [u8; 32] = [0x11; 32]; let mpc_hash = mpc_primitives::hash::NodeImageHash::from(mpc_hash_bytes); let block_ts = 1_000_000_000u64; for (account_id, _, _) in participant_list { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .block_timestamp(block_ts) .build() ); contract .vote_code_hash(mpc_hash) .expect("mpc vote should succeed"); } // Now add a launcher hash — should auto-derive compose hashes for (account_id, _, _) in &participant_list[0..3] { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .block_timestamp(block_ts) .build() ); contract .vote_add_launcher_hash(launcher_hash) .expect("launcher vote should succeed"); } let compose_hashes = contract.allowed_launcher_compose_hashes(); assert_eq!( compose_hashes.len(), 1, "should have 1 compose hash (1 launcher x 1 mpc)" ); } #[test] fn test_allowed_launcher_image_hashes_view_returns_empty_initially() { let (contract, _participants, _first) = setup_tee_test_contract(3, 2); assert!(contract.allowed_launcher_image_hashes().is_empty()); } #[test] fn test_vote_add_launcher_hash_clears_votes_on_success() { let (mut contract, participants, _first) = setup_tee_test_contract(4, 3); let participant_list = participants.participants(); let launcher_hash = make_launcher_hash(0xEE); // Vote with 3 participants to reach threshold for (account_id, _, _) in &participant_list[0..3] { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_add_launcher_hash(launcher_hash) .expect("vote should succeed"); } assert_eq!(contract.allowed_launcher_image_hashes().len(), 1); // Votes should be cleared — voting for a second hash should start from 0 let launcher_hash_2 = make_launcher_hash(0xFF); let (account_id, _, _) = &participant_list[0]; testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_add_launcher_hash(launcher_hash_2) .expect("vote should succeed"); // Only 1 vote for hash_2, should not be added yet assert_eq!( contract.allowed_launcher_image_hashes().len(), 1, "second hash should not be added with only 1 vote" ); } /// Tests the `launcher_hash_votes()` view method: /// 1. Starts empty /// 2. After each vote, reflects the correct count and action (Add) /// 3. After threshold is reached, votes are cleared #[test] fn test_launcher_hash_votes_view() { let (mut contract, participants, _first) = setup_tee_test_contract(4, 3); let participant_list = participants.participants(); let launcher_hash = make_launcher_hash(0xCC); assert!(contract.launcher_hash_votes().vote_by_account.is_empty()); // First vote let (account_0, _, _) = &participant_list[0]; testing_env!( VMContextBuilder::new() .signer_account_id(account_0.clone()) .predecessor_account_id(account_0.clone()) .build() ); contract .vote_add_launcher_hash(launcher_hash) .expect("vote should succeed"); let votes = &contract.launcher_hash_votes().vote_by_account; assert_eq!(votes.len(), 1); let expected_action = LauncherVoteAction::Add(launcher_hash); assert!(votes.values().all(|v| *v == expected_action)); // Second vote let (account_1, _, _) = &participant_list[1]; testing_env!( VMContextBuilder::new() .signer_account_id(account_1.clone()) .predecessor_account_id(account_1.clone()) .build() ); contract .vote_add_launcher_hash(launcher_hash) .expect("vote should succeed"); let votes = &contract.launcher_hash_votes().vote_by_account; assert_eq!(votes.len(), 2); assert!(votes.values().all(|v| *v == expected_action)); // Third vote reaches threshold — votes should be cleared let (account_2, _, _) = &participant_list[2]; testing_env!( VMContextBuilder::new() .signer_account_id(account_2.clone()) .predecessor_account_id(account_2.clone()) .build() ); contract .vote_add_launcher_hash(launcher_hash) .expect("vote should succeed"); assert!( contract.launcher_hash_votes().vote_by_account.is_empty(), "votes should be cleared after threshold reached" ); } /// Tests the `code_hash_votes()` view method: /// 1. Starts empty /// 2. After each vote, reflects the correct participant and hash /// 3. After threshold is reached, votes are cleared #[test] fn test_code_hash_votes_view() { let num_participants = 4; let threshold = 3; let (mut contract, participants, _) = setup_tee_test_contract(num_participants, threshold); let participant_list = participants.participants(); let code_hash = NodeImageHash::from([0xAB; 32]); assert!(contract.code_hash_votes().proposal_by_account.is_empty()); for (i, (account, _, _)) in participant_list[..threshold as usize].iter().enumerate() { testing_env!( VMContextBuilder::new() .signer_account_id(account.clone()) .predecessor_account_id(account.clone()) .build() ); contract .vote_code_hash(code_hash) .expect("vote should succeed"); let votes = &contract.code_hash_votes().proposal_by_account; if i < (threshold - 1) as usize { assert_eq!(votes.len(), i + 1); assert!(votes.values().all(|v| *v == code_hash)); } else { assert!( votes.is_empty(), "votes should be cleared after threshold reached" ); } } } #[test] fn test_new_mpc_image_derives_compose_for_existing_launchers() { let (mut contract, participants, _first) = setup_tee_test_contract(4, 3); let participant_list = participants.participants(); let launcher_hash = make_launcher_hash(0xAA); let block_ts = 1_000_000_000u64; // First approve an MPC image let mpc_hash_1 = mpc_primitives::hash::NodeImageHash::from([0x11; 32]); for (account_id, _, _) in participant_list { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .block_timestamp(block_ts) .build() ); contract .vote_code_hash(mpc_hash_1) .expect("mpc vote should succeed"); } // Add a launcher hash for (account_id, _, _) in &participant_list[0..3] { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .block_timestamp(block_ts) .build() ); contract .vote_add_launcher_hash(launcher_hash) .expect("launcher vote should succeed"); } assert_eq!(contract.allowed_launcher_compose_hashes().len(), 1); // Now vote in a second MPC image — should auto-derive a new compose hash let mpc_hash_2 = mpc_primitives::hash::NodeImageHash::from([0x22; 32]); for (account_id, _, _) in participant_list { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .block_timestamp(block_ts) .build() ); contract .vote_code_hash(mpc_hash_2) .expect("mpc vote 2 should succeed"); } assert_eq!( contract.allowed_launcher_compose_hashes().len(), 2, "should have 2 compose hashes (1 launcher x 2 mpc images)" ); } /// Tests the full launcher compose hash lifecycle with MPC hash expiry: /// 1. Add M1, add L1 → compose: {L1,M1} /// 2. Add M2 → compose: {L1,M1}, {L1,M2} /// 3. Advance time past M2's deadline so M1 is fully expired /// (valid_entries keeps the last expired entry as cutoff, so M1 only /// drops when M2's grace period also passes) /// 4. Stored compose hashes persist — still {L1,M1}, {L1,M2} /// 5. Add L2 → paired only with valid M2, not expired M1 /// 6. Add M3 → paired with both L1 and L2 /// 7. Final: {L1,M1}, {L1,M2}, {L1,M3}, {L2,M2}, {L2,M3} /// Note: {L2,M1} is NOT present since M1 was expired when L2 was added #[test] fn test_launcher_compose_lifecycle_with_mpc_expiry() { let (mut contract, participants, _first) = setup_tee_test_contract(4, 3); let participant_list = participants.participants(); let sec = 1_000_000_000u64; let day = 24 * 60 * 60 * sec; let upgrade_deadline = 7 * day; let t0 = sec; let vote_mpc = |contract: &mut MpcContract, hash: NodeImageHash, ts: u64| { for (account_id, _, _) in participant_list { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .block_timestamp(ts) .build() ); contract .vote_code_hash(hash) .expect("mpc vote should succeed"); } }; let vote_launcher = |contract: &mut MpcContract, hash: LauncherImageHash, ts: u64| { for (account_id, _, _) in &participant_list[0..3] { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .block_timestamp(ts) .build() ); contract .vote_add_launcher_hash(hash) .expect("launcher vote should succeed"); } }; let l1 = make_launcher_hash(0xA1); let l2 = make_launcher_hash(0xA2); let m1 = NodeImageHash::from([0x11; 32]); let m2 = NodeImageHash::from([0x22; 32]); let m3 = NodeImageHash::from([0x33; 32]); vote_mpc(&mut contract, m1, t0); vote_launcher(&mut contract, l1, t0); assert_eq!(contract.allowed_launcher_compose_hashes().len(), 1); let t1 = t0 + day; vote_mpc(&mut contract, m2, t1); assert_eq!(contract.allowed_launcher_compose_hashes().len(), 2); let t2 = t1 + upgrade_deadline + sec; testing_env!( VMContextBuilder::new() .signer_account_id(participant_list[0].0.clone()) .predecessor_account_id(participant_list[0].0.clone()) .block_timestamp(t2) .build() ); assert_eq!( contract.allowed_launcher_compose_hashes().len(), 2, "stored compose hashes persist even after MPC hash expires" ); vote_launcher(&mut contract, l2, t2); assert_eq!( contract.allowed_launcher_compose_hashes().len(), 3, "L2 paired only with valid M2, not expired M1" ); vote_mpc(&mut contract, m3, t2); assert_eq!( contract.allowed_launcher_compose_hashes().len(), 5, "M3 paired with both L1 and L2" ); let compose_hashes = contract.allowed_launcher_compose_hashes(); assert!(compose_hashes.contains(&get_docker_compose_hash(&l1, &m1))); assert!(compose_hashes.contains(&get_docker_compose_hash(&l1, &m2))); assert!(compose_hashes.contains(&get_docker_compose_hash(&l1, &m3))); assert!(compose_hashes.contains(&get_docker_compose_hash(&l2, &m2))); assert!(compose_hashes.contains(&get_docker_compose_hash(&l2, &m3))); assert!(!compose_hashes.contains(&get_docker_compose_hash(&l2, &m1))); } fn make_measurement(byte: u8) -> ContractExpectedMeasurements { ContractExpectedMeasurements { mrtd: MrtdHash::from([byte; 48]), rtmr0: Rtmr0Hash::from([byte.wrapping_add(1); 48]), rtmr1: Rtmr1Hash::from([byte.wrapping_add(2); 48]), rtmr2: Rtmr2Hash::from([byte.wrapping_add(3); 48]), key_provider_event_digest: KeyProviderEventDigest::from([byte.wrapping_add(4); 48]), } } /// Tests that adding an OS measurement requires threshold votes and that /// duplicate measurements are rejected. #[test] fn test_vote_add_os_measurement_threshold() { let (mut contract, participants, _first) = setup_tee_test_contract(4, 3); let participant_list = participants.participants(); let measurement = make_measurement(0xAA); // First 2 votes — below threshold (3) for (account_id, _, _) in &participant_list[0..2] { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_add_os_measurement(measurement.clone()) .expect("add vote should succeed"); } assert!( contract.allowed_os_measurements().is_empty(), "measurement should not be added before threshold" ); // 3rd vote — threshold reached let (account_id, _, _) = &participant_list[2]; testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_add_os_measurement(measurement.clone()) .expect("add vote should succeed"); assert_eq!(contract.allowed_os_measurements().len(), 1); assert_eq!(contract.allowed_os_measurements()[0], measurement); // Voting for the same measurement again should not duplicate for (account_id, _, _) in &participant_list[0..3] { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_add_os_measurement(measurement.clone()) .expect("add vote should succeed"); } assert_eq!( contract.allowed_os_measurements().len(), 1, "duplicate measurement should not be added" ); } /// Tests that removing an OS measurement requires unanimity and that /// the last measurement cannot be removed. #[test] fn test_vote_remove_os_measurement_unanimity() { let (mut contract, participants, _first) = setup_tee_test_contract(4, 3); let participant_list = participants.participants(); let measurement_1 = make_measurement(0xAA); let measurement_2 = make_measurement(0xBB); // Add two measurements for m in [&measurement_1, &measurement_2] { for (account_id, _, _) in participant_list { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_add_os_measurement(m.clone()) .expect("add vote should succeed"); } } assert_eq!(contract.allowed_os_measurements().len(), 2); // 3 votes to remove — not enough (need all 4) for (account_id, _, _) in &participant_list[0..3] { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_remove_os_measurement(measurement_1.clone()) .expect("remove vote should succeed"); } assert_eq!( contract.allowed_os_measurements().len(), 2, "measurement should not be removed before unanimity" ); // 4th vote — unanimous, should remove let (account_id, _, _) = &participant_list[3]; testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_remove_os_measurement(measurement_1.clone()) .expect("remove vote should succeed"); assert_eq!(contract.allowed_os_measurements().len(), 1); assert_eq!(contract.allowed_os_measurements()[0], measurement_2); } /// Tests that the last OS measurement cannot be removed. #[test] fn test_cannot_remove_last_os_measurement() { let (mut contract, participants, _first) = setup_tee_test_contract(4, 3); let participant_list = participants.participants(); let measurement = make_measurement(0xAA); // Add a single measurement for (account_id, _, _) in participant_list { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_add_os_measurement(measurement.clone()) .expect("add vote should succeed"); } assert_eq!(contract.allowed_os_measurements().len(), 1); // All 4 vote to remove — should not remove because it's the last one for (account_id, _, _) in participant_list { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_remove_os_measurement(measurement.clone()) .expect("remove vote should succeed"); } assert_eq!( contract.allowed_os_measurements().len(), 1, "last OS measurement should not be removable" ); } /// Tests the os_measurement_votes view method returns correct vote data. #[test] fn test_os_measurement_votes_view() { let (mut contract, participants, _first) = setup_tee_test_contract(4, 3); let participant_list = participants.participants(); let measurement = make_measurement(0xCC); // Initially empty assert!(contract.os_measurement_votes().vote_by_account.is_empty()); // Cast one vote let (account_id, _, _) = &participant_list[0]; testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_add_os_measurement(measurement.clone()) .expect("add vote should succeed"); let votes = contract.os_measurement_votes(); assert_eq!(votes.vote_by_account.len(), 1); let (_, action) = votes.vote_by_account.iter().next().unwrap(); assert_eq!(*action, MeasurementVoteAction::Add(measurement)); } /// Tests the allowed_os_measurements view method returns the full structs /// with correct field values after adding measurements. #[test] fn test_allowed_os_measurements_view() { let (mut contract, participants, _first) = setup_tee_test_contract(4, 3); let participant_list = participants.participants(); let measurement_1 = make_measurement(0xAA); let measurement_2 = make_measurement(0xBB); // Initially empty assert!(contract.allowed_os_measurements().is_empty()); // Add first measurement (3 votes = threshold) for (account_id, _, _) in &participant_list[0..3] { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_add_os_measurement(measurement_1.clone()) .expect("add vote should succeed"); } let allowed = contract.allowed_os_measurements(); assert_eq!(allowed.len(), 1); assert_eq!(allowed[0], measurement_1); // Add second measurement for (account_id, _, _) in &participant_list[0..3] { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_add_os_measurement(measurement_2.clone()) .expect("add vote should succeed"); } let allowed = contract.allowed_os_measurements(); assert_eq!(allowed.len(), 2); assert_eq!(allowed[0], measurement_1); assert_eq!(allowed[1], measurement_2); } /// Tests that votes are cleared after a successful measurement add, /// so a subsequent vote starts from scratch. #[test] fn test_vote_add_os_measurement_clears_votes_on_success() { let (mut contract, participants, _first) = setup_tee_test_contract(4, 3); let participant_list = participants.participants(); let measurement = make_measurement(0xAA); // Vote with 3 participants to reach threshold for (account_id, _, _) in &participant_list[0..3] { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_add_os_measurement(measurement.clone()) .expect("vote should succeed"); } assert_eq!(contract.allowed_os_measurements().len(), 1); // Votes should be cleared — voting for a second measurement should start from 0 let measurement_2 = make_measurement(0xBB); let (account_id, _, _) = &participant_list[0]; testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_add_os_measurement(measurement_2.clone()) .expect("vote should succeed"); // Only 1 vote for measurement_2, should not be added yet assert_eq!( contract.allowed_os_measurements().len(), 1, "second measurement should not be added with only 1 vote" ); } /// Tests JSON serialization roundtrip for `ContractExpectedMeasurements`. /// Verifies hex encoding/decoding of 48-byte fields works correctly. #[test] fn test_contract_expected_measurements_json_roundtrip() { let measurement = make_measurement(0xAA); let json = serde_json::to_string(&measurement).expect("serialize to JSON"); let deserialized: ContractExpectedMeasurements = serde_json::from_str(&json).expect("deserialize from JSON"); assert_eq!(measurement, deserialized); // Verify the JSON contains hex strings, not raw byte arrays assert!(json.contains("aa"), "JSON should contain hex-encoded bytes"); assert!( !json.contains('['), "JSON should not contain array brackets" ); } #[cfg(all(feature = "__abi-generate", not(target_arch = "wasm32")))] #[test] fn mpc_contract_borsh_schema_has_not_changed() { let schema = borsh::schema::BorshSchemaContainer::for_type::(); insta::assert_debug_snapshot!(schema); } #[test] fn register_foreign_chain_support__should_store_supported_chains_for_participant() { // Given let running_state = gen_running_state(1); let participants = running_state .parameters .participants() .participants() .clone(); let first_account = participants[0].0.clone(); let mut contract = MpcContract::new_from_protocol_state(ProtocolContractState::Running(running_state)); let foreign_chain_support: dtos::SupportedForeignChains = BTreeSet::from([dtos::ForeignChain::Bitcoin, dtos::ForeignChain::Ethereum]).into(); let _env = Environment::new(None, Some(first_account.clone()), None); // When contract .register_foreign_chain_support(foreign_chain_support.clone()) .expect("register should succeed"); // Then let votes = contract.get_foreign_chain_support_by_node(); assert_eq!(votes.foreign_chain_support_by_node.len(), 1); assert_eq!( votes .foreign_chain_support_by_node .get(&first_account.clone()), Some(&foreign_chain_support) ); } #[test] fn register_foreign_chain_support__should_store_for_previous_participant_during_resharing() { // Given: a contract mid-resharing, and a participant from the previous running set. let (_env, resharing_state) = gen_resharing_state(1); let previous_participant = resharing_state .previous_running_state .parameters .participants() .participants()[0] .0 .clone(); let mut contract = MpcContract::new_from_protocol_state(ProtocolContractState::Resharing(resharing_state)); let foreign_chain_support: dtos::SupportedForeignChains = BTreeSet::from([dtos::ForeignChain::Bitcoin]).into(); let _env = Environment::new(None, Some(previous_participant.clone()), None); // When: that participant registers foreign-chain support outside of Running state. contract .register_foreign_chain_support(foreign_chain_support.clone()) .expect("a previous participant may register during resharing"); // Then: the registration is stored against their account. let stored = contract.get_foreign_chain_support_by_node(); assert_eq!( stored .foreign_chain_support_by_node .get(&previous_participant), Some(&foreign_chain_support) ); } #[test] #[should_panic(expected = "not a voter")] fn register_foreign_chain_config__should_reject_non_participant() { // Given let running_state = gen_running_state(1); let mut contract = MpcContract::new_from_protocol_state(ProtocolContractState::Running(running_state)); let foreign_chain_configuration: dtos::ForeignChainConfiguration = BTreeMap::from([( dtos::ForeignChain::Bitcoin, NonEmptyBTreeSet::new(dtos::RpcProvider { rpc_url: "https://btc.example.com".to_string(), }), )]) .into(); let non_participant = gen_account_id(); let _env = Environment::new(None, Some(non_participant), None); // When / Then: a non-participant is rejected. Registration now authenticates via // `voter_or_panic()`, which panics rather than returning an error. contract .register_foreign_chain_config(foreign_chain_configuration) .expect("non-participant should not be able to register"); } #[test] fn get_supported_foreign_chains__should_return_chains_supported_by_all_participants() { // Given let running_state = gen_running_state(1); let participants = running_state .parameters .participants() .participants() .clone(); let mut contract = MpcContract::new_from_protocol_state(ProtocolContractState::Running(running_state)); // Both participants support Bitcoin and Ethereum let foreign_chain_configuration: dtos::ForeignChainConfiguration = BTreeMap::from([ ( dtos::ForeignChain::Bitcoin, NonEmptyBTreeSet::new(dtos::RpcProvider { rpc_url: "https://btc.example.com".to_string(), }), ), ( dtos::ForeignChain::Ethereum, NonEmptyBTreeSet::new(dtos::RpcProvider { rpc_url: "https://eth.example.com".to_string(), }), ), ]) .into(); for (account_id, _, _) in &participants { let _env = Environment::new(None, Some(account_id.clone()), None); contract .register_foreign_chain_config(foreign_chain_configuration.clone()) .expect("register should succeed"); } // When let result = contract.get_supported_foreign_chains(); // Then assert!(result.contains(&dtos::ForeignChain::Bitcoin)); assert!(result.contains(&dtos::ForeignChain::Ethereum)); assert_eq!(result.len(), 2); } #[test] fn get_supported_foreign_chains__should_exclude_chains_not_supported_by_all() { // Given let running_state = gen_running_state(1); let participants = running_state .parameters .participants() .participants() .clone(); let mut contract = MpcContract::new_from_protocol_state(ProtocolContractState::Running(running_state)); // All participants except the last support Bitcoin + Ethereum for (account_id, _, _) in &participants[..participants.len() - 1] { let _env = Environment::new(None, Some(account_id.clone()), None); let foreign_chain_configuration: dtos::ForeignChainConfiguration = BTreeMap::from([ ( dtos::ForeignChain::Bitcoin, NonEmptyBTreeSet::new(dtos::RpcProvider { rpc_url: "https://btc.example.com".to_string(), }), ), ( dtos::ForeignChain::Ethereum, NonEmptyBTreeSet::new(dtos::RpcProvider { rpc_url: "https://eth.example.com".to_string(), }), ), ]) .into(); contract .register_foreign_chain_config(foreign_chain_configuration) .expect("register should succeed"); } // Last participant supports only Bitcoin { let last = &participants[participants.len() - 1].0; let _env = Environment::new(None, Some(last.clone()), None); let foreign_chain_configuration: dtos::ForeignChainConfiguration = BTreeMap::from([( dtos::ForeignChain::Bitcoin, NonEmptyBTreeSet::new(dtos::RpcProvider { rpc_url: "https://btc.example.com".to_string(), }), )]) .into(); contract .register_foreign_chain_config(foreign_chain_configuration) .expect("register should succeed"); } // When let result = contract.get_supported_foreign_chains(); // Then - only Bitcoin is unanimous assert!(result.contains(&dtos::ForeignChain::Bitcoin)); assert!(!result.contains(&dtos::ForeignChain::Ethereum)); assert_eq!(result.len(), 1); } #[test] fn get_supported_foreign_chains__different_rpc_urls_per_participant_is_fine() { // Given let running_state = gen_running_state(1); let participants = running_state .parameters .participants() .participants() .clone(); let mut contract = MpcContract::new_from_protocol_state(ProtocolContractState::Running(running_state)); // Each participant registers the same chains but with different RPC URLs for (i, (account_id, _, _)) in participants.iter().enumerate() { let _env = Environment::new(None, Some(account_id.clone()), None); let foreign_chain_configuration: dtos::ForeignChainConfiguration = BTreeMap::from([ ( dtos::ForeignChain::Bitcoin, NonEmptyBTreeSet::new(dtos::RpcProvider { rpc_url: format!("https://btc-node-{i}.example.com"), }), ), ( dtos::ForeignChain::Ethereum, NonEmptyBTreeSet::new(dtos::RpcProvider { rpc_url: format!("https://eth-node-{i}.example.com"), }), ), ]) .into(); contract .register_foreign_chain_config(foreign_chain_configuration) .expect("register should succeed"); } // When let result = contract.get_supported_foreign_chains(); // Then — both chains are supported despite different RPC URLs assert!(result.contains(&dtos::ForeignChain::Bitcoin)); assert!(result.contains(&dtos::ForeignChain::Ethereum)); assert_eq!(result.len(), 2); } #[test] fn get_supported_foreign_chains__should_return_empty_when_no_votes() { // Given let running_state = gen_running_state(1); let contract = MpcContract::new_from_protocol_state(ProtocolContractState::Running(running_state)); // When let result = contract.get_supported_foreign_chains(); // Then assert!(result.is_empty()); } #[test] fn vote_update_foreign_chain_providers__should_apply_chain_and_return_it_when_threshold_reached() { // Given: a running contract with 4 participants and signing threshold 3. let (_context, mut contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); let participant_account_ids: Vec = contract .protocol_state .threshold_parameters() .unwrap() .participants() .participants() .iter() .map(|(account_id, _, _)| account_id.clone()) .collect(); assert_eq!(participant_account_ids.len(), 4); let chain = dtos::ForeignChain::Ethereum; let entry = dtos::ChainEntry { providers: NonEmptyBTreeMap::new( dtos::ProviderId("alchemy".to_string()), dtos::ProviderConfig { base_url: "https://alchemy.example.com".to_string(), auth_scheme: dtos::AuthScheme::None, chain_routing: dtos::ChainRouting::Embedded, }, ), quorum: 1, }; let batch = NonEmptyBTreeMap::new(chain, entry.clone()); let vote_as = |contract: &mut MpcContract, account_id: &AccountId| { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_update_foreign_chain_providers(batch.clone()) .expect("vote should succeed") }; // When: first two participants vote (count = 2, below threshold 3). let applied_p0 = vote_as(&mut contract, &participant_account_ids[0]); let applied_p1 = vote_as(&mut contract, &participant_account_ids[1]); // Then: nothing applied yet. assert!(applied_p0.is_empty(), "1st vote should not apply"); assert!(applied_p1.is_empty(), "2nd vote should not apply"); assert!(contract.allowed_foreign_chain_providers().is_empty()); // When: third participant votes (crosses threshold). let applied_p2 = vote_as(&mut contract, &participant_account_ids[2]); // Then: the chain is reported as applied this call and is now stored. assert_eq!(applied_p2, vec![chain]); let stored = contract.allowed_foreign_chain_providers(); assert_eq!(stored.len(), 1); assert_eq!(stored.get(&chain), Some(&entry)); } #[test] #[should_panic(expected = "not a voter")] fn vote_update_foreign_chain_providers__should_panic_when_caller_is_not_a_participant() { // Given: a running contract whose participant set does NOT contain `non_participant`. let (_context, mut contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); let non_participant = gen_account_id(); let batch = NonEmptyBTreeMap::new( dtos::ForeignChain::Ethereum, dtos::ChainEntry { providers: NonEmptyBTreeMap::new( dtos::ProviderId("alchemy".to_string()), dtos::ProviderConfig { base_url: "https://alchemy.example.com".to_string(), auth_scheme: dtos::AuthScheme::None, chain_routing: dtos::ChainRouting::Embedded, }, ), quorum: 1, }, ); // When: a non-participant attempts to vote — voter_or_panic should reject. testing_env!( VMContextBuilder::new() .signer_account_id(non_participant.clone()) .predecessor_account_id(non_participant) .build() ); let _ = contract.vote_update_foreign_chain_providers(batch); } fn participant_account_ids(contract: &MpcContract) -> Vec { contract .protocol_state .threshold_parameters() .unwrap() .participants() .participants() .iter() .map(|(account_id, _, _)| account_id.clone()) .collect() } /// Votes `chain` into the on-chain RPC whitelist using the signing threshold of /// participants (so the chain becomes whitelisted). fn whitelist_chain(contract: &mut MpcContract, chain: dtos::ForeignChain) { let entry = dtos::ChainEntry { providers: NonEmptyBTreeMap::new( dtos::ProviderId("alchemy".to_string()), dtos::ProviderConfig { base_url: "https://provider.example.com".to_string(), auth_scheme: dtos::AuthScheme::None, chain_routing: dtos::ChainRouting::Embedded, }, ), quorum: 1, }; let batch = NonEmptyBTreeMap::new(chain, entry); let threshold = contract.threshold().unwrap().value() as usize; for account_id in participant_account_ids(contract).iter().take(threshold) { testing_env!( VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .build() ); contract .vote_update_foreign_chain_providers(batch.clone()) .expect("vote should succeed"); } } fn register_foreign_chain_config( contract: &mut MpcContract, account_id: &AccountId, chains: impl IntoIterator, ) { let foreign_chains_config: dtos::ForeignChainsConfig = chains.into_iter().collect::>().into(); // In mock setup, account_public_key == tls_public_key. let tls_key = contract .protocol_state .threshold_parameters() .unwrap() .participants() .info(account_id) .expect("account must be a participant") .tls_public_key .clone(); let mut env = Environment::new(None, Some(account_id.clone()), None); env.set_pk(near_sdk::PublicKey::from(tls_key)); contract .register_foreign_chains_config(foreign_chains_config) .expect("register should succeed"); } #[test] // Setup with 4 participants, first 3 supporting 4 chains, 4th one supports only 2. // Node operator of 4th node spins up new node, and registers config that supports all 4 chains. // Available chains should still be 2. // Node operator of 4th node migrates node to new node, and new node becomes participant, // then all 4 chains should be supported. fn get_available_foreign_chains__should_not_count_non_participant_node_config() { // Given: 4 participants, threshold 4 (all must agree); 4 chains whitelisted. let (_context, mut contract, _) = basic_setup_with_protocol(Protocol::CaitSith, DomainPurpose::ForeignTx, &mut OsRng); let all_chains = [ dtos::ForeignChain::Bitcoin, dtos::ForeignChain::Ethereum, dtos::ForeignChain::Solana, dtos::ForeignChain::Bnb, ]; let partial_chains = [dtos::ForeignChain::Bitcoin, dtos::ForeignChain::Ethereum]; for chain in all_chains { whitelist_chain(&mut contract, chain); } // Raise both the governance threshold and ForeignTx domain threshold to 4 so that all // participants must cover a chain for it to be available. { let ProtocolContractState::Running(ref mut state) = contract.protocol_state else { panic!("expected Running"); }; state.parameters = ThresholdParameters::new( state.parameters.participants().clone(), Threshold::new(4), ) .unwrap(); for domain in state.domains.domains_mut() { if domain.purpose == DomainPurpose::ForeignTx { domain.reconstruction_threshold = ReconstructionThreshold::new(4); } } } let participants = contract .protocol_state .threshold_parameters() .unwrap() .participants() .clone(); let participant_ids = participant_account_ids(&contract); // Nodes 1-3 cover all 4 chains. for account_id in participant_ids.iter().take(3) { register_foreign_chain_config(&mut contract, account_id, all_chains); } // Node 4 (active participant) only covers 2 chains. let operator4 = &participant_ids[3]; register_foreign_chain_config(&mut contract, operator4, partial_chains); // Operator 4's new migration node (not yet a participant) covers all 4 chains and registers. let new_tls_key = dtos::Ed25519PublicKey([99u8; 32]); let new_signer_pk = dtos::Ed25519PublicKey([98u8; 32]); contract.tee_state.stored_attestations.insert( new_tls_key.clone(), NodeAttestation { node_id: NodeId { account_id: operator4.clone(), tls_public_key: new_tls_key.clone(), account_public_key: new_signer_pk.clone(), }, verified_attestation: VerifiedAttestation::Mock(MpcMockAttestation::Valid), }, ); let foreign_chains_config: dtos::ForeignChainsConfig = all_chains.into_iter().collect::>().into(); let mut env = Environment::new(None, Some(operator4.clone()), None); env.set_pk(near_sdk::PublicKey::from(new_signer_pk)); contract .register_foreign_chains_config(foreign_chains_config) .expect("new node of same operator should be able to register"); // Then: only 2 chains available — new node's config doesn't count since it's not a participant. let available = contract.get_available_foreign_chains(); assert_eq!(available.len(), 2); assert!(available.contains(&dtos::ForeignChain::Bitcoin)); assert!(available.contains(&dtos::ForeignChain::Ethereum)); // When: migration completes — participant 4's TLS key is updated to the new node. let old_info = participants.info(operator4).unwrap().clone(); let new_info = ParticipantInfo { tls_public_key: new_tls_key, ..old_info }; { let ProtocolContractState::Running(ref mut state) = contract.protocol_state else { panic!("expected Running"); }; // Reconstruct ThresholdParameters with the updated participants. let mut updated_participants = state.parameters.participants().clone(); updated_participants .update_info(operator4.clone(), new_info) .unwrap(); state.parameters = ThresholdParameters::new(updated_participants, Threshold::new(4)).unwrap(); } contract.recompute_available_foreign_chains(); // Then: all 4 chains are now available. let available = contract.get_available_foreign_chains(); assert_eq!(available.len(), 4); } #[test] fn conclude_node_migration__should_recompute_available_foreign_chains() { // Given: 4 participants, threshold 4; 4 chains whitelisted. // Node 4 supports only 2 chains. // Node 4's operator migrates to a new node that supports all 4 chains. // After conclude_node_migration the cache must reflect 4 chains without a manual recompute. let (_context, mut contract, _) = basic_setup_with_protocol(Protocol::CaitSith, DomainPurpose::ForeignTx, &mut OsRng); let all_chains = [ dtos::ForeignChain::Bitcoin, dtos::ForeignChain::Ethereum, dtos::ForeignChain::Solana, dtos::ForeignChain::Bnb, ]; let partial_chains = [dtos::ForeignChain::Bitcoin, dtos::ForeignChain::Ethereum]; for chain in all_chains { whitelist_chain(&mut contract, chain); } { let ProtocolContractState::Running(ref mut state) = contract.protocol_state else { panic!("expected Running"); }; state.parameters = ThresholdParameters::new( state.parameters.participants().clone(), Threshold::new(4), ) .unwrap(); for domain in state.domains.domains_mut() { if domain.purpose == DomainPurpose::ForeignTx { domain.reconstruction_threshold = ReconstructionThreshold::new(4); } } } let participant_ids = participant_account_ids(&contract); for account_id in participant_ids.iter().take(3) { register_foreign_chain_config(&mut contract, account_id, all_chains); } let operator4 = &participant_ids[3]; register_foreign_chain_config(&mut contract, operator4, partial_chains); let available = contract.get_available_foreign_chains(); assert_eq!( available.len(), 2, "only partial chains available before migration" ); // When: operator 4 migrates to a new node that supports all 4 chains. let (_, new_participant_info) = gen_participant(100); let new_tls_key = new_participant_info.tls_public_key.clone(); let new_signer_pk = bogus_ed25519_public_key(); let new_signer_near_pk = near_sdk::PublicKey::from(new_signer_pk.clone()); let destination_node_info = DestinationNodeInfo { signer_account_pk: new_signer_pk.clone(), destination_node_info: new_participant_info.into(), }; // Add attestation for the new node (mirrors what ConcludeNodeMigrationTestSetup::setup does). contract .tee_state .add_participant( NodeId { account_id: operator4.clone(), tls_public_key: new_tls_key.clone(), account_public_key: new_signer_pk.clone(), }, mpc_attestation::attestation::Attestation::Mock(MpcMockAttestation::Valid), Duration::from_secs(contract.config.tee_upgrade_deadline_duration_seconds), ) .expect("attestation insertion should succeed"); // New node pre-registers its config. let mut env = Environment::new(None, Some(operator4.clone()), None); env.set_pk(new_signer_near_pk.clone()); let full_config: dtos::ForeignChainsConfig = all_chains.into_iter().collect::>().into(); contract .register_foreign_chains_config(full_config) .expect("new node should be able to register"); let keyset = match &contract.protocol_state { ProtocolContractState::Running(s) => s.keyset.clone(), _ => panic!("expected Running"), }; contract .node_migrations .set_destination_node_info(operator4.clone(), destination_node_info); let mut env = Environment::new(None, Some(operator4.clone()), None); env.set_pk(new_signer_near_pk); contract .conclude_node_migration(&keyset) .expect("migration should succeed"); // Then: all 4 chains available — no manual recompute needed. let available = contract.get_available_foreign_chains(); assert_eq!(available.len(), 4); } #[test] fn get_available_foreign_chains__should_include_chain_when_at_least_threshold_participants_cover_it() { // Given: 4 participants, signing threshold 3; Bitcoin whitelisted. let (_context, mut contract, _) = basic_setup_with_protocol(Protocol::CaitSith, DomainPurpose::ForeignTx, &mut OsRng); let participants = participant_account_ids(&contract); whitelist_chain(&mut contract, dtos::ForeignChain::Bitcoin); // When: exactly the threshold (3) of 4 participants cover Bitcoin — one node does not. for account_id in participants.iter().take(3) { register_foreign_chain_config(&mut contract, account_id, [dtos::ForeignChain::Bitcoin]); } // Then: Bitcoin is available. A single non-covering node cannot take it down — the // regression the legacy intersection rule had. let available = contract.get_available_foreign_chains(); assert!(available.contains(&dtos::ForeignChain::Bitcoin)); assert_eq!(available.len(), 1); } #[test] fn get_available_foreign_chains__should_exclude_chain_when_fewer_than_threshold_cover_it() { // Given: 4 participants, threshold 3; Bitcoin whitelisted. let (_context, mut contract, _) = basic_setup_with_protocol(Protocol::CaitSith, DomainPurpose::ForeignTx, &mut OsRng); let participants = participant_account_ids(&contract); whitelist_chain(&mut contract, dtos::ForeignChain::Bitcoin); // When: only 2 of 4 (< threshold) cover Bitcoin. for account_id in participants.iter().take(2) { register_foreign_chain_config(&mut contract, account_id, [dtos::ForeignChain::Bitcoin]); } // Then: Bitcoin is not available. let available = contract.get_available_foreign_chains(); assert!(!available.contains(&dtos::ForeignChain::Bitcoin)); assert!(available.is_empty()); } #[test] fn get_available_foreign_chains__should_exclude_chain_that_is_covered_but_not_whitelisted() { // Given: 4 participants, threshold 3; Bitcoin is NOT whitelisted. let (_context, mut contract, _) = basic_setup_with_protocol(Protocol::CaitSith, DomainPurpose::ForeignTx, &mut OsRng); let participants = participant_account_ids(&contract); // When: all 4 participants cover Bitcoin. for account_id in &participants { register_foreign_chain_config(&mut contract, account_id, [dtos::ForeignChain::Bitcoin]); } // Then: Bitcoin is still not available — `available` is a subset of `whitelisted`. let available = contract.get_available_foreign_chains(); assert!(available.is_empty()); } #[test] fn get_available_foreign_chains__should_only_include_whitelisted_chains_with_threshold_coverage() { // Given: 4 participants, threshold 3. Bitcoin and Ethereum are whitelisted; Solana is not. let (_context, mut contract, _) = basic_setup_with_protocol(Protocol::CaitSith, DomainPurpose::ForeignTx, &mut OsRng); let participants = participant_account_ids(&contract); whitelist_chain(&mut contract, dtos::ForeignChain::Bitcoin); whitelist_chain(&mut contract, dtos::ForeignChain::Ethereum); // When (each participant registers its full covered set in one call, since a // registration replaces the participant's previously reported set): // - Bitcoin: covered by 3 participants (whitelisted + threshold) -> available. // - Ethereum: covered by 1 participant (whitelisted but under threshold) -> not available. // - Solana: covered by all 4 (threshold met but not whitelisted) -> not available. register_foreign_chain_config( &mut contract, &participants[0], [ dtos::ForeignChain::Bitcoin, dtos::ForeignChain::Ethereum, dtos::ForeignChain::Solana, ], ); register_foreign_chain_config( &mut contract, &participants[1], [dtos::ForeignChain::Bitcoin, dtos::ForeignChain::Solana], ); register_foreign_chain_config( &mut contract, &participants[2], [dtos::ForeignChain::Bitcoin, dtos::ForeignChain::Solana], ); register_foreign_chain_config( &mut contract, &participants[3], [dtos::ForeignChain::Solana], ); // Then: only Bitcoin is available. let available = contract.get_available_foreign_chains(); assert!(available.contains(&dtos::ForeignChain::Bitcoin)); assert!(!available.contains(&dtos::ForeignChain::Ethereum)); assert!(!available.contains(&dtos::ForeignChain::Solana)); assert_eq!(available.len(), 1); } #[test] fn vote_update_foreign_chain_providers__should_populate_available_set_when_whitelisting_covered_chain() { // Given: 4 participants, threshold 3. Bitcoin is NOT yet whitelisted. let (_context, mut contract, _) = basic_setup_with_protocol(Protocol::CaitSith, DomainPurpose::ForeignTx, &mut OsRng); let participants = participant_account_ids(&contract); // Threshold (3) participants already cover Bitcoin — but the chain is not whitelisted, // so the cache must be empty. for account_id in participants.iter().take(3) { register_foreign_chain_config(&mut contract, account_id, [dtos::ForeignChain::Bitcoin]); } assert!(contract.get_available_foreign_chains().is_empty()); // When: whitelist Bitcoin (vote_update_foreign_chain_providers triggers a recompute). whitelist_chain(&mut contract, dtos::ForeignChain::Bitcoin); // Then: cache flips from empty to populated. let available = contract.get_available_foreign_chains(); assert!(available.contains(&dtos::ForeignChain::Bitcoin)); assert_eq!(available.len(), 1); } #[test] fn clean_foreign_chain_data__should_drop_departed_participant_contribution_from_cache() { // Given: 4 participants, threshold 3, Bitcoin whitelisted. // Exactly 3 participants (0, 1, 2) cover Bitcoin → threshold met → available. let (_context, mut contract, _) = basic_setup_with_protocol(Protocol::CaitSith, DomainPurpose::ForeignTx, &mut OsRng); let participants = participant_account_ids(&contract); whitelist_chain(&mut contract, dtos::ForeignChain::Bitcoin); for account_id in participants.iter().take(3) { register_foreign_chain_config(&mut contract, account_id, [dtos::ForeignChain::Bitcoin]); } assert!( contract .get_available_foreign_chains() .contains(&dtos::ForeignChain::Bitcoin) ); // Simulate resharing completion: the new Running state drops participant[2] and keeps // participant[3] (who has not registered any chain). Participant[2]'s registration // entry is still in foreign_chains_configs — this is the stale data that // clean_foreign_chain_data must remove. let (domains, keyset) = { let ProtocolContractState::Running(ref state) = contract.protocol_state else { panic!("expected Running state"); }; (state.domains.clone(), state.keyset.clone()) }; let mut new_participants = { let ProtocolContractState::Running(ref state) = contract.protocol_state else { panic!("expected Running state"); }; state.parameters.participants().clone() }; new_participants.remove(&participants[2]); // New Running: participants {0, 1, 3}, threshold 3. Only 0 and 1 cover Bitcoin → 2 < 3. let new_params = ThresholdParameters::new_unvalidated(new_participants, Threshold::new(3)); contract.protocol_state = ProtocolContractState::Running(RunningContractState::new( domains, keyset, new_params, AddDomainsVotes::default(), )); // When: vote_reshared recomputes the cache, then clean_foreign_chain_data // prunes participant[2]'s stale storage entry. contract.recompute_available_foreign_chains(); contract .clean_foreign_chain_data() .expect("clean should succeed"); // Then: 2 participants cover Bitcoin (< threshold 3) → no longer available. let available = contract.get_available_foreign_chains(); assert!(!available.contains(&dtos::ForeignChain::Bitcoin)); assert!(available.is_empty()); } #[test] fn recompute_available_foreign_chains__should_update_cache_during_resharing() { // Given: Running contract with Bitcoin whitelisted; threshold 3, only 2 participants // registered → Bitcoin not yet available. let (_context, mut contract, _) = basic_setup_with_protocol(Protocol::CaitSith, DomainPurpose::ForeignTx, &mut OsRng); let participants = participant_account_ids(&contract); whitelist_chain(&mut contract, dtos::ForeignChain::Bitcoin); for account_id in participants.iter().take(2) { register_foreign_chain_config(&mut contract, account_id, [dtos::ForeignChain::Bitcoin]); } assert!(contract.get_available_foreign_chains().is_empty()); // Transition to Resharing. Use the same participant set so mocked attestations remain valid. let resharing = { let ProtocolContractState::Running(ref mut state) = contract.protocol_state else { panic!("expected Running state"); }; let proposal = ProposedThresholdParameters::new(state.parameters.clone(), BTreeMap::new()); state .transition_to_resharing_no_checks(&proposal) .expect("contract has at least one domain") }; contract.protocol_state = ProtocolContractState::Resharing(resharing); // When: the 3rd participant (from the old running set) registers during Resharing. register_foreign_chain_config( &mut contract, &participants[2], [dtos::ForeignChain::Bitcoin], ); // Then: cache updated using the embedded previous running-state — Bitcoin now available. assert!( contract .get_available_foreign_chains() .contains(&dtos::ForeignChain::Bitcoin) ); } #[test] fn recompute_available_foreign_chains__should_use_domain_threshold_not_governance_threshold() { // Given: 4 participants, governance threshold=3, ForeignTx domain reconstruction_threshold=2. // Only 2 supporters will register — below governance threshold but meets domain threshold. let contract_account_id = AccountId::from_str("contract_account.near").unwrap(); testing_env!( VMContextBuilder::new() .attached_deposit(NearToken::from_yoctonear(1)) .predecessor_account_id(contract_account_id.clone()) .current_account_id(contract_account_id) .build() ); let domain_id = DomainId::default(); let domains = vec![DomainConfig { id: domain_id, protocol: Protocol::CaitSith, reconstruction_threshold: ReconstructionThreshold::new(2), purpose: DomainPurpose::ForeignTx, }]; let (pk, _sk) = make_public_key_for_curve(Curve::Secp256k1, &mut OsRng); let key_for_domain = KeyForDomain { domain_id, key: pk.try_into().unwrap(), attempt: AttemptId::new(), }; let keyset = Keyset::new(EpochId::new(0), vec![key_for_domain]); let parameters = ThresholdParameters::new(gen_participants(4), Threshold::new(3)).unwrap(); let mut contract = MpcContract::init_running(domains, 1, keyset, (¶meters).into_dto_type(), None) .unwrap(); let participants = participant_account_ids(&contract); whitelist_chain(&mut contract, dtos::ForeignChain::Bitcoin); // When: exactly 2 participants register Bitcoin (meets domain threshold=2, below governance threshold=3). register_foreign_chain_config( &mut contract, &participants[0], [dtos::ForeignChain::Bitcoin], ); register_foreign_chain_config( &mut contract, &participants[1], [dtos::ForeignChain::Bitcoin], ); // Then: Bitcoin is available — the ForeignTx domain threshold (2) is used, not governance (3). assert!( contract .get_available_foreign_chains() .contains(&dtos::ForeignChain::Bitcoin), "chain should be available at domain threshold=2 even though governance threshold=3" ); } #[test] fn recompute_available_foreign_chains__should_use_max_threshold_across_foreign_tx_domains() { // Given // The lower-threshold domain is listed first so a regression to `.find()` would pick // threshold=2, whereas `.max()` across both foreign-tx domains picks threshold=3. let contract_account_id = AccountId::from_str("contract_account.near").unwrap(); testing_env!( VMContextBuilder::new() .attached_deposit(NearToken::from_yoctonear(1)) .predecessor_account_id(contract_account_id.clone()) .current_account_id(contract_account_id) .build() ); let foreign_tx_domain = |id: u64, threshold: u64| DomainConfig { id: DomainId(id), protocol: Protocol::CaitSith, reconstruction_threshold: ReconstructionThreshold::new(threshold), purpose: DomainPurpose::ForeignTx, }; let domains = vec![foreign_tx_domain(0, 2), foreign_tx_domain(1, 3)]; let keys_for_domains = domains .iter() .map(|domain| { let (pk, _sk) = make_public_key_for_curve(Curve::Secp256k1, &mut OsRng); KeyForDomain { domain_id: domain.id, key: pk.try_into().unwrap(), attempt: AttemptId::new(), } }) .collect(); let keyset = Keyset::new(EpochId::new(0), keys_for_domains); let parameters = ThresholdParameters::new(gen_participants(4), Threshold::new(3)).unwrap(); let mut contract = MpcContract::init_running(domains, 2, keyset, (¶meters).into_dto_type(), None) .unwrap(); let participants = participant_account_ids(&contract); whitelist_chain(&mut contract, dtos::ForeignChain::Bitcoin); // When let bitcoin_available = |contract: &MpcContract| { contract .get_available_foreign_chains() .contains(&dtos::ForeignChain::Bitcoin) }; register_foreign_chain_config( &mut contract, &participants[0], [dtos::ForeignChain::Bitcoin], ); register_foreign_chain_config( &mut contract, &participants[1], [dtos::ForeignChain::Bitcoin], ); let available_below_threshold = bitcoin_available(&contract); register_foreign_chain_config( &mut contract, &participants[2], [dtos::ForeignChain::Bitcoin], ); let available_at_threshold = bitcoin_available(&contract); // Then assert!( !available_below_threshold, "2 supporters is below the max foreign-tx threshold (3)" ); assert!( available_at_threshold, "3 supporters meets the max foreign-tx threshold (3)" ); } #[test] fn register_foreign_chains_config__should_succeed_for_new_participant_during_resharing() { // Given: Running contract; transition to Resharing whose proposed set adds a new participant // not present in the old running set. let (_context, mut contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); let (new_account_id, new_info) = gen_participant(100); let mut new_participants = contract .protocol_state .threshold_parameters() .unwrap() .participants() .clone(); new_participants .insert(new_account_id.clone(), new_info) .expect("new participant should be inserted"); let new_params = ThresholdParameters::new(new_participants.clone(), Threshold::new(3)).unwrap(); let new_proposal = ProposedThresholdParameters::new(new_params, BTreeMap::new()); let resharing = { let ProtocolContractState::Running(ref mut state) = contract.protocol_state else { panic!("expected Running state"); }; state .transition_to_resharing_no_checks(&new_proposal) .expect("contract has at least one domain") }; contract.protocol_state = ProtocolContractState::Resharing(resharing); // Provide mocked attestations for every participant in the proposed new set, // including the newly added one. contract.tee_state = TeeState::with_mocked_participant_attestations(&new_participants); // When: the new participant (not in the old running set) registers its foreign chain config. let foreign_chains_config: dtos::ForeignChainsConfig = BTreeSet::from([dtos::ForeignChain::Bitcoin]).into(); let new_tls_key = new_participants .info(&new_account_id) .unwrap() .tls_public_key .clone(); let mut env = Environment::new(None, Some(new_account_id.clone()), None); // Set the signer pk to the new participant's TLS key, which is also its account_public_key // in the mocked attestation, so lookup_node_id_by_signer_pk finds exactly this participant. env.set_pk(near_sdk::PublicKey::from(new_tls_key.clone())); // Then: the call succeeds — new participant is in the proposed set. contract .register_foreign_chains_config(foreign_chains_config) .expect("new participant should be able to register during Resharing"); assert!( contract .foreign_chains .get() .foreign_chains_configs .contains_key(&new_tls_key), "config should be stored under the new participant's TLS key" ); } #[test] fn register_foreign_chains_config__should_allow_two_nodes_from_same_operator_to_register_config() { // Given: Running contract; pick one operator account. let (_context, mut contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); let participants = contract .protocol_state .threshold_parameters() .unwrap() .participants() .clone(); let (operator_account, _, info) = participants.participants().iter().next().unwrap(); let tls_key_a = info.tls_public_key.clone(); // Simulate a second node for the same operator with a distinct TLS key and signer pk. let tls_key_b = dtos::Ed25519PublicKey([99u8; 32]); let signer_pk_b = dtos::Ed25519PublicKey([98u8; 32]); contract.tee_state.stored_attestations.insert( tls_key_b.clone(), NodeAttestation { node_id: NodeId { account_id: operator_account.clone(), tls_public_key: tls_key_b.clone(), account_public_key: signer_pk_b.clone(), }, verified_attestation: VerifiedAttestation::Mock(MpcMockAttestation::Valid), }, ); // When: node A (the registered participant node) registers its config. register_foreign_chain_config( &mut contract, operator_account, [dtos::ForeignChain::Bitcoin], ); // When: node B (the migration candidate, same operator) registers its config. let foreign_chains_config: dtos::ForeignChainsConfig = BTreeSet::from([dtos::ForeignChain::Bitcoin]).into(); let mut env = Environment::new(None, Some(operator_account.clone()), None); env.set_pk(near_sdk::PublicKey::from(signer_pk_b)); contract .register_foreign_chains_config(foreign_chains_config) .expect("second node from same operator should be able to register"); // Then: both nodes' configs exist independently. let configs = &contract.foreign_chains.get().foreign_chains_configs; assert!(configs.contains_key(&tls_key_a), "node A config must exist"); assert!(configs.contains_key(&tls_key_b), "node B config must exist"); } }