From 29dc0473785bd7337764f30c7a5485c7be1c55e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 30 Jun 2026 18:56:07 +0200 Subject: [PATCH 01/28] feat(contract): async TEE attestation verification, drop dcap-qvl Dstack attestations are no longer verified in-WASM. submit_participant_info now branches: Mock is verified synchronously, Dstack yields and offloads DCAP quote verification to a separate tee-verifier contract via a cross-contract call, resuming through resolve_verification / on_attestation_verified. This lets the contract drop the mpc-attestation local-verify feature (and with it dcap-qvl). Adds the pending-attestation state, the yield/resume + refund/revert machinery, the verifier-call gas config, and the related error variants. This is the contract-side wiring only; the verifier interface (tee-verifier-interface, DstackAttestation::verify) already exists. Sandbox coverage (stub verifier + tests) and the design doc follow in a separate PR. --- Cargo.lock | 2 + crates/contract/Cargo.toml | 5 +- crates/contract/src/config.rs | 23 + crates/contract/src/dto_mapping.rs | 20 + crates/contract/src/errors.rs | 12 + crates/contract/src/lib.rs | 629 +++++++++-------- ...contract_borsh_schema_has_not_changed.snap | 20 + crates/contract/src/storage_keys.rs | 1 + crates/contract/src/tee.rs | 1 + .../contract/src/tee/pending_attestation.rs | 53 ++ crates/contract/src/tee/tee_state.rs | 273 ++++---- crates/contract/src/v3_12_0_state.rs | 1 + .../tests/inprocess/attestation_submission.rs | 9 +- .../tests/sandbox/contract_configuration.rs | 4 + .../sandbox/upgrade_from_current_contract.rs | 4 + .../snapshots/abi__abi_has_not_changed.snap | 640 +++++++++++++++++- crates/mpc-attestation/src/attestation.rs | 7 +- .../src/method_names.rs | 6 + .../src/types/config.rs | 24 + crates/test-utils/src/contract_types.rs | 4 + 20 files changed, 1290 insertions(+), 448 deletions(-) create mode 100644 crates/contract/src/tee/pending_attestation.rs diff --git a/Cargo.lock b/Cargo.lock index da7e592957..06e982f4e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5773,6 +5773,7 @@ version = "3.13.0" dependencies = [ "anyhow", "assert_matches", + "attestation", "blstrs", "borsh", "cargo-near-build", @@ -5807,6 +5808,7 @@ dependencies = [ "serde_with", "sha2 0.10.9", "signature", + "tee-verifier-interface", "test-utils", "thiserror 2.0.18", "threshold-signatures", diff --git a/crates/contract/Cargo.toml b/crates/contract/Cargo.toml index 8a2f7c4eb2..e9d8f991a6 100644 --- a/crates/contract/Cargo.toml +++ b/crates/contract/Cargo.toml @@ -65,6 +65,7 @@ abi = [ "near-mpc-contract-interface/abi", "mpc-attestation/abi", "mpc-primitives/abi", + "tee-verifier-interface/borsh-schema", "schemars", ] # This is used when running `cargo clippy --all-features`, because otherwise `abi` feat will break compilation. @@ -74,6 +75,7 @@ __abi-generate = ["abi", "near-sdk/__abi-generate"] [dependencies] assert_matches = { workspace = true } +attestation = { workspace = true } blstrs = { workspace = true } borsh = { workspace = true } curve25519-dalek = { workspace = true } @@ -87,7 +89,7 @@ k256 = { workspace = true, features = [ "arithmetic", "expose-field", ] } -mpc-attestation = { workspace = true, features = ["local-verify"] } +mpc-attestation = { workspace = true } mpc-primitives = { workspace = true } near-account-id = { workspace = true, features = ["serde"] } near-mpc-bounded-collections = { workspace = true } @@ -102,6 +104,7 @@ rand = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true } +tee-verifier-interface = { workspace = true } thiserror = { workspace = true } threshold-signatures = { workspace = true, optional = true } diff --git a/crates/contract/src/config.rs b/crates/contract/src/config.rs index 9acf2a28ca..b94868caf9 100644 --- a/crates/contract/src/config.rs +++ b/crates/contract/src/config.rs @@ -21,6 +21,8 @@ const DEFAULT_RETURN_SIGNATURE_AND_CLEAN_STATE_ON_SUCCESS_CALL_TERA_GAS: u64 = 7 const DEFAULT_RETURN_CK_AND_CLEAN_STATE_ON_SUCCESS_CALL_TERA_GAS: u64 = 7; /// Prepaid gas for a `fail_on_timeout` call const DEFAULT_FAIL_ON_TIMEOUT_TERA_GAS: u64 = 2; +/// Prepaid gas for a `fail_attestation_submission` call +const DEFAULT_FAIL_ATTESTATION_SUBMISSION_TERA_GAS: u64 = 2; /// Prepaid gas for a `clean_tee_status` call const DEFAULT_CLEAN_TEE_STATUS_TERA_GAS: u64 = 10; /// Prepaid gas for the reshare-time `clean_invalid_attestations` promise. @@ -34,6 +36,15 @@ const DEFAULT_REMOVE_NON_PARTICIPANT_UPDATE_VOTES_TERA_GAS: u64 = 5; const DEFAULT_CLEAN_FOREIGN_CHAIN_DATA_TERA_GAS: u64 = 5; /// Prepaid gas for a `remove_non_participant_tee_verifier_votes` call const DEFAULT_REMOVE_NON_PARTICIPANT_TEE_VERIFIER_VOTES_TERA_GAS: u64 = 5; +/// Gas attached to the cross-contract `verify_quote` call on the TEE verifier. +const DEFAULT_VERIFIER_TERA_GAS: u64 = 100; +/// Prepaid gas for the `resolve_verification` callback. Carries the bulk of the +/// post-DCAP work (allowlist match, RTMR3 replay, app-compose validation, store). +const DEFAULT_RESOLVE_VERIFICATION_TERA_GAS: u64 = 60; +/// Prepaid gas for the `on_attestation_verified` yield-callback. Sized for its +/// heaviest (timeout) branch, which removes the pending entry and schedules both +/// a refund transfer and the `fail_attestation_submission` promise. +const DEFAULT_ON_ATTESTATION_VERIFIED_TERA_GAS: u64 = 10; /// Config for V2 of the contract. #[near(serializers=[borsh, json])] @@ -56,6 +67,8 @@ pub(crate) struct Config { pub(crate) return_ck_and_clean_state_on_success_call_tera_gas: u64, /// Prepaid gas for a `fail_on_timeout` call. pub(crate) fail_on_timeout_tera_gas: u64, + /// Prepaid gas for a `fail_attestation_submission` call. + pub(crate) fail_attestation_submission_tera_gas: u64, /// Prepaid gas for a `clean_tee_status` call. pub(crate) clean_tee_status_tera_gas: u64, /// Prepaid gas for the reshare-time `clean_invalid_attestations` promise. @@ -68,6 +81,12 @@ pub(crate) struct Config { pub(crate) clean_foreign_chain_data_tera_gas: u64, /// Prepaid gas for a `remove_non_participant_tee_verifier_votes` call. pub(crate) remove_non_participant_tee_verifier_votes_tera_gas: u64, + /// Gas attached to the cross-contract `verify_quote` call on the verifier. + pub(crate) verifier_tera_gas: u64, + /// Prepaid gas for the `resolve_verification` callback. + pub(crate) resolve_verification_tera_gas: u64, + /// Prepaid gas for the `on_attestation_verified` yield-callback. + pub(crate) on_attestation_verified_tera_gas: u64, } impl Default for Config { @@ -85,6 +104,7 @@ impl Default for Config { return_ck_and_clean_state_on_success_call_tera_gas: DEFAULT_RETURN_CK_AND_CLEAN_STATE_ON_SUCCESS_CALL_TERA_GAS, fail_on_timeout_tera_gas: DEFAULT_FAIL_ON_TIMEOUT_TERA_GAS, + fail_attestation_submission_tera_gas: DEFAULT_FAIL_ATTESTATION_SUBMISSION_TERA_GAS, clean_tee_status_tera_gas: DEFAULT_CLEAN_TEE_STATUS_TERA_GAS, clean_invalid_attestations_tera_gas: DEFAULT_CLEAN_INVALID_ATTESTATIONS_TERA_GAS, cleanup_orphaned_node_migrations_tera_gas: @@ -94,6 +114,9 @@ impl Default for Config { clean_foreign_chain_data_tera_gas: DEFAULT_CLEAN_FOREIGN_CHAIN_DATA_TERA_GAS, remove_non_participant_tee_verifier_votes_tera_gas: DEFAULT_REMOVE_NON_PARTICIPANT_TEE_VERIFIER_VOTES_TERA_GAS, + verifier_tera_gas: DEFAULT_VERIFIER_TERA_GAS, + resolve_verification_tera_gas: DEFAULT_RESOLVE_VERIFICATION_TERA_GAS, + on_attestation_verified_tera_gas: DEFAULT_ON_ATTESTATION_VERIFIED_TERA_GAS, } } } diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index 104870e261..e4a6f7c6a5 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -472,6 +472,9 @@ impl From for Config { if let Some(v) = config_ext.fail_on_timeout_tera_gas { config.fail_on_timeout_tera_gas = v; } + if let Some(v) = config_ext.fail_attestation_submission_tera_gas { + config.fail_attestation_submission_tera_gas = v; + } if let Some(v) = config_ext.clean_tee_status_tera_gas { config.clean_tee_status_tera_gas = v; } @@ -490,6 +493,15 @@ impl From for Config { if let Some(v) = config_ext.remove_non_participant_tee_verifier_votes_tera_gas { config.remove_non_participant_tee_verifier_votes_tera_gas = v; } + if let Some(v) = config_ext.verifier_tera_gas { + config.verifier_tera_gas = v; + } + if let Some(v) = config_ext.resolve_verification_tera_gas { + config.resolve_verification_tera_gas = v; + } + if let Some(v) = config_ext.on_attestation_verified_tera_gas { + config.on_attestation_verified_tera_gas = v; + } config } @@ -510,6 +522,7 @@ impl From<&Config> for near_mpc_contract_interface::types::Config { return_ck_and_clean_state_on_success_call_tera_gas: value .return_ck_and_clean_state_on_success_call_tera_gas, fail_on_timeout_tera_gas: value.fail_on_timeout_tera_gas, + fail_attestation_submission_tera_gas: value.fail_attestation_submission_tera_gas, clean_tee_status_tera_gas: value.clean_tee_status_tera_gas, clean_invalid_attestations_tera_gas: value.clean_invalid_attestations_tera_gas, cleanup_orphaned_node_migrations_tera_gas: value @@ -519,6 +532,9 @@ impl From<&Config> for near_mpc_contract_interface::types::Config { clean_foreign_chain_data_tera_gas: value.clean_foreign_chain_data_tera_gas, remove_non_participant_tee_verifier_votes_tera_gas: value .remove_non_participant_tee_verifier_votes_tera_gas, + verifier_tera_gas: value.verifier_tera_gas, + resolve_verification_tera_gas: value.resolve_verification_tera_gas, + on_attestation_verified_tera_gas: value.on_attestation_verified_tera_gas, } } } @@ -538,6 +554,7 @@ impl From for Config { return_ck_and_clean_state_on_success_call_tera_gas: value .return_ck_and_clean_state_on_success_call_tera_gas, fail_on_timeout_tera_gas: value.fail_on_timeout_tera_gas, + fail_attestation_submission_tera_gas: value.fail_attestation_submission_tera_gas, clean_tee_status_tera_gas: value.clean_tee_status_tera_gas, clean_invalid_attestations_tera_gas: value.clean_invalid_attestations_tera_gas, cleanup_orphaned_node_migrations_tera_gas: value @@ -547,6 +564,9 @@ impl From for Config { clean_foreign_chain_data_tera_gas: value.clean_foreign_chain_data_tera_gas, remove_non_participant_tee_verifier_votes_tera_gas: value .remove_non_participant_tee_verifier_votes_tera_gas, + verifier_tera_gas: value.verifier_tera_gas, + resolve_verification_tera_gas: value.resolve_verification_tera_gas, + on_attestation_verified_tera_gas: value.on_attestation_verified_tera_gas, } } } diff --git a/crates/contract/src/errors.rs b/crates/contract/src/errors.rs index 7c67f0c100..f9bd9fbf96 100644 --- a/crates/contract/src/errors.rs +++ b/crates/contract/src/errors.rs @@ -1,6 +1,7 @@ use crate::crypto_shared::kdf::TweakNotOnCurve; use crate::primitives::domain::MIN_RECONSTRUCTION_THRESHOLD; use crate::primitives::key_state::{EpochId, Keyset}; +use crate::tee::tee_state::AttestationSubmissionError; use near_account_id::AccountId; use near_mpc_contract_interface::types as dtos; use near_mpc_contract_interface::types::{DomainId, DomainPurpose, ForeignChain, Protocol}; @@ -28,6 +29,14 @@ pub enum TeeError { "Due to previously failed TEE validation, the network is not accepting new requests at this point in time. Try again later." )] TeeValidationFailed, + #[error( + "A Dstack attestation verification is already in flight for this account; wait for it to finish before resubmitting." + )] + VerificationAlreadyPending, + #[error( + "No TEE verifier is configured yet. Participants must vote one in via vote_tee_verifier_change before Dstack attestations can be submitted." + )] + VerifierNotConfigured, } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] @@ -318,6 +327,9 @@ pub enum Error { // Tee errors #[error(transparent)] NodeMigrationError(#[from] NodeMigrationError), + // Tee attestation submission errors + #[error(transparent)] + AttestationSubmission(#[from] AttestationSubmissionError), } impl near_sdk::FunctionError for Error { diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index dfee3449c5..9e927a54c8 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -48,6 +48,7 @@ use crate::{ votes::ProposalHash, }, storage_keys::StorageKey, + tee::pending_attestation::{AttestationResult, PendingAttestation}, tee::tee_state::{TeeQuoteStatus, TeeState}, tee::verifier_votes::{TeeVerifierVotes, VerifierChangeProposal}, update::{ProposeUpdateArgs, ProposedUpdates, Update, UpdateId}, @@ -71,6 +72,7 @@ use near_mpc_contract_interface::types::{ use near_mpc_contract_interface::{method_names, types::CKDRequestArgs}; use dtos::{Curve, DomainConfig, DomainId, DomainPurpose, Protocol}; +use mpc_attestation::attestation::{Attestation, DstackAttestation}; use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash, TeeVerifierCodeHash}; use near_sdk::{ AccountId, CryptoHash, Gas, GasWeight, NearToken, Promise, PromiseError, PromiseOrValue, env, @@ -86,11 +88,12 @@ use primitives::{ }; use tee::measurements::{ContractExpectedMeasurements, MeasurementVoteAction, MeasurementVotes}; use tee::proposal::{CodeHashesVotes, LauncherHashVotes}; +use tee_verifier_interface::{VerificationResult, VerifiedReport}; use state::{ProtocolContractState, running::RunningContractState}; use tee::{ proposal::{LauncherVoteAction, NodeImageHash}, - tee_state::{AttestationSubmissionError, NodeId, ParticipantInsertion, TeeValidationResult}, + tee_state::{NodeId, ParticipantInsertion, TeeValidationResult}, }; /// Register used to receive data id from `promise_await_data`. @@ -140,6 +143,16 @@ fn require_deposit(minimum_deposit: NearToken, predecessor: &AccountId) { } } +/// Refunds an attestation submitter's attached deposit (no-op for a zero +/// deposit). Used when an [`Attestation::Dstack`] verification is rejected or +/// times out. +fn refund_attestation_deposit(account_id: &AccountId, deposit: NearToken) { + if deposit > NearToken::from_yoctonear(0) { + log!("refund attestation deposit {deposit} to {account_id}"); + Promise::new(account_id.clone()).transfer(deposit).detach(); + } +} + impl Default for MpcContract { fn default() -> Self { env::panic_str("Calling default not allowed."); @@ -165,11 +178,17 @@ pub struct MpcContract { 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. + /// until participants vote one in. An [`Attestation::Dstack`] submission + /// offloads quote verification to this account; while it is [`None`], such + /// submissions are rejected with [`TeeError::VerifierNotConfigured`]. // 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, + /// In-flight [`Attestation::Dstack`] verifications, one entry per submitter + /// account, held between the cross-contract verify-quote call and its + /// resolution (or the yield timeout). + pending_attestations: LookupMap, } #[near(serializers=[borsh])] @@ -753,8 +772,14 @@ impl MpcContract { ) } - /// (Prospective) Participants can submit their tee participant information through this - /// endpoint. + /// Submit a TEE attestation for a current or prospective participant. + /// + /// - [`Attestation::Mock`] is verified synchronously. + /// - [`Attestation::Dstack`] is verified asynchronously, by yielding on a + /// cross-contract verify-quote call. It rejects a second submission from + /// the same account while one is still in flight. + /// + /// The attached deposit pays for storage on success, and is refunded on failure. #[payable] #[handle_result] pub fn submit_participant_info( @@ -775,13 +800,6 @@ impl MpcContract { 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 @@ -792,62 +810,140 @@ impl MpcContract { } })?; - // 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, + let node_id = NodeId { + account_id: account_id.clone(), + tls_public_key, + account_public_key, + }; + // Decides who pays for storage. Captured now because the async Dstack + // path checks it in a later callback, where the caller is the contract + // itself and participant status can no longer be derived. + let caller_is_participant = self.voter_account().is_ok(); + + match proposed_participant_attestation { + Attestation::Mock(mock) => { + let tee_upgrade_deadline_duration = + Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); + let initial_storage = env::storage_usage(); + let insertion = self.tee_state.add_mock_participant( + node_id, + mock, + tee_upgrade_deadline_duration, + )?; + self.charge_attestation_storage( + &account_id, + initial_storage, + &insertion, + caller_is_participant, + env::attached_deposit(), + )?; + Ok(()) + } + Attestation::Dstack(dstack) => { + self.submit_dstack_attestation(node_id, dstack, caller_is_participant) + } + } + } + + /// Async [`Attestation::Dstack`] submission: registers a yield, fires the + /// cross-contract verify-quote call, and resumes via + /// [`Self::resolve_verification`]. + fn submit_dstack_attestation( + &mut self, + node_id: NodeId, + dstack: DstackAttestation, + caller_is_participant: bool, + ) -> Result<(), Error> { + let account_id = node_id.account_id.clone(); + + if self.pending_attestations.contains_key(&account_id) { + return Err(TeeError::VerificationAlreadyPending.into()); + } + + let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { + return Err(TeeError::VerifierNotConfigured.into()); + }; + + let attached_deposit = env::attached_deposit(); + let tls_public_key = node_id.tls_public_key.clone(); + + // Call the verifier; `resolve_verification` bridges its response back into + // the yield registered below. Scheduled before `enqueue_yield_request` so + // that helper's `promise_return` stays the method's last host call. + Promise::new(verifier_account_id) + .function_call( + method_names::VERIFY_QUOTE.to_string(), + borsh::to_vec(&(&dstack.quote, &dstack.collateral)) + .expect("borsh serialization of verify_quote args must succeed"), + NearToken::from_yoctonear(0), + Gas::from_tgas(self.config.verifier_tera_gas), ) - .map_err(|err| { - let reason = match &err { - AttestationSubmissionError::InvalidAttestation(_) => { - format!("TeeQuoteStatus is invalid: {err}") - } - AttestationSubmissionError::TlsKeyOwnedByOtherAccount => err.to_string(), - }; - InvalidParameters::InvalidTeeRemoteAttestation { reason } - })?; + .then( + Self::ext(env::current_account_id()) + .with_static_gas(Gas::from_tgas(self.config.resolve_verification_tera_gas)) + .resolve_verification(node_id), + ) + .detach(); - let caller_is_not_participant = self.voter_account().is_err(); - let is_new_attestation = matches!( - attestation_insertion_result, - ParticipantInsertion::NewlyInsertedParticipant + self.enqueue_yield_request( + method_names::ON_ATTESTATION_VERIFIED, + serde_json::to_vec(&(&account_id,)) + .expect("json serialization of account_id must succeed"), + Gas::from_tgas(self.config.on_attestation_verified_tera_gas), + |this, data_id| { + this.pending_attestations.insert( + account_id.clone(), + PendingAttestation { + dstack, + tls_public_key, + attached_deposit, + caller_is_participant, + data_id, + }, + ); + }, ); - let attestation_storage_must_be_paid_by_caller = - is_new_attestation || caller_is_not_participant; + // The yield is the method's return value: `enqueue_yield_request` called + // `promise_return` as the final host call, so returning unit here adds no + // `value_return` that would override it. + Ok(()) + } - 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(); + fn charge_attestation_storage( + &self, + account_id: &AccountId, + initial_storage: u64, + insertion: &ParticipantInsertion, + caller_is_participant: bool, + attached: NearToken, + ) -> Result<(), Error> { + let is_new_attestation = + matches!(insertion, ParticipantInsertion::NewlyInsertedParticipant); + // A participant refreshing an existing attestation is not charged. + if caller_is_participant && !is_new_attestation { + return Ok(()); + } - if attached < cost { - return Err(InvalidParameters::InsufficientDeposit { - attached: attached.as_yoctonear(), - required: cost.as_yoctonear(), - } - .into()); - } + // `saturating_sub`: if a re-submission shrinks the entry, charge nothing + // rather than underflow. Intentional asymmetry: we do not refund freed + // bytes either, since the caller already paid for the larger entry. + let storage_used = env::storage_usage().saturating_sub(initial_storage); + let cost = env::storage_byte_cost().saturating_mul(storage_used as u128); - // 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(); + if attached < cost { + return Err(InvalidParameters::InsufficientDeposit { + attached: attached.as_yoctonear(), + required: cost.as_yoctonear(), } + .into()); } + if let Some(diff) = attached.checked_sub(cost) + && diff > NearToken::from_yoctonear(0) + { + Promise::new(account_id.clone()).transfer(diff).detach(); + } Ok(()) } @@ -1969,6 +2065,7 @@ impl MpcContract { ), tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), + pending_attestations: LookupMap::new(StorageKey::PendingAttestations), }) } @@ -2048,6 +2145,7 @@ impl MpcContract { ), tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), + pending_attestations: LookupMap::new(StorageKey::PendingAttestations), }) } @@ -2271,6 +2369,143 @@ impl MpcContract { } } + /// Verify-quote callback: maps the verifier's response to an [`AttestationResult`] + /// and resumes the yield. + #[private] + pub fn resolve_verification( + &mut self, + node_id: NodeId, + #[serializer(borsh)] + #[callback_result] + result: Result, + ) { + let account_id = node_id.account_id.clone(); + + // No verdict (verifier unreachable, panicked, or out of gas). Don't resume; + // the yield timeout fires `on_attestation_verified` to clean up and refund. + let result = match result { + Ok(result) => result, + Err(promise_err) => { + log!("verifier did not answer for {account_id}: {promise_err:?}"); + return; + } + }; + + // Take the pending entry now. A late verifier response can arrive after the + // ~200-block yield timeout already fired and `on_attestation_verified` removed + // the entry and resolved the yield; there is then nothing to do. + let Some(pending) = self.pending_attestations.remove(&account_id) else { + log!( + "resolve_verification: no pending attestation for {account_id} (late response or already cleaned up); ignoring" + ); + return; + }; + + let attestation_result = match result { + VerificationResult::Rejected(reason) => { + log!("verifier rejected quote for {account_id}: {reason}"); + AttestationResult::Err(format!("verifier rejected quote: {reason}")) + } + VerificationResult::Verified(report) => { + self.finish_verified_attestation(&node_id, &pending, &report) + } + }; + + if matches!(attestation_result, AttestationResult::Err(_)) { + refund_attestation_deposit(&account_id, pending.attached_deposit); + } + // MUST be the last host call: anything after could panic and roll back + // the state mutations above. + env::promise_yield_resume( + &pending.data_id, + serde_json::to_vec(&attestation_result) + .expect("json serialization of AttestationResult must succeed"), + ); + } + + /// Runs the post-DCAP checks and stores the attestation for a + /// [`VerificationResult::Verified`] response, returning the outcome to resume + /// the yield with. On failure it reverts the store explicitly, since the + /// callback receipt commits regardless (unlike the synchronous path). + fn finish_verified_attestation( + &mut self, + node_id: &NodeId, + pending: &PendingAttestation, + report: &VerifiedReport, + ) -> AttestationResult { + let account_id = &node_id.account_id; + let tee_upgrade_deadline_duration = + Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); + + let initial_storage = env::storage_usage(); + let insertion = match self.tee_state.finish_dstack_verify( + node_id.clone(), + &pending.dstack, + report, + tee_upgrade_deadline_duration, + ) { + Ok(insertion) => insertion, + Err(err) => { + log!("post-DCAP check failed for {account_id}: {err}"); + return AttestationResult::Err(format!("post-DCAP check failed: {err}")); + } + }; + + match self.charge_attestation_storage( + account_id, + initial_storage, + &insertion, + pending.caller_is_participant, + pending.attached_deposit, + ) { + Ok(()) => AttestationResult::Ok, + Err(err) => { + // This receipt commits even though we resume the yield with an + // error, so the store above is NOT rolled back automatically + // (unlike the synchronous path). Undo it explicitly, or the + // caller would get storage for free plus a full refund. + self.tee_state + .revert_dstack_store(&pending.tls_public_key, insertion); + AttestationResult::Err(err.to_string()) + } + } + } + + /// Yield-resume callback for a [`Attestation::Dstack`] submission. On + /// success it resolves the caller's transaction; on a rejection or the + /// ~200-block timeout it cleans up, refunds, and fails from a separate + /// receipt. + #[private] + pub fn on_attestation_verified( + &mut self, + account_id: AccountId, + #[callback_result] result: Result, + ) -> PromiseOrValue<()> { + let reason = match result { + Ok(AttestationResult::Ok) => return PromiseOrValue::Value(()), + Ok(AttestationResult::Err(reason)) => reason, + Err(_promise_err) => { + // Timeout: the resolution callback never resumed us, so the + // pending entry is still here. Clean it up and refund. + if let Some(pending) = self.pending_attestations.remove(&account_id) { + refund_attestation_deposit(&account_id, pending.attached_deposit); + log!("yield timeout for {account_id}: refunded and cleaned up"); + } + "verifier did not respond within the yield-resume window".to_string() + } + }; + + // Fail the submitter's transaction from a separate receipt so the + // cleanup above commits (a panic here would roll it back). + let promise = Promise::new(env::current_account_id()).function_call( + method_names::FAIL_ATTESTATION_SUBMISSION.to_string(), + borsh::to_vec(&reason).expect("borsh serialization of reason must succeed"), + NearToken::from_near(0), + Gas::from_tgas(self.config.fail_attestation_submission_tera_gas), + ); + 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, @@ -2337,6 +2572,11 @@ impl MpcContract { } } + #[private] + pub fn fail_attestation_submission(#[serializer(borsh)] reason: String) { + env::panic_str(&reason); + } + #[private] pub fn fail_on_timeout() { // To stay consistent with the old version of the timeout error @@ -2701,9 +2941,8 @@ mod tests { use elliptic_curve::Group; use k256::{self, Secp256k1, ecdsa::SigningKey, elliptic_curve}; use mpc_attestation::attestation::{ - Attestation as MpcAttestation, MockAttestation as MpcMockAttestation, VerifiedAttestation, + 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; @@ -2721,10 +2960,6 @@ mod tests { 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 _; @@ -4109,8 +4344,8 @@ mod tests { 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.contains("failed verification"), + "Error should mention attestation verification failure, got: {}", error_string ); } @@ -4585,6 +4820,7 @@ mod tests { ), tee_verifier_account_id: None, tee_verifier_votes: Default::default(), + pending_attestations: LookupMap::new(StorageKey::PendingAttestations), } } } @@ -5036,14 +5272,12 @@ mod tests { destination_node_info, ); } - let valid_participant_attestation = mpc_attestation::attestation::Attestation::Mock( - mpc_attestation::attestation::MockAttestation::Valid, - ); + let valid_participant_attestation = MpcMockAttestation::Valid; let tee_upgrade_duration = Duration::from_secs(contract.config.tee_upgrade_deadline_duration_seconds); - let insertion_result = contract.tee_state.add_participant( + let insertion_result = contract.tee_state.add_mock_participant( NodeId { account_id: self.signer_account_id.clone(), tls_public_key: self.attestation_tls_key.clone(), @@ -5697,15 +5931,15 @@ mod tests { tls_public_key: target_participant_info.tls_public_key.clone(), account_public_key: bogus_ed25519_public_key(), }; - let expiring_attestation = MpcAttestation::Mock(MpcMockAttestation::WithConstraints { + let expiring_attestation = 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) + .add_mock_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 @@ -5816,15 +6050,15 @@ mod tests { tls_public_key: target_participant_info.tls_public_key.clone(), account_public_key: bogus_ed25519_public_key(), }; - let expiring_attestation = MpcAttestation::Mock(MpcMockAttestation::WithConstraints { + let expiring_attestation = 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) + .add_mock_participant(node_id, expiring_attestation, TEE_UPGRADE_DURATION) .expect("mock attestation is not yet expired and valid"); let (first_account_id, _, _) = &participant_list[0]; @@ -5847,247 +6081,6 @@ mod tests { 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]) } @@ -7504,13 +7497,13 @@ mod tests { // Add attestation for the new node (mirrors what ConcludeNodeMigrationTestSetup::setup does). contract .tee_state - .add_participant( + .add_mock_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), + MpcMockAttestation::Valid, Duration::from_secs(contract.config.tee_upgrade_deadline_duration_seconds), ) .expect("attestation insertion should succeed"); diff --git a/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap b/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap index 8f1b147a31..8247fb153b 100644 --- a/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap +++ b/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap @@ -234,6 +234,10 @@ BorshSchemaContainer { "fail_on_timeout_tera_gas", "u64", ), + ( + "fail_attestation_submission_tera_gas", + "u64", + ), ( "clean_tee_status_tera_gas", "u64", @@ -258,6 +262,18 @@ BorshSchemaContainer { "remove_non_participant_tee_verifier_votes_tera_gas", "u64", ), + ( + "verifier_tera_gas", + "u64", + ), + ( + "resolve_verification_tera_gas", + "u64", + ), + ( + "on_attestation_verified_tera_gas", + "u64", + ), ], ), }, @@ -711,6 +727,10 @@ BorshSchemaContainer { "tee_verifier_votes", "TeeVerifierVotes", ), + ( + "pending_attestations", + "LookupMap", + ), ], ), }, diff --git a/crates/contract/src/storage_keys.rs b/crates/contract/src/storage_keys.rs index e4e0349c6d..1bb1dc88e8 100644 --- a/crates/contract/src/storage_keys.rs +++ b/crates/contract/src/storage_keys.rs @@ -34,4 +34,5 @@ pub enum StorageKey { ForeignChainMetadata, TeeVerifierVotesByVoter, TeeVerifierVotesByProposal, + PendingAttestations, } diff --git a/crates/contract/src/tee.rs b/crates/contract/src/tee.rs index 9fafd439f9..310f91b910 100644 --- a/crates/contract/src/tee.rs +++ b/crates/contract/src/tee.rs @@ -1,4 +1,5 @@ pub mod measurements; +pub mod pending_attestation; pub mod proposal; pub mod tee_state; #[cfg(any(test, feature = "test-utils"))] diff --git a/crates/contract/src/tee/pending_attestation.rs b/crates/contract/src/tee/pending_attestation.rs new file mode 100644 index 0000000000..ce9fc5cc57 --- /dev/null +++ b/crates/contract/src/tee/pending_attestation.rs @@ -0,0 +1,53 @@ +//! State for an in-flight [`DstackAttestation`] submission. +//! +//! A [`DstackAttestation`] submission is asynchronous: it yields, fires a cross-contract +//! verify-quote call, and resumes from the response callback. What the callback +//! needs but cannot re-read from contract state is stashed here, keyed by the +//! submitter's account id, until the yield resolves. + +use borsh::{BorshDeserialize, BorshSerialize}; +use mpc_attestation::attestation::DstackAttestation; +use near_mpc_contract_interface::types::Ed25519PublicKey; +use near_sdk::{CryptoHash, NearToken, near}; + +/// One in-flight verification per submitter account. +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct PendingAttestation { + /// The submitted payload the post-DCAP checks consume once the verifier + /// returns its report. + pub dstack: DstackAttestation, + /// Checked against the quote's report-data during the post-DCAP checks. + pub tls_public_key: Ed25519PublicKey, + /// Stashed because the deposit is not visible from the callback receipt: + /// consumed for storage on success, refunded on failure. + pub attached_deposit: NearToken, + /// Participant status at submit time, which decides whether the caller pays + /// for storage. Captured because the callback receipt is no longer the + /// caller, so it can no longer be re-derived. + pub caller_is_participant: bool, + /// Yield handle, read back by the callback to resume the yield. + pub data_id: CryptoHash, +} + +#[near(serializers = [json])] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AttestationResult { + Ok, + Err(String), +} + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case(AttestationResult::Ok)] + #[case(AttestationResult::Err("rejected".to_string()))] + fn attestation_result__should_round_trip_json(#[case] original: AttestationResult) { + let bytes = serde_json::to_vec(&original).expect("serialize"); + let decoded: AttestationResult = serde_json::from_slice(&bytes).expect("deserialize"); + assert_eq!(original, decoded); + } +} diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 6d74a8f0e7..13dba9d5c2 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -11,13 +11,17 @@ use crate::{ }; use borsh::{BorshDeserialize, BorshSerialize}; use mpc_attestation::{ - attestation::{self, AcceptedAttestation, Attestation, VerifiedAttestation}, + attestation::{ + self, AcceptedAttestation, DstackAttestation, DstackVerify, MockAttestation, + VerifiedAttestation, + }, report_data::{ReportData, ReportDataV1}, }; use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash}; use near_mpc_contract_interface::types::Ed25519PublicKey; use near_sdk::{env, near, store::IterableMap}; use std::time::Duration; +use tee_verifier_interface::VerifiedReport; pub use near_mpc_contract_interface::types::NodeId; @@ -33,8 +37,8 @@ pub enum TeeQuoteStatus { Invalid(String), } -#[derive(Debug, Clone, thiserror::Error)] -pub(crate) enum AttestationSubmissionError { +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum AttestationSubmissionError { #[error("the submitted attestation failed verification, reason: {:?}", .0)] InvalidAttestation(#[from] attestation::VerificationError), #[error( @@ -44,9 +48,12 @@ pub(crate) enum AttestationSubmissionError { } #[derive(Debug)] +#[expect(clippy::large_enum_variant)] pub(crate) enum ParticipantInsertion { NewlyInsertedParticipant, - UpdatedExistingParticipant, + /// Holds the overwritten entry so [`TeeState::revert_dstack_store`] can put + /// it back if the async store is rolled back. + UpdatedExistingParticipant(NodeAttestation), } #[derive(Debug)] @@ -143,31 +150,48 @@ impl TeeState { } fn current_time_seconds() -> u64 { - let current_time_milliseconds = env::block_timestamp_ms(); - current_time_milliseconds / 1_000 + env::block_timestamp_ms() / 1_000 } - /// Adds a participant attestation for the given node iff the attestation succeeds verification. - pub(crate) fn add_participant( + pub(crate) fn add_mock_participant( &mut self, node_id: NodeId, - attestation: Attestation, + mock: MockAttestation, tee_upgrade_deadline_duration: Duration, ) -> Result { - let expected_report_data: ReportData = ReportDataV1::new( - *node_id.tls_public_key.as_bytes(), - *node_id.account_public_key.as_bytes(), - ) - .into(); + let AcceptedAttestation { + attestation: verified_attestation, + advisory_ids, + } = mock.verify( + Self::current_time_seconds(), + &self.get_allowed_mpc_docker_image_hashes(tee_upgrade_deadline_duration), + &self.get_allowed_launcher_compose_hashes(), + &self.get_accepted_measurements(), + )?; + + log_informational_advisory_ids(&advisory_ids); + + self.store_verified_attestation(node_id, verified_attestation) + } + /// Runs the post-DCAP checks for a [`Attestation::Dstack`] attestation + /// against the [`VerifiedReport`] the verifier returned, then stores the + /// result. + pub(crate) fn finish_dstack_verify( + &mut self, + node_id: NodeId, + dstack: &DstackAttestation, + report: &VerifiedReport, + tee_upgrade_deadline_duration: Duration, + ) -> Result { + let expected_report_data = Self::expected_report_data(&node_id); let accepted_measurements = self.get_accepted_measurements(); - // TODO(#3264): run DCAP in the verifier contract (Promise + callback) and - // do the post-DCAP checks here, instead of verifying locally in-WASM. let AcceptedAttestation { attestation: verified_attestation, advisory_ids, - } = attestation.verify_locally( - expected_report_data.into(), + } = dstack.verify( + report, + expected_report_data, Self::current_time_seconds(), &self.get_allowed_mpc_docker_image_hashes(tee_upgrade_deadline_duration), &self.get_allowed_launcher_compose_hashes(), @@ -175,7 +199,30 @@ impl TeeState { )?; log_informational_advisory_ids(&advisory_ids); + self.store_verified_attestation(node_id, verified_attestation) + } + + fn expected_report_data(node_id: &NodeId) -> ::attestation::report_data::ReportData { + let report_data: ReportData = ReportDataV1::new( + *node_id.tls_public_key.as_bytes(), + *node_id.account_public_key.as_bytes(), + ) + .into(); + report_data.into() + } + /// Stores an already-verified attestation, rejecting a TLS key owned by a + /// different account. + /// + /// On an update, the returned [`ParticipantInsertion::UpdatedExistingParticipant`] + /// carries the displaced [`NodeAttestation`]; the Dstack path uses it to undo + /// this store via [`Self::revert_dstack_store`], because its callback receipt + /// commits even when the later storage charge fails. + fn store_verified_attestation( + &mut self, + node_id: NodeId, + verified_attestation: VerifiedAttestation, + ) -> Result { let tls_pk = node_id.tls_public_key.clone(); // Authorization: a TLS key registered to one account must not be @@ -188,7 +235,7 @@ impl TeeState { return Err(AttestationSubmissionError::TlsKeyOwnedByOtherAccount); } - let insertion = self.stored_attestations.insert( + let previous = self.stored_attestations.insert( tls_pk, NodeAttestation { node_id, @@ -196,12 +243,32 @@ impl TeeState { }, ); - Ok(match insertion { - Some(_previous_attestation) => ParticipantInsertion::UpdatedExistingParticipant, + Ok(match previous { + Some(previous) => ParticipantInsertion::UpdatedExistingParticipant(previous), None => ParticipantInsertion::NewlyInsertedParticipant, }) } + /// Undoes a [`Self::finish_dstack_verify`] store: restores the displaced + /// entry, or removes the newly-inserted one if there was none. Used by the + /// async flow when the storage charge fails after the store, so a caller + /// can't get storage for free in a receipt that still commits. + pub(crate) fn revert_dstack_store( + &mut self, + tls_public_key: &Ed25519PublicKey, + insertion: ParticipantInsertion, + ) { + match insertion { + ParticipantInsertion::UpdatedExistingParticipant(previous) => { + self.stored_attestations + .insert(tls_public_key.clone(), previous); + } + ParticipantInsertion::NewlyInsertedParticipant => { + self.stored_attestations.remove(tls_public_key); + } + } + } + /// reverifies stored participant attestations. pub(crate) fn reverify_participants( &self, @@ -541,7 +608,7 @@ mod tests { }; use crate::tee::test_utils::set_block_timestamp; use assert_matches::assert_matches; - use mpc_attestation::attestation::{Attestation, MockAttestation}; + use mpc_attestation::attestation::MockAttestation; use mpc_primitives::hash::{LauncherImageHash, NodeImageHash}; use near_account_id::AccountId; use near_sdk::test_utils::VMContextBuilder; @@ -580,7 +647,7 @@ mod tests { .collect(); // Add TEE information for all participants and non-participant - let local_attestation = Attestation::Mock(MockAttestation::Valid); + let local_attestation = MockAttestation::Valid; let non_participant_uid = NodeId { account_id: non_participant.clone(), @@ -590,7 +657,7 @@ mod tests { for node_id in &participant_nodes { tee_state - .add_participant( + .add_mock_participant( node_id.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, @@ -598,7 +665,7 @@ mod tests { .unwrap(); } tee_state - .add_participant( + .add_mock_participant( non_participant_uid.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, @@ -649,24 +716,24 @@ mod tests { account_public_key: bogus_ed25519_public_key(), }; - let fresh = Attestation::Mock(MockAttestation::WithConstraints { + let fresh = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(FRESH_EXPIRY_SECONDS), expected_measurements: None, - }); - let stale = Attestation::Mock(MockAttestation::WithConstraints { + }; + let stale = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(STALE_EXPIRY_SECONDS), expected_measurements: None, - }); + }; tee_state - .add_participant(fresh_node.clone(), fresh, Duration::from_secs(0)) + .add_mock_participant(fresh_node.clone(), fresh, Duration::from_secs(0)) .unwrap(); tee_state - .add_participant(stale_node.clone(), stale, Duration::from_secs(0)) + .add_mock_participant(stale_node.clone(), stale, Duration::from_secs(0)) .unwrap(); assert_eq!(tee_state.stored_attestations.len(), 2); @@ -699,12 +766,12 @@ mod tests { let mut tee_state = TeeState::default(); - let expired = Attestation::Mock(MockAttestation::WithConstraints { + let expired = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(EXPIRY_SECONDS), expected_measurements: None, - }); + }; for idx in 0..10 { let node_id = NodeId { @@ -713,7 +780,7 @@ mod tests { account_public_key: bogus_ed25519_public_key(), }; tee_state - .add_participant(node_id, expired.clone(), Duration::from_secs(0)) + .add_mock_participant(node_id, expired.clone(), Duration::from_secs(0)) .unwrap(); } assert_eq!(tee_state.stored_attestations.len(), 10); @@ -749,14 +816,14 @@ mod tests { tls_public_key: bogus_ed25519_public_key(), account_public_key: bogus_ed25519_public_key(), }; - let attestation = Attestation::Mock(MockAttestation::WithConstraints { + let attestation = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(FUTURE_EXPIRY_SECONDS), expected_measurements: None, - }); + }; tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // When: cleanup runs while the attestation is still valid. @@ -778,7 +845,7 @@ mod tests { let mut tee_state = TeeState::default(); let participant: AccountId = "dave.near".parse().unwrap(); - let local_attestation = Attestation::Mock(MockAttestation::Valid); + let local_attestation = MockAttestation::Valid; let participant_id = NodeId { account_id: participant.clone(), @@ -786,7 +853,7 @@ mod tests { tls_public_key: bogus_ed25519_public_key(), }; - let insertion_result = tee_state.add_participant( + let insertion_result = tee_state.add_mock_participant( participant_id.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, @@ -797,7 +864,7 @@ mod tests { ); // when - let re_insertion_result = tee_state.add_participant( + let re_insertion_result = tee_state.add_mock_participant( participant_id.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, @@ -806,7 +873,7 @@ mod tests { // then assert_matches!( re_insertion_result, - Ok(ParticipantInsertion::UpdatedExistingParticipant) + Ok(ParticipantInsertion::UpdatedExistingParticipant(_)) ); } @@ -819,11 +886,11 @@ mod tests { tls_public_key: bogus_ed25519_public_key(), account_public_key: bogus_ed25519_public_key(), }; - let attestation = Attestation::Mock(MockAttestation::Valid); + let attestation = MockAttestation::Valid; // when tee_state - .add_participant(node_id, attestation, Duration::from_secs(0)) + .add_mock_participant(node_id, attestation, Duration::from_secs(0)) .unwrap(); // then @@ -843,11 +910,11 @@ mod tests { tls_public_key: bogus_ed25519_public_key(), account_public_key: bogus_ed25519_public_key(), }; - let attestation = Attestation::Mock(MockAttestation::Valid); + let attestation = MockAttestation::Valid; // when tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // then @@ -868,11 +935,11 @@ mod tests { tls_public_key: bogus_ed25519_public_key(), account_public_key: bogus_ed25519_public_key(), }; - let attestation = Attestation::Mock(MockAttestation::Valid); + let attestation = MockAttestation::Valid; // when tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // then @@ -906,16 +973,16 @@ mod tests { // when tee_state - .add_participant( + .add_mock_participant( node_1.clone(), - Attestation::Mock(MockAttestation::Valid), + MockAttestation::Valid, Duration::from_secs(0), ) .unwrap(); tee_state - .add_participant( + .add_mock_participant( node_2.clone(), - Attestation::Mock(MockAttestation::Valid), + MockAttestation::Valid, Duration::from_secs(0), ) .unwrap(); @@ -948,15 +1015,15 @@ mod tests { testing_env!(VMContextBuilder::new().block_timestamp(NOW_SECONDS).build()); - let attestation = Attestation::Mock(MockAttestation::WithConstraints { + let attestation = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(NOW_SECONDS), expected_measurements: None, - }); + }; tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // when @@ -981,15 +1048,15 @@ mod tests { testing_env!(VMContextBuilder::new().block_timestamp(0).build()); - let attestation = Attestation::Mock(MockAttestation::WithConstraints { + let attestation = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(EXPIRY_TIMESTAMP_SECONDS), expected_measurements: None, - }); + }; tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // when @@ -1026,15 +1093,15 @@ mod tests { .build() ); - let attestation = Attestation::Mock(MockAttestation::WithConstraints { + let attestation = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(EXPIRY_TIMESTAMP_SECONDS), expected_measurements: None, - }); + }; tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // when @@ -1087,11 +1154,7 @@ mod tests { account_public_key: Ed25519PublicKey::try_from(&signer_pk).unwrap(), }; tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("Attestation is valid on insertion"); // 4. Verify check passes @@ -1152,11 +1215,7 @@ mod tests { account_public_key: Ed25519PublicKey::try_from(&signer_pk).unwrap(), }; tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("Attestation is valid on insertion"); let result = tee_state.is_caller_an_attested_participant(&participants); @@ -1188,11 +1247,7 @@ mod tests { account_public_key: old_signer_pk, // Mismatch here }; tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("Attestation is valid on insertion"); // when @@ -1235,11 +1290,7 @@ mod tests { for (account_id, _, participant_info) in participants.participants().iter() { let node_id = create_node_id(account_id, &participant_info.tls_public_key); tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("mock attestation is valid"); } @@ -1260,11 +1311,7 @@ mod tests { for (account_id, _, participant_info) in participant_list.iter().take(2) { let node_id = create_node_id(account_id, &participant_info.tls_public_key); tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("mock attestation is valid"); } // Third participant has no attestation @@ -1294,25 +1341,21 @@ mod tests { for (account_id, _, participant_info) in participant_list.iter().take(2) { let node_id = create_node_id(account_id, &participant_info.tls_public_key); tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("mock attestation is valid"); } // Add expiring attestation for third participant let (account_id, _, participant_info) = &participant_list[2]; let node_id = create_node_id(account_id, &participant_info.tls_public_key); - let expiring_attestation = Attestation::Mock(MockAttestation::WithConstraints { + let expiring_attestation = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(expiry_time_secs), expected_measurements: None, - }); + }; tee_state - .add_participant(node_id, expiring_attestation, tee_upgrade_duration) + .add_mock_participant(node_id, expiring_attestation, tee_upgrade_duration) .expect("mock attestation is valid"); // Advance time to exact expiry boundary @@ -1345,17 +1388,17 @@ mod tests { for (i, (account_id, _, participant_info)) in participant_list.iter().enumerate() { let node_id = create_node_id(account_id, &participant_info.tls_public_key); let attestation = if i == 2 { - Attestation::Mock(MockAttestation::WithConstraints { + MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(expiry_time_secs), expected_measurements: None, - }) + } } else { - Attestation::Mock(MockAttestation::Valid) + MockAttestation::Valid }; tee_state - .add_participant(node_id, attestation, tee_upgrade_duration) + .add_mock_participant(node_id, attestation, tee_upgrade_duration) .expect("mock attestation is valid"); } @@ -1386,9 +1429,9 @@ mod tests { account_public_key: bogus_ed25519_public_key(), }; tee_state - .add_participant( + .add_mock_participant( alice_node.clone(), - Attestation::Mock(MockAttestation::Valid), + MockAttestation::Valid, TEE_UPGRADE_DURATION, ) .expect("initial insertion should succeed"); @@ -1399,9 +1442,9 @@ mod tests { tls_public_key: tls_public_key.clone(), account_public_key: bogus_ed25519_public_key(), }; - let result = tee_state.add_participant( + let result = tee_state.add_mock_participant( attacker_node, - Attestation::Mock(MockAttestation::Valid), + MockAttestation::Valid, TEE_UPGRADE_DURATION, ); @@ -1431,11 +1474,7 @@ mod tests { account_public_key: bogus_ed25519_public_key(), }; tee_state - .add_participant( - initial_node, - Attestation::Mock(MockAttestation::Valid), - TEE_UPGRADE_DURATION, - ) + .add_mock_participant(initial_node, MockAttestation::Valid, TEE_UPGRADE_DURATION) .expect("initial insertion should succeed"); // When: the same account resubmits with a rotated account_public_key. @@ -1444,14 +1483,17 @@ mod tests { tls_public_key, account_public_key: bogus_ed25519_public_key(), }; - let result = tee_state.add_participant( + let result = tee_state.add_mock_participant( rotated_node.clone(), - Attestation::Mock(MockAttestation::Valid), + MockAttestation::Valid, TEE_UPGRADE_DURATION, ); // Then: the update is accepted and the stored entry reflects the new key. - assert_matches!(result, Ok(ParticipantInsertion::UpdatedExistingParticipant)); + assert_matches!( + result, + Ok(ParticipantInsertion::UpdatedExistingParticipant(_)) + ); let stored = tee_state .stored_attestations .get(&rotated_node.tls_public_key) @@ -1470,22 +1512,15 @@ mod tests { for (account_id, _, participant_info) in participant_list.iter().take(2) { let node_id = create_node_id(account_id, &participant_info.tls_public_key); tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("mock attestation is valid"); } // Add invalid attestation for third participant let (account_id, _, participant_info) = &participant_list[2]; let node_id = create_node_id(account_id, &participant_info.tls_public_key); - let add_participant_result = tee_state.add_participant( - node_id, - Attestation::Mock(MockAttestation::Invalid), - tee_upgrade_duration, - ); + let add_participant_result = + tee_state.add_mock_participant(node_id, MockAttestation::Invalid, tee_upgrade_duration); assert_matches!( add_participant_result, diff --git a/crates/contract/src/v3_12_0_state.rs b/crates/contract/src/v3_12_0_state.rs index cfa9567bd5..48bccc24a2 100644 --- a/crates/contract/src/v3_12_0_state.rs +++ b/crates/contract/src/v3_12_0_state.rs @@ -125,6 +125,7 @@ impl From for crate::MpcContract { ), tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), + pending_attestations: LookupMap::new(StorageKey::PendingAttestations), } } } diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index 937611a957..42f88998aa 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -3,14 +3,14 @@ use mpc_contract::{ MpcContract, crypto_shared::types::PublicKeyExtended, - errors::{Error, InvalidParameters}, + errors::Error, primitives::{ key_state::{AttemptId, EpochId, KeyForDomain, Keyset}, participants::{ParticipantId, ParticipantInfo}, test_utils::{bogus_ed25519_public_key, gen_participants}, thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters}, }, - tee::tee_state::NodeId, + tee::tee_state::{AttestationSubmissionError, NodeId}, }; use near_mpc_contract_interface::types::{ Attestation, InitConfig, MockAttestation, Protocol, ProtocolContractState, @@ -341,8 +341,9 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { // entry is unchanged. assert_matches!( &attack_result, - Err(Error::InvalidParameters(InvalidParameters::InvalidTeeRemoteAttestation { reason })) - if reason.contains("TLS public key is already registered") + Err(Error::AttestationSubmission( + AttestationSubmissionError::TlsKeyOwnedByOtherAccount + )) ); let stored_after = setup .contract diff --git a/crates/contract/tests/sandbox/contract_configuration.rs b/crates/contract/tests/sandbox/contract_configuration.rs index 83e370a2f0..bcf4419223 100644 --- a/crates/contract/tests/sandbox/contract_configuration.rs +++ b/crates/contract/tests/sandbox/contract_configuration.rs @@ -97,12 +97,16 @@ async fn contract_configuration_can_be_set_on_initialization() { return_signature_and_clean_state_on_success_call_tera_gas: Some(66), return_ck_and_clean_state_on_success_call_tera_gas: Some(77), fail_on_timeout_tera_gas: Some(88), + fail_attestation_submission_tera_gas: Some(89), clean_tee_status_tera_gas: Some(99), clean_invalid_attestations_tera_gas: Some(101), cleanup_orphaned_node_migrations_tera_gas: Some(11), remove_non_participant_update_votes_tera_gas: Some(12), clean_foreign_chain_data_tera_gas: Some(13), remove_non_participant_tee_verifier_votes_tera_gas: Some(14), + verifier_tera_gas: Some(15), + resolve_verification_tera_gas: Some(16), + on_attestation_verified_tera_gas: Some(17), }; let SandboxTestSetup { contract, .. } = SandboxTestSetup::builder() diff --git a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs index ef9d4e712b..eef1390cc2 100644 --- a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs +++ b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs @@ -113,12 +113,16 @@ async fn test_propose_update_config() { return_signature_and_clean_state_on_success_call_tera_gas: 66, return_ck_and_clean_state_on_success_call_tera_gas: 77, fail_on_timeout_tera_gas: 88, + fail_attestation_submission_tera_gas: 89, clean_tee_status_tera_gas: 99, clean_invalid_attestations_tera_gas: 101, cleanup_orphaned_node_migrations_tera_gas: 11, remove_non_participant_update_votes_tera_gas: 12, clean_foreign_chain_data_tera_gas: 13, remove_non_participant_tee_verifier_votes_tera_gas: 14, + verifier_tera_gas: 15, + resolve_verification_tera_gas: 16, + on_attestation_verified_tera_gas: 17, }; let mut proposals = Vec::with_capacity(mpc_signer_accounts.len()); diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 4d39c43f38..7f626962d0 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -558,6 +558,39 @@ expression: abi } } }, + { + "name": "fail_attestation_submission", + "kind": "view", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "borsh", + "args": [ + { + "name": "reason", + "type_schema": { + "declaration": "String", + "definitions": { + "String": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "u8": { + "Primitive": 1 + } + } + } + } + ] + } + }, { "name": "fail_on_timeout", "kind": "view", @@ -920,6 +953,40 @@ expression: abi } } }, + { + "name": "on_attestation_verified", + "doc": " Yield-resume callback for a [`Attestation::Dstack`] submission. On\n success it resolves the caller's transaction; on a rejection or the\n ~200-block timeout it cleans up, refunds, and fails from a separate\n receipt.", + "kind": "call", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "account_id", + "type_schema": { + "description": "NEAR Account Identifier.\n\nThis is a unique, syntactically valid, human-readable account identifier on the NEAR network.\n\n[See the crate-level docs for information about validation.](index.html#account-id-rules)\n\nAlso see [Error kind precedence](AccountId#error-kind-precedence).\n\n## Examples\n\n``` use near_account_id::AccountId;\n\nlet alice: AccountId = \"alice.near\".parse().unwrap();\n\nassert!(\"ƒelicia.near\".parse::().is_err()); // (ƒ is not f) ```", + "type": "string" + } + } + ] + }, + "callbacks": [ + { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/AttestationResult" + } + } + ], + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/PromiseOrValueNull" + } + } + }, { "name": "os_measurement_votes", "doc": " Returns the current OS measurement votes, showing each participant's vote.", @@ -983,6 +1050,10 @@ expression: abi "fail_on_timeout_tera_gas", "u64" ], + [ + "fail_attestation_submission_tera_gas", + "u64" + ], [ "clean_tee_status_tera_gas", "u64" @@ -1006,6 +1077,18 @@ expression: abi [ "remove_non_participant_tee_verifier_votes_tera_gas", "u64" + ], + [ + "verifier_tera_gas", + "u64" + ], + [ + "resolve_verification_tera_gas", + "u64" + ], + [ + "on_attestation_verified_tera_gas", + "u64" ] ] }, @@ -1259,6 +1342,470 @@ expression: abi ] } }, + { + "name": "resolve_verification", + "doc": " Verify-quote callback: maps the verifier's response to an [`AttestationResult`]\n and resumes the yield.", + "kind": "call", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "node_id", + "type_schema": { + "$ref": "#/definitions/NodeId" + } + } + ] + }, + "callbacks": [ + { + "serialization_type": "borsh", + "type_schema": { + "declaration": "VerificationResult", + "definitions": { + "EnclaveReport": { + "Struct": [ + [ + "cpu_svn", + "[u8; 16]" + ], + [ + "misc_select", + "u32" + ], + [ + "reserved1", + "[u8; 28]" + ], + [ + "attributes", + "[u8; 16]" + ], + [ + "mr_enclave", + "[u8; 32]" + ], + [ + "reserved2", + "[u8; 32]" + ], + [ + "mr_signer", + "[u8; 32]" + ], + [ + "reserved3", + "[u8; 96]" + ], + [ + "isv_prod_id", + "u16" + ], + [ + "isv_svn", + "u16" + ], + [ + "reserved4", + "[u8; 60]" + ], + [ + "report_data", + "[u8; 64]" + ] + ] + }, + "Report": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "SgxEnclave", + "Report__SgxEnclave" + ], + [ + 1, + "TD10", + "Report__TD10" + ], + [ + 2, + "TD15", + "Report__TD15" + ] + ] + } + }, + "Report__SgxEnclave": { + "Struct": [ + "EnclaveReport" + ] + }, + "Report__TD10": { + "Struct": [ + "TDReport10" + ] + }, + "Report__TD15": { + "Struct": [ + "TDReport15" + ] + }, + "String": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "TDReport10": { + "Struct": [ + [ + "tee_tcb_svn", + "[u8; 16]" + ], + [ + "mr_seam", + "[u8; 48]" + ], + [ + "mr_signer_seam", + "[u8; 48]" + ], + [ + "seam_attributes", + "[u8; 8]" + ], + [ + "td_attributes", + "[u8; 8]" + ], + [ + "xfam", + "[u8; 8]" + ], + [ + "mr_td", + "[u8; 48]" + ], + [ + "mr_config_id", + "[u8; 48]" + ], + [ + "mr_owner", + "[u8; 48]" + ], + [ + "mr_owner_config", + "[u8; 48]" + ], + [ + "rt_mr0", + "[u8; 48]" + ], + [ + "rt_mr1", + "[u8; 48]" + ], + [ + "rt_mr2", + "[u8; 48]" + ], + [ + "rt_mr3", + "[u8; 48]" + ], + [ + "report_data", + "[u8; 64]" + ] + ] + }, + "TDReport15": { + "Struct": [ + [ + "base", + "TDReport10" + ], + [ + "tee_tcb_svn2", + "[u8; 16]" + ], + [ + "mr_service_td", + "[u8; 48]" + ] + ] + }, + "TcbStatus": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "UpToDate", + "TcbStatus__UpToDate" + ], + [ + 1, + "OutOfDateConfigurationNeeded", + "TcbStatus__OutOfDateConfigurationNeeded" + ], + [ + 2, + "OutOfDate", + "TcbStatus__OutOfDate" + ], + [ + 3, + "ConfigurationAndSWHardeningNeeded", + "TcbStatus__ConfigurationAndSWHardeningNeeded" + ], + [ + 4, + "ConfigurationNeeded", + "TcbStatus__ConfigurationNeeded" + ], + [ + 5, + "SWHardeningNeeded", + "TcbStatus__SWHardeningNeeded" + ], + [ + 6, + "Revoked", + "TcbStatus__Revoked" + ] + ] + } + }, + "TcbStatusWithAdvisory": { + "Struct": [ + [ + "status", + "TcbStatus" + ], + [ + "advisory_ids", + "Vec" + ] + ] + }, + "TcbStatus__ConfigurationAndSWHardeningNeeded": { + "Struct": null + }, + "TcbStatus__ConfigurationNeeded": { + "Struct": null + }, + "TcbStatus__OutOfDate": { + "Struct": null + }, + "TcbStatus__OutOfDateConfigurationNeeded": { + "Struct": null + }, + "TcbStatus__Revoked": { + "Struct": null + }, + "TcbStatus__SWHardeningNeeded": { + "Struct": null + }, + "TcbStatus__UpToDate": { + "Struct": null + }, + "Vec": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "String" + } + }, + "Vec": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "VerificationResult": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "Verified", + "VerificationResult__Verified" + ], + [ + 1, + "Rejected", + "VerificationResult__Rejected" + ] + ] + } + }, + "VerificationResult__Rejected": { + "Struct": [ + "VerifierError" + ] + }, + "VerificationResult__Verified": { + "Struct": [ + "VerifiedReport" + ] + }, + "VerifiedReport": { + "Struct": [ + [ + "status", + "String" + ], + [ + "advisory_ids", + "Vec" + ], + [ + "report", + "Report" + ], + [ + "ppid", + "Vec" + ], + [ + "qe_status", + "TcbStatusWithAdvisory" + ], + [ + "platform_status", + "TcbStatusWithAdvisory" + ] + ] + }, + "VerifierError": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "DcapVerification", + "VerifierError__DcapVerification" + ] + ] + } + }, + "VerifierError__DcapVerification": { + "Struct": [ + "String" + ] + }, + "[u8; 16]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 16, + "end": 16 + }, + "elements": "u8" + } + }, + "[u8; 28]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 28, + "end": 28 + }, + "elements": "u8" + } + }, + "[u8; 32]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 32, + "end": 32 + }, + "elements": "u8" + } + }, + "[u8; 48]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 48, + "end": 48 + }, + "elements": "u8" + } + }, + "[u8; 60]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 60, + "end": 60 + }, + "elements": "u8" + } + }, + "[u8; 64]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 64, + "end": 64 + }, + "elements": "u8" + } + }, + "[u8; 8]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 8, + "end": 8 + }, + "elements": "u8" + } + }, + "[u8; 96]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 96, + "end": 96 + }, + "elements": "u8" + } + }, + "u16": { + "Primitive": 2 + }, + "u32": { + "Primitive": 4 + }, + "u8": { + "Primitive": 1 + } + } + } + } + ] + }, { "name": "respond", "kind": "call", @@ -1536,7 +2083,7 @@ expression: abi }, { "name": "submit_participant_info", - "doc": " (Prospective) Participants can submit their tee participant information through this\n endpoint.", + "doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously, by yielding on a\n cross-contract verify-quote call. It rejects a second submission from\n the same account while one is still in flight.\n\n The attached deposit pays for storage on success, and is refunded on failure.", "kind": "call", "modifiers": [ "payable" @@ -2442,6 +2989,28 @@ expression: abi } ] }, + "AttestationResult": { + "oneOf": [ + { + "type": "string", + "enum": [ + "Ok" + ] + }, + { + "type": "object", + "required": [ + "Err" + ], + "properties": { + "Err": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, "AuthenticatedAccountId": { "description": "An account ID that has been authenticated (i.e., the caller is this account).", "type": "string" @@ -2714,14 +3283,18 @@ expression: abi "clean_tee_status_tera_gas", "cleanup_orphaned_node_migrations_tera_gas", "contract_upgrade_deposit_tera_gas", + "fail_attestation_submission_tera_gas", "fail_on_timeout_tera_gas", "key_event_timeout_blocks", + "on_attestation_verified_tera_gas", "remove_non_participant_tee_verifier_votes_tera_gas", "remove_non_participant_update_votes_tera_gas", + "resolve_verification_tera_gas", "return_ck_and_clean_state_on_success_call_tera_gas", "return_signature_and_clean_state_on_success_call_tera_gas", "sign_call_gas_attachment_requirement_tera_gas", - "tee_upgrade_deadline_duration_seconds" + "tee_upgrade_deadline_duration_seconds", + "verifier_tera_gas" ], "properties": { "ckd_call_gas_attachment_requirement_tera_gas": { @@ -2760,6 +3333,12 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "fail_attestation_submission_tera_gas": { + "description": "Prepaid gas for a `fail_attestation_submission` call.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, "fail_on_timeout_tera_gas": { "description": "Prepaid gas for a `fail_on_timeout` call.", "type": "integer", @@ -2772,6 +3351,12 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "on_attestation_verified_tera_gas": { + "description": "Prepaid gas for the `on_attestation_verified` yield-callback.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, "remove_non_participant_tee_verifier_votes_tera_gas": { "description": "Prepaid gas for a `remove_non_participant_tee_verifier_votes` call.", "type": "integer", @@ -2784,6 +3369,12 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "resolve_verification_tera_gas": { + "description": "Prepaid gas for the `resolve_verification` callback.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, "return_ck_and_clean_state_on_success_call_tera_gas": { "description": "Prepaid gas for a `return_ck_and_clean_state_on_success` call.", "type": "integer", @@ -2807,6 +3398,12 @@ expression: abi "type": "integer", "format": "uint64", "minimum": 0.0 + }, + "verifier_tera_gas": { + "description": "Gas attached to the cross-contract `verify_quote` call on the verifier.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 } } }, @@ -3374,6 +3971,15 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "fail_attestation_submission_tera_gas": { + "description": "Prepaid gas for a `fail_attestation_submission` call.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, "fail_on_timeout_tera_gas": { "description": "Prepaid gas for a `fail_on_timeout` call.", "type": [ @@ -3392,6 +3998,15 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "on_attestation_verified_tera_gas": { + "description": "Prepaid gas for the `on_attestation_verified` yield-callback.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, "remove_non_participant_tee_verifier_votes_tera_gas": { "description": "Prepaid gas for a `remove_non_participant_tee_verifier_votes` call.", "type": [ @@ -3410,6 +4025,15 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "resolve_verification_tera_gas": { + "description": "Prepaid gas for the `resolve_verification` callback.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, "return_ck_and_clean_state_on_success_call_tera_gas": { "description": "Prepaid gas for a `return_ck_and_clean_state_on_success` call.", "type": [ @@ -3445,6 +4069,15 @@ expression: abi ], "format": "uint64", "minimum": 0.0 + }, + "verifier_tera_gas": { + "description": "Gas attached to the cross-contract `verify_quote` call on the verifier.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 } } }, @@ -4051,6 +4684,9 @@ expression: abi } } }, + "PromiseOrValueNull": { + "type": "null" + }, "PromiseOrValueSignatureResponse": { "oneOf": [ { diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 25585d68ea..1fd671e6a9 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -346,10 +346,9 @@ impl Attestation { } } - /// Full local verification: runs DCAP (`dcap_qvl::verify::verify`) and then - /// the post-DCAP checks. Behind the `local-verify` feature, which pulls in - /// `dcap-qvl`. Used by off-chain callers and, today, by `mpc-contract`. - // TODO(#3264): contract drops this once DCAP moves to the verifier contract. + /// Full local verification: runs the DCAP quote verification and then the + /// post-DCAP checks. Behind the `local-verify` feature, which pulls in + /// `dcap-qvl`. Used by off-chain callers (node, tee-authority, attestation-cli). #[cfg(feature = "local-verify")] pub fn verify_locally( &self, diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index a6ae25a80d..fde652c2d8 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -64,6 +64,12 @@ pub const RETURN_SIGNATURE_AND_CLEAN_STATE_ON_SUCCESS: &str = pub const RETURN_CK_AND_CLEAN_STATE_ON_SUCCESS: &str = "return_ck_and_clean_state_on_success"; pub const RETURN_VERIFY_FOREIGN_TX_AND_CLEAN_STATE_ON_SUCCESS: &str = "return_verify_foreign_tx_and_clean_state_on_success"; +pub const ON_ATTESTATION_VERIFIED: &str = "on_attestation_verified"; +pub const RESOLVE_VERIFICATION: &str = "resolve_verification"; +pub const FAIL_ATTESTATION_SUBMISSION: &str = "fail_attestation_submission"; + +// TEE verifier contract (the method `mpc-contract` calls cross-contract) +pub const VERIFY_QUOTE: &str = "verify_quote"; // View methods pub const STATE: &str = "state"; diff --git a/crates/near-mpc-contract-interface/src/types/config.rs b/crates/near-mpc-contract-interface/src/types/config.rs index 75646b5a5a..4816084bac 100644 --- a/crates/near-mpc-contract-interface/src/types/config.rs +++ b/crates/near-mpc-contract-interface/src/types/config.rs @@ -37,6 +37,8 @@ pub struct InitConfig { pub return_ck_and_clean_state_on_success_call_tera_gas: Option, /// Prepaid gas for a `fail_on_timeout` call. pub fail_on_timeout_tera_gas: Option, + /// Prepaid gas for a `fail_attestation_submission` call. + pub fail_attestation_submission_tera_gas: Option, /// Prepaid gas for a `clean_tee_status` call. pub clean_tee_status_tera_gas: Option, /// Prepaid gas for the reshare-time `clean_invalid_attestations` promise. @@ -49,6 +51,12 @@ pub struct InitConfig { pub clean_foreign_chain_data_tera_gas: Option, /// Prepaid gas for a `remove_non_participant_tee_verifier_votes` call. pub remove_non_participant_tee_verifier_votes_tera_gas: Option, + /// Gas attached to the cross-contract `verify_quote` call on the verifier. + pub verifier_tera_gas: Option, + /// Prepaid gas for the `resolve_verification` callback. + pub resolve_verification_tera_gas: Option, + /// Prepaid gas for the `on_attestation_verified` yield-callback. + pub on_attestation_verified_tera_gas: Option, } /// Configuration parameters of the contract. @@ -87,6 +95,8 @@ pub struct Config { pub return_ck_and_clean_state_on_success_call_tera_gas: u64, /// Prepaid gas for a `fail_on_timeout` call. pub fail_on_timeout_tera_gas: u64, + /// Prepaid gas for a `fail_attestation_submission` call. + pub fail_attestation_submission_tera_gas: u64, /// Prepaid gas for a `clean_tee_status` call. pub clean_tee_status_tera_gas: u64, /// Prepaid gas for the reshare-time `clean_invalid_attestations` promise. @@ -99,6 +109,12 @@ pub struct Config { pub clean_foreign_chain_data_tera_gas: u64, /// Prepaid gas for a `remove_non_participant_tee_verifier_votes` call. pub remove_non_participant_tee_verifier_votes_tera_gas: u64, + /// Gas attached to the cross-contract `verify_quote` call on the verifier. + pub verifier_tera_gas: u64, + /// Prepaid gas for the `resolve_verification` callback. + pub resolve_verification_tera_gas: u64, + /// Prepaid gas for the `on_attestation_verified` yield-callback. + pub on_attestation_verified_tera_gas: u64, } #[cfg(test)] @@ -117,12 +133,16 @@ mod tests { return_signature_and_clean_state_on_success_call_tera_gas: Some(7), return_ck_and_clean_state_on_success_call_tera_gas: Some(7), fail_on_timeout_tera_gas: Some(2), + fail_attestation_submission_tera_gas: Some(2), clean_tee_status_tera_gas: Some(10), clean_invalid_attestations_tera_gas: Some(10), cleanup_orphaned_node_migrations_tera_gas: Some(3), remove_non_participant_update_votes_tera_gas: Some(5), clean_foreign_chain_data_tera_gas: Some(5), remove_non_participant_tee_verifier_votes_tera_gas: Some(5), + verifier_tera_gas: Some(100), + resolve_verification_tera_gas: Some(60), + on_attestation_verified_tera_gas: Some(10), }; let json = serde_json::to_string(&original_config).unwrap(); let serialized_and_deserialized_config: InitConfig = serde_json::from_str(&json).unwrap(); @@ -167,12 +187,16 @@ mod tests { return_signature_and_clean_state_on_success_call_tera_gas: None, return_ck_and_clean_state_on_success_call_tera_gas: None, fail_on_timeout_tera_gas: None, + fail_attestation_submission_tera_gas: None, clean_tee_status_tera_gas: None, clean_invalid_attestations_tera_gas: None, cleanup_orphaned_node_migrations_tera_gas: None, remove_non_participant_update_votes_tera_gas: None, clean_foreign_chain_data_tera_gas: None, remove_non_participant_tee_verifier_votes_tera_gas: None, + verifier_tera_gas: None, + resolve_verification_tera_gas: None, + on_attestation_verified_tera_gas: None, }; assert_eq!(default_config, config_with_all_values_as_none); diff --git a/crates/test-utils/src/contract_types.rs b/crates/test-utils/src/contract_types.rs index 6334ab1536..9b64d77631 100644 --- a/crates/test-utils/src/contract_types.rs +++ b/crates/test-utils/src/contract_types.rs @@ -15,5 +15,9 @@ pub fn dummy_config(value: u64) -> near_mpc_contract_interface::types::Config { remove_non_participant_update_votes_tera_gas: value + 11, clean_foreign_chain_data_tera_gas: value + 12, remove_non_participant_tee_verifier_votes_tera_gas: value + 13, + verifier_tera_gas: value + 14, + resolve_verification_tera_gas: value + 15, + on_attestation_verified_tera_gas: value + 16, + fail_attestation_submission_tera_gas: value + 17, } } From 16ce2263b853e4c61fe2a83f8467393c93a81499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 1 Jul 2026 12:50:23 +0200 Subject: [PATCH 02/28] fix(contract): address review on async attestation flow - refund the attached deposit when a participant refreshes an existing attestation (charge_attestation_storage early-return kept it silently) - add v3_13_0_state migration shadow so migrate() can upgrade from a deployed 3.13.0 layout, not just 3.12.0 - collapse the two per-arm debug_asserts in on_attestation_verified into one guarding both resolved paths, with a note on why cleanup stays in resolve_verification (its remove-before-store gates the timeout race) - nits: from_yoctonear(0) on the fail-call; drop a redundant yield comment; reframe the promise_yield_resume comment; PendingAttestation fields pub(crate); document the verify_quote wire args; benchmark TODOs on the new gas defaults - TODO(#3720): stash only tcb_info in PendingAttestation (follow-up) --- crates/contract/src/lib.rs | 32 +++- .../contract/src/tee/pending_attestation.rs | 16 +- crates/contract/src/v3_13_0_state.rs | 144 ++++++++++++++++++ 3 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 crates/contract/src/v3_13_0_state.rs diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 9e927a54c8..348215e01c 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -17,6 +17,7 @@ pub mod update; pub mod utils; pub mod v3_12_0_state; +pub mod v3_13_0_state; #[cfg(feature = "bench-contract-methods")] mod bench; @@ -904,9 +905,6 @@ impl MpcContract { }, ); - // The yield is the method's return value: `enqueue_yield_request` called - // `promise_return` as the final host call, so returning unit here adds no - // `value_return` that would override it. Ok(()) } @@ -920,8 +918,9 @@ impl MpcContract { ) -> Result<(), Error> { let is_new_attestation = matches!(insertion, ParticipantInsertion::NewlyInsertedParticipant); - // A participant refreshing an existing attestation is not charged. + if caller_is_participant && !is_new_attestation { + refund_attestation_deposit(account_id, attached); return Ok(()); } @@ -2161,6 +2160,14 @@ impl MpcContract { 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.13.0 state: {:?}", err); + } + }; + match try_state_read::() { Ok(Some(state)) => return Ok(state.into()), Ok(None) => return Err(InvalidState::ContractStateIsMissing.into()), @@ -2415,7 +2422,7 @@ impl MpcContract { refund_attestation_deposit(&account_id, pending.attached_deposit); } // MUST be the last host call: anything after could panic and roll back - // the state mutations above. + // the state mutations above env::promise_yield_resume( &pending.data_id, serde_json::to_vec(&attestation_result) @@ -2482,8 +2489,17 @@ impl MpcContract { #[callback_result] result: Result, ) -> PromiseOrValue<()> { let reason = match result { - Ok(AttestationResult::Ok) => return PromiseOrValue::Value(()), - Ok(AttestationResult::Err(reason)) => reason, + Ok(resolved) => { + // resolve_verification already removed the entry and refunded on failure. It + // removes before storing, so a verifier reply that arrives after the + // ~200-block yield-resume timeout (handled by the Err arm below) bails + // instead of storing an attestation whose deposit was already refunded. + debug_assert!(!self.pending_attestations.contains_key(&account_id)); + match resolved { + AttestationResult::Ok => return PromiseOrValue::Value(()), + AttestationResult::Err(reason) => reason, + } + } Err(_promise_err) => { // Timeout: the resolution callback never resumed us, so the // pending entry is still here. Clean it up and refund. @@ -2500,7 +2516,7 @@ impl MpcContract { let promise = Promise::new(env::current_account_id()).function_call( method_names::FAIL_ATTESTATION_SUBMISSION.to_string(), borsh::to_vec(&reason).expect("borsh serialization of reason must succeed"), - NearToken::from_near(0), + NearToken::from_yoctonear(0), Gas::from_tgas(self.config.fail_attestation_submission_tera_gas), ); PromiseOrValue::Promise(promise.as_return()) diff --git a/crates/contract/src/tee/pending_attestation.rs b/crates/contract/src/tee/pending_attestation.rs index ce9fc5cc57..20f1374ec0 100644 --- a/crates/contract/src/tee/pending_attestation.rs +++ b/crates/contract/src/tee/pending_attestation.rs @@ -13,20 +13,22 @@ use near_sdk::{CryptoHash, NearToken, near}; /// One in-flight verification per submitter account. #[derive(Debug, BorshSerialize, BorshDeserialize)] pub struct PendingAttestation { - /// The submitted payload the post-DCAP checks consume once the verifier - /// returns its report. - pub dstack: DstackAttestation, + /// The submitted payload. Only `tcb_info` is read after the verifier callback; + /// `quote`/`collateral` are consumed once for the pre-callback `verify_quote` + /// call, so storing the whole struct holds ~2-8 KiB of dead bytes per in-flight + /// entry. TODO(#3720): stash only `tcb_info`. + pub(crate) dstack: DstackAttestation, /// Checked against the quote's report-data during the post-DCAP checks. - pub tls_public_key: Ed25519PublicKey, + pub(crate) tls_public_key: Ed25519PublicKey, /// Stashed because the deposit is not visible from the callback receipt: /// consumed for storage on success, refunded on failure. - pub attached_deposit: NearToken, + pub(crate) attached_deposit: NearToken, /// Participant status at submit time, which decides whether the caller pays /// for storage. Captured because the callback receipt is no longer the /// caller, so it can no longer be re-derived. - pub caller_is_participant: bool, + pub(crate) caller_is_participant: bool, /// Yield handle, read back by the callback to resume the yield. - pub data_id: CryptoHash, + pub(crate) data_id: CryptoHash, } #[near(serializers = [json])] diff --git a/crates/contract/src/v3_13_0_state.rs b/crates/contract/src/v3_13_0_state.rs new file mode 100644 index 0000000000..226654f272 --- /dev/null +++ b/crates/contract/src/v3_13_0_state.rs @@ -0,0 +1,144 @@ +//! ## Overview +//! Shadows the contract state written by the `3.13.0` release so [`crate::migrate`] +//! can upgrade from it. See [`crate::v3_12_0_state`] for the rationale and guideline. +//! +//! `3.13.0` differs from the live layout only by the two fields this version adds: +//! `Config::fail_attestation_submission_tera_gas` (and the three verifier gas knobs, +//! all defaulted here) and the `MpcContract::pending_attestations` map. + +use borsh::{BorshDeserialize, BorshSerialize}; +use near_mpc_contract_interface::types::{Metrics, VerifyForeignTransactionRequest}; +use near_sdk::{ + AccountId, env, + store::{Lazy, LookupMap}, +}; + +use crate::{ + SupportedForeignChainsByNode, + foreign_chains_metadata::ForeignChainsMetadata, + node_migrations::NodeMigrations, + primitives::{ + ckd::CKDRequest, + domain::max_reconstruction_threshold, + signature::{SignatureRequest, YieldIndex}, + thresholds::ThresholdParameters, + }, + state::{ProtocolContractState, running::RunningContractState}, + storage_keys::StorageKey, + tee::{tee_state::TeeState, verifier_votes::TeeVerifierVotes}, + update::ProposedUpdates, +}; + +/// The `Config` layout written by the `3.13.0` contract, before +/// `fail_attestation_submission_tera_gas` and the verifier gas knobs were added. +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct OldConfig { + key_event_timeout_blocks: u64, + tee_upgrade_deadline_duration_seconds: u64, + contract_upgrade_deposit_tera_gas: u64, + sign_call_gas_attachment_requirement_tera_gas: u64, + ckd_call_gas_attachment_requirement_tera_gas: u64, + return_signature_and_clean_state_on_success_call_tera_gas: u64, + return_ck_and_clean_state_on_success_call_tera_gas: u64, + fail_on_timeout_tera_gas: u64, + clean_tee_status_tera_gas: u64, + clean_invalid_attestations_tera_gas: u64, + cleanup_orphaned_node_migrations_tera_gas: u64, + remove_non_participant_update_votes_tera_gas: u64, + clean_foreign_chain_data_tera_gas: u64, + remove_non_participant_tee_verifier_votes_tera_gas: u64, +} + +impl From for crate::Config { + fn from(old: OldConfig) -> Self { + crate::Config { + key_event_timeout_blocks: old.key_event_timeout_blocks, + tee_upgrade_deadline_duration_seconds: old.tee_upgrade_deadline_duration_seconds, + contract_upgrade_deposit_tera_gas: old.contract_upgrade_deposit_tera_gas, + sign_call_gas_attachment_requirement_tera_gas: old + .sign_call_gas_attachment_requirement_tera_gas, + ckd_call_gas_attachment_requirement_tera_gas: old + .ckd_call_gas_attachment_requirement_tera_gas, + return_signature_and_clean_state_on_success_call_tera_gas: old + .return_signature_and_clean_state_on_success_call_tera_gas, + return_ck_and_clean_state_on_success_call_tera_gas: old + .return_ck_and_clean_state_on_success_call_tera_gas, + fail_on_timeout_tera_gas: old.fail_on_timeout_tera_gas, + clean_tee_status_tera_gas: old.clean_tee_status_tera_gas, + clean_invalid_attestations_tera_gas: old.clean_invalid_attestations_tera_gas, + cleanup_orphaned_node_migrations_tera_gas: old + .cleanup_orphaned_node_migrations_tera_gas, + remove_non_participant_update_votes_tera_gas: old + .remove_non_participant_update_votes_tera_gas, + clean_foreign_chain_data_tera_gas: old.clean_foreign_chain_data_tera_gas, + remove_non_participant_tee_verifier_votes_tera_gas: old + .remove_non_participant_tee_verifier_votes_tera_gas, + // New in this version: the attestation fail-call and verifier-call gas + // knobs, added alongside the async attestation flow. + ..crate::Config::default() + } + } +} + +/// Keep this module in sync with [`crate::MpcContract`]: it is the `3.13.0` layout, +/// which differs only by the appended `pending_attestations` map. +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct MpcContract { + protocol_state: ProtocolContractState, + pending_signature_requests: LookupMap>, + pending_ckd_requests: LookupMap>, + pending_verify_foreign_tx_requests: LookupMap>, + proposed_updates: ProposedUpdates, + node_foreign_chain_support: SupportedForeignChainsByNode, + config: OldConfig, + tee_state: TeeState, + accept_requests: bool, + node_migrations: NodeMigrations, + metrics: Metrics, + foreign_chains: Lazy, + tee_verifier_account_id: Option, + tee_verifier_votes: TeeVerifierVotes, +} + +impl From for crate::MpcContract { + fn from(old: MpcContract) -> Self { + if let ProtocolContractState::Running(running) = &old.protocol_state { + validate_threshold_relation_on_migration(running); + } + + crate::MpcContract { + protocol_state: old.protocol_state, + pending_signature_requests: old.pending_signature_requests, + pending_ckd_requests: old.pending_ckd_requests, + pending_verify_foreign_tx_requests: old.pending_verify_foreign_tx_requests, + proposed_updates: old.proposed_updates, + node_foreign_chain_support: old.node_foreign_chain_support, + config: old.config.into(), + tee_state: old.tee_state, + accept_requests: old.accept_requests, + node_migrations: old.node_migrations, + metrics: old.metrics, + foreign_chains: old.foreign_chains, + tee_verifier_account_id: old.tee_verifier_account_id, + tee_verifier_votes: old.tee_verifier_votes, + pending_attestations: LookupMap::new(StorageKey::PendingAttestations), + } + } +} + +fn validate_threshold_relation_on_migration(running: &RunningContractState) { + let num_participants = running.parameters.participants().len() as u64; + let max_reconstruction_threshold = max_reconstruction_threshold(running.domains.domains()); + if let Err(err) = ThresholdParameters::validate_governance_against_reconstruction( + num_participants, + running.parameters.threshold(), + max_reconstruction_threshold, + ) { + env::panic_str(&format!( + "Migration aborted: existing state violates the GovernanceThreshold/ReconstructionThreshold relation ({err:?}). num_participants={}, governance_threshold={}, max_reconstruction_threshold={:?}. Correct it via vote_new_parameters before upgrading.", + num_participants, + running.parameters.threshold().value(), + max_reconstruction_threshold.map(|t| t.inner()), + )); + } +} From 43e3be69abf6f74018a2e291a614fb040c627dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 1 Jul 2026 15:00:40 +0200 Subject: [PATCH 03/28] refactor(contract): clarify async attestation names and comments Rename the attestation verify-and-store helpers so they describe what they do rather than when they run: - add_mock_participant -> verify_and_store_mock - finish_dstack_verify -> verify_and_store_dstack - finish_verified_attestation -> verify_post_dcap_and_store Also tighten the resolve_verification and on_attestation_verified comments: scope the no-verdict note to the Err arm, name the ~200-block yield-resume timeout explicitly, and trim the debug_assert rationale to the single invariant it rests on --- crates/contract/src/lib.rs | 33 +++++------- crates/contract/src/tee/tee_state.rs | 78 +++++++++++++--------------- 2 files changed, 51 insertions(+), 60 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 348215e01c..6860512351 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -826,7 +826,7 @@ impl MpcContract { let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); let initial_storage = env::storage_usage(); - let insertion = self.tee_state.add_mock_participant( + let insertion = self.tee_state.verify_and_store_mock( node_id, mock, tee_upgrade_deadline_duration, @@ -869,8 +869,7 @@ impl MpcContract { let tls_public_key = node_id.tls_public_key.clone(); // Call the verifier; `resolve_verification` bridges its response back into - // the yield registered below. Scheduled before `enqueue_yield_request` so - // that helper's `promise_return` stays the method's last host call. + // the yield registered below Promise::new(verifier_account_id) .function_call( method_names::VERIFY_QUOTE.to_string(), @@ -2388,10 +2387,10 @@ impl MpcContract { ) { let account_id = node_id.account_id.clone(); - // No verdict (verifier unreachable, panicked, or out of gas). Don't resume; - // the yield timeout fires `on_attestation_verified` to clean up and refund. let result = match result { Ok(result) => result, + // No verdict (verifier unreachable, panicked, or out of gas). Don't resume; + // the yield timeout fires `on_attestation_verified` to clean up and refund. Err(promise_err) => { log!("verifier did not answer for {account_id}: {promise_err:?}"); return; @@ -2414,7 +2413,7 @@ impl MpcContract { AttestationResult::Err(format!("verifier rejected quote: {reason}")) } VerificationResult::Verified(report) => { - self.finish_verified_attestation(&node_id, &pending, &report) + self.verify_post_dcap_and_store(&node_id, &pending, &report) } }; @@ -2434,7 +2433,7 @@ impl MpcContract { /// [`VerificationResult::Verified`] response, returning the outcome to resume /// the yield with. On failure it reverts the store explicitly, since the /// callback receipt commits regardless (unlike the synchronous path). - fn finish_verified_attestation( + fn verify_post_dcap_and_store( &mut self, node_id: &NodeId, pending: &PendingAttestation, @@ -2445,7 +2444,7 @@ impl MpcContract { Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); let initial_storage = env::storage_usage(); - let insertion = match self.tee_state.finish_dstack_verify( + let insertion = match self.tee_state.verify_and_store_dstack( node_id.clone(), &pending.dstack, report, @@ -2490,10 +2489,7 @@ impl MpcContract { ) -> PromiseOrValue<()> { let reason = match result { Ok(resolved) => { - // resolve_verification already removed the entry and refunded on failure. It - // removes before storing, so a verifier reply that arrives after the - // ~200-block yield-resume timeout (handled by the Err arm below) bails - // instead of storing an attestation whose deposit was already refunded. + // `resolve_verification` removes the entry before resuming debug_assert!(!self.pending_attestations.contains_key(&account_id)); match resolved { AttestationResult::Ok => return PromiseOrValue::Value(()), @@ -2501,8 +2497,7 @@ impl MpcContract { } } Err(_promise_err) => { - // Timeout: the resolution callback never resumed us, so the - // pending entry is still here. Clean it up and refund. + // ~200-block yield-resume timeout. Clean it up and refund. if let Some(pending) = self.pending_attestations.remove(&account_id) { refund_attestation_deposit(&account_id, pending.attached_deposit); log!("yield timeout for {account_id}: refunded and cleaned up"); @@ -2512,7 +2507,7 @@ impl MpcContract { }; // Fail the submitter's transaction from a separate receipt so the - // cleanup above commits (a panic here would roll it back). + // cleanup above commits (a panic here would roll it back) let promise = Promise::new(env::current_account_id()).function_call( method_names::FAIL_ATTESTATION_SUBMISSION.to_string(), borsh::to_vec(&reason).expect("borsh serialization of reason must succeed"), @@ -5293,7 +5288,7 @@ mod tests { let tee_upgrade_duration = Duration::from_secs(contract.config.tee_upgrade_deadline_duration_seconds); - let insertion_result = contract.tee_state.add_mock_participant( + let insertion_result = contract.tee_state.verify_and_store_mock( NodeId { account_id: self.signer_account_id.clone(), tls_public_key: self.attestation_tls_key.clone(), @@ -5955,7 +5950,7 @@ mod tests { }; contract .tee_state - .add_mock_participant(node_id, expiring_attestation, TEE_UPGRADE_DURATION) + .verify_and_store_mock(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 @@ -6074,7 +6069,7 @@ mod tests { }; contract .tee_state - .add_mock_participant(node_id, expiring_attestation, TEE_UPGRADE_DURATION) + .verify_and_store_mock(node_id, expiring_attestation, TEE_UPGRADE_DURATION) .expect("mock attestation is not yet expired and valid"); let (first_account_id, _, _) = &participant_list[0]; @@ -7513,7 +7508,7 @@ mod tests { // Add attestation for the new node (mirrors what ConcludeNodeMigrationTestSetup::setup does). contract .tee_state - .add_mock_participant( + .verify_and_store_mock( NodeId { account_id: operator4.clone(), tls_public_key: new_tls_key.clone(), diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 13dba9d5c2..5dfeaff001 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -153,7 +153,7 @@ impl TeeState { env::block_timestamp_ms() / 1_000 } - pub(crate) fn add_mock_participant( + pub(crate) fn verify_and_store_mock( &mut self, node_id: NodeId, mock: MockAttestation, @@ -177,7 +177,7 @@ impl TeeState { /// Runs the post-DCAP checks for a [`Attestation::Dstack`] attestation /// against the [`VerifiedReport`] the verifier returned, then stores the /// result. - pub(crate) fn finish_dstack_verify( + pub(crate) fn verify_and_store_dstack( &mut self, node_id: NodeId, dstack: &DstackAttestation, @@ -211,13 +211,6 @@ impl TeeState { report_data.into() } - /// Stores an already-verified attestation, rejecting a TLS key owned by a - /// different account. - /// - /// On an update, the returned [`ParticipantInsertion::UpdatedExistingParticipant`] - /// carries the displaced [`NodeAttestation`]; the Dstack path uses it to undo - /// this store via [`Self::revert_dstack_store`], because its callback receipt - /// commits even when the later storage charge fails. fn store_verified_attestation( &mut self, node_id: NodeId, @@ -249,7 +242,7 @@ impl TeeState { }) } - /// Undoes a [`Self::finish_dstack_verify`] store: restores the displaced + /// Undoes a [`Self::verify_and_store_dstack`] store: restores the displaced /// entry, or removes the newly-inserted one if there was none. Used by the /// async flow when the storage charge fails after the store, so a caller /// can't get storage for free in a receipt that still commits. @@ -657,7 +650,7 @@ mod tests { for node_id in &participant_nodes { tee_state - .add_mock_participant( + .verify_and_store_mock( node_id.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, @@ -665,7 +658,7 @@ mod tests { .unwrap(); } tee_state - .add_mock_participant( + .verify_and_store_mock( non_participant_uid.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, @@ -730,10 +723,10 @@ mod tests { }; tee_state - .add_mock_participant(fresh_node.clone(), fresh, Duration::from_secs(0)) + .verify_and_store_mock(fresh_node.clone(), fresh, Duration::from_secs(0)) .unwrap(); tee_state - .add_mock_participant(stale_node.clone(), stale, Duration::from_secs(0)) + .verify_and_store_mock(stale_node.clone(), stale, Duration::from_secs(0)) .unwrap(); assert_eq!(tee_state.stored_attestations.len(), 2); @@ -780,7 +773,7 @@ mod tests { account_public_key: bogus_ed25519_public_key(), }; tee_state - .add_mock_participant(node_id, expired.clone(), Duration::from_secs(0)) + .verify_and_store_mock(node_id, expired.clone(), Duration::from_secs(0)) .unwrap(); } assert_eq!(tee_state.stored_attestations.len(), 10); @@ -823,7 +816,7 @@ mod tests { expected_measurements: None, }; tee_state - .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // When: cleanup runs while the attestation is still valid. @@ -853,7 +846,7 @@ mod tests { tls_public_key: bogus_ed25519_public_key(), }; - let insertion_result = tee_state.add_mock_participant( + let insertion_result = tee_state.verify_and_store_mock( participant_id.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, @@ -864,7 +857,7 @@ mod tests { ); // when - let re_insertion_result = tee_state.add_mock_participant( + let re_insertion_result = tee_state.verify_and_store_mock( participant_id.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, @@ -890,7 +883,7 @@ mod tests { // when tee_state - .add_mock_participant(node_id, attestation, Duration::from_secs(0)) + .verify_and_store_mock(node_id, attestation, Duration::from_secs(0)) .unwrap(); // then @@ -914,7 +907,7 @@ mod tests { // when tee_state - .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // then @@ -939,7 +932,7 @@ mod tests { // when tee_state - .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // then @@ -973,14 +966,14 @@ mod tests { // when tee_state - .add_mock_participant( + .verify_and_store_mock( node_1.clone(), MockAttestation::Valid, Duration::from_secs(0), ) .unwrap(); tee_state - .add_mock_participant( + .verify_and_store_mock( node_2.clone(), MockAttestation::Valid, Duration::from_secs(0), @@ -1023,7 +1016,7 @@ mod tests { }; tee_state - .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // when @@ -1056,7 +1049,7 @@ mod tests { }; tee_state - .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // when @@ -1101,7 +1094,7 @@ mod tests { }; tee_state - .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // when @@ -1154,7 +1147,7 @@ mod tests { account_public_key: Ed25519PublicKey::try_from(&signer_pk).unwrap(), }; tee_state - .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) + .verify_and_store_mock(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("Attestation is valid on insertion"); // 4. Verify check passes @@ -1215,7 +1208,7 @@ mod tests { account_public_key: Ed25519PublicKey::try_from(&signer_pk).unwrap(), }; tee_state - .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) + .verify_and_store_mock(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("Attestation is valid on insertion"); let result = tee_state.is_caller_an_attested_participant(&participants); @@ -1247,7 +1240,7 @@ mod tests { account_public_key: old_signer_pk, // Mismatch here }; tee_state - .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) + .verify_and_store_mock(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("Attestation is valid on insertion"); // when @@ -1290,7 +1283,7 @@ mod tests { for (account_id, _, participant_info) in participants.participants().iter() { let node_id = create_node_id(account_id, &participant_info.tls_public_key); tee_state - .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) + .verify_and_store_mock(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("mock attestation is valid"); } @@ -1311,7 +1304,7 @@ mod tests { for (account_id, _, participant_info) in participant_list.iter().take(2) { let node_id = create_node_id(account_id, &participant_info.tls_public_key); tee_state - .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) + .verify_and_store_mock(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("mock attestation is valid"); } // Third participant has no attestation @@ -1341,7 +1334,7 @@ mod tests { for (account_id, _, participant_info) in participant_list.iter().take(2) { let node_id = create_node_id(account_id, &participant_info.tls_public_key); tee_state - .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) + .verify_and_store_mock(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("mock attestation is valid"); } @@ -1355,7 +1348,7 @@ mod tests { expected_measurements: None, }; tee_state - .add_mock_participant(node_id, expiring_attestation, tee_upgrade_duration) + .verify_and_store_mock(node_id, expiring_attestation, tee_upgrade_duration) .expect("mock attestation is valid"); // Advance time to exact expiry boundary @@ -1398,7 +1391,7 @@ mod tests { MockAttestation::Valid }; tee_state - .add_mock_participant(node_id, attestation, tee_upgrade_duration) + .verify_and_store_mock(node_id, attestation, tee_upgrade_duration) .expect("mock attestation is valid"); } @@ -1429,7 +1422,7 @@ mod tests { account_public_key: bogus_ed25519_public_key(), }; tee_state - .add_mock_participant( + .verify_and_store_mock( alice_node.clone(), MockAttestation::Valid, TEE_UPGRADE_DURATION, @@ -1442,7 +1435,7 @@ mod tests { tls_public_key: tls_public_key.clone(), account_public_key: bogus_ed25519_public_key(), }; - let result = tee_state.add_mock_participant( + let result = tee_state.verify_and_store_mock( attacker_node, MockAttestation::Valid, TEE_UPGRADE_DURATION, @@ -1474,7 +1467,7 @@ mod tests { account_public_key: bogus_ed25519_public_key(), }; tee_state - .add_mock_participant(initial_node, MockAttestation::Valid, TEE_UPGRADE_DURATION) + .verify_and_store_mock(initial_node, MockAttestation::Valid, TEE_UPGRADE_DURATION) .expect("initial insertion should succeed"); // When: the same account resubmits with a rotated account_public_key. @@ -1483,7 +1476,7 @@ mod tests { tls_public_key, account_public_key: bogus_ed25519_public_key(), }; - let result = tee_state.add_mock_participant( + let result = tee_state.verify_and_store_mock( rotated_node.clone(), MockAttestation::Valid, TEE_UPGRADE_DURATION, @@ -1512,15 +1505,18 @@ mod tests { for (account_id, _, participant_info) in participant_list.iter().take(2) { let node_id = create_node_id(account_id, &participant_info.tls_public_key); tee_state - .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) + .verify_and_store_mock(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("mock attestation is valid"); } // Add invalid attestation for third participant let (account_id, _, participant_info) = &participant_list[2]; let node_id = create_node_id(account_id, &participant_info.tls_public_key); - let add_participant_result = - tee_state.add_mock_participant(node_id, MockAttestation::Invalid, tee_upgrade_duration); + let add_participant_result = tee_state.verify_and_store_mock( + node_id, + MockAttestation::Invalid, + tee_upgrade_duration, + ); assert_matches!( add_participant_result, From 79dc9fe4a7a7a4112df51d5991db76eb706120a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 1 Jul 2026 15:00:40 +0200 Subject: [PATCH 04/28] docs(contract): fix migrate doc link in v3_13_0_state --- crates/contract/src/v3_13_0_state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/src/v3_13_0_state.rs b/crates/contract/src/v3_13_0_state.rs index 226654f272..2de7185d00 100644 --- a/crates/contract/src/v3_13_0_state.rs +++ b/crates/contract/src/v3_13_0_state.rs @@ -1,5 +1,5 @@ //! ## Overview -//! Shadows the contract state written by the `3.13.0` release so [`crate::migrate`] +//! Shadows the contract state written by the `3.13.0` release so [`crate::MpcContract::migrate`] //! can upgrade from it. See [`crate::v3_12_0_state`] for the rationale and guideline. //! //! `3.13.0` differs from the live layout only by the two fields this version adds: From c21a4db8dff373b2cccdeafd23325af693801676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 1 Jul 2026 17:09:43 +0200 Subject: [PATCH 05/28] fix(contract): address review nits on async attestation - Fix broken rustdoc intra-doc link in tee_state.rs (Attestation was not in scope; use the in-scope DstackAttestation), which the workspace broken_intra_doc_links=deny lint would fail CI on - Correct the v3_13_0_state module doc field count (four Config gas fields plus the pending_attestations map, not two) - Rename stale add_participant_* tests to the verify_and_store_mock SUT and the __should_ form - Fix a/an grammar in an Attestation::Dstack doc comment --- crates/contract/src/lib.rs | 2 +- crates/contract/src/tee/tee_state.rs | 17 ++++++++--------- crates/contract/src/v3_13_0_state.rs | 7 ++++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 6860512351..51d338c4f1 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -2477,7 +2477,7 @@ impl MpcContract { } } - /// Yield-resume callback for a [`Attestation::Dstack`] submission. On + /// Yield-resume callback for an [`Attestation::Dstack`] submission. On /// success it resolves the caller's transaction; on a rejection or the /// ~200-block timeout it cleans up, refunds, and fails from a separate /// receipt. diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 5dfeaff001..c93541bed0 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -174,9 +174,8 @@ impl TeeState { self.store_verified_attestation(node_id, verified_attestation) } - /// Runs the post-DCAP checks for a [`Attestation::Dstack`] attestation - /// against the [`VerifiedReport`] the verifier returned, then stores the - /// result. + /// Runs the post-DCAP checks for a [`DstackAttestation`] against the + /// [`VerifiedReport`] the verifier returned, then stores the result. pub(crate) fn verify_and_store_dstack( &mut self, node_id: NodeId, @@ -871,7 +870,7 @@ mod tests { } #[test] - fn add_participant_increases_storage_size() { + fn verify_and_store_mock__should_increase_storage_size() { // given let mut tee_state = TeeState::default(); let node_id = NodeId { @@ -895,7 +894,7 @@ mod tests { } #[test] - fn add_participant_indexes_by_tls_key() { + fn verify_and_store_mock__should_index_by_tls_key() { // given let mut tee_state = TeeState::default(); let node_id = NodeId { @@ -920,7 +919,7 @@ mod tests { } #[test] - fn add_participant_preserves_node_id_integrity() { + fn verify_and_store_mock__should_preserve_node_id_integrity() { // given let mut tee_state = TeeState::default(); let node_id = NodeId { @@ -1409,7 +1408,7 @@ mod tests { } #[test] - fn add_participant__should_reject_tls_key_owned_by_other_account() { + fn verify_and_store_mock__should_reject_tls_key_owned_by_other_account() { // Given: an existing attestation registered to `alice.near` under some TLS key. const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); @@ -1454,7 +1453,7 @@ mod tests { } #[test] - fn add_participant__should_allow_same_account_to_update_its_own_entry() { + fn verify_and_store_mock__should_allow_same_account_to_update_its_own_entry() { // Given: an existing attestation registered to `alice.near`. const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); @@ -1495,7 +1494,7 @@ mod tests { } #[test] - fn add_participant_rejects_invalid_attestations() { + fn verify_and_store_mock__should_reject_invalid_attestations() { let mut tee_state = TeeState::default(); let participants = gen_participants(3); let participant_list: Vec<_> = participants.participants().to_vec(); diff --git a/crates/contract/src/v3_13_0_state.rs b/crates/contract/src/v3_13_0_state.rs index 2de7185d00..e0170cd524 100644 --- a/crates/contract/src/v3_13_0_state.rs +++ b/crates/contract/src/v3_13_0_state.rs @@ -2,9 +2,10 @@ //! Shadows the contract state written by the `3.13.0` release so [`crate::MpcContract::migrate`] //! can upgrade from it. See [`crate::v3_12_0_state`] for the rationale and guideline. //! -//! `3.13.0` differs from the live layout only by the two fields this version adds: -//! `Config::fail_attestation_submission_tera_gas` (and the three verifier gas knobs, -//! all defaulted here) and the `MpcContract::pending_attestations` map. +//! `3.13.0` differs from the live layout by four appended `Config` gas fields +//! (`fail_attestation_submission_tera_gas`, `verifier_tera_gas`, +//! `resolve_verification_tera_gas`, `on_attestation_verified_tera_gas`), all +//! defaulted here, and the appended `MpcContract::pending_attestations` map. use borsh::{BorshDeserialize, BorshSerialize}; use near_mpc_contract_interface::types::{Metrics, VerifyForeignTransactionRequest}; From fd2291fda94965e94598b863fd3f8762b9a5b616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 1 Jul 2026 17:22:25 +0200 Subject: [PATCH 06/28] test(contract): sync ABI snapshot with a/an doc fix The a->an grammar fix in on_attestation_verified's doc comment changed the doc string embedded in the ABI, which test_abi_has_not_changed compares against the committed snapshot. Update the snapshot to match --- crates/contract/tests/snapshots/abi__abi_has_not_changed.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 7f626962d0..4cc52a3414 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -955,7 +955,7 @@ expression: abi }, { "name": "on_attestation_verified", - "doc": " Yield-resume callback for a [`Attestation::Dstack`] submission. On\n success it resolves the caller's transaction; on a rejection or the\n ~200-block timeout it cleans up, refunds, and fails from a separate\n receipt.", + "doc": " Yield-resume callback for an [`Attestation::Dstack`] submission. On\n success it resolves the caller's transaction; on a rejection or the\n ~200-block timeout it cleans up, refunds, and fails from a separate\n receipt.", "kind": "call", "modifiers": [ "private" From e15bc3da438dc71dc7443fc54b04ec0063e4c40e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 1 Jul 2026 17:52:59 +0200 Subject: [PATCH 07/28] refactor(contract): unify zero-NearToken spelling to from_yoctonear(0) The fail_on_timeout callback sites used NearToken::from_near(0) while the new attestation code uses from_yoctonear(0); both are zero. Unify on from_yoctonear(0) --- crates/contract/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 51d338c4f1..e106f18e93 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -2367,7 +2367,7 @@ impl MpcContract { let promise = Promise::new(env::current_account_id()).function_call( method_names::FAIL_ON_TIMEOUT.to_string(), vec![], - NearToken::from_near(0), + NearToken::from_yoctonear(0), fail_on_timeout_gas, ); near_sdk::PromiseOrValue::Promise(promise.as_return()) @@ -2542,7 +2542,7 @@ impl MpcContract { let promise = Promise::new(env::current_account_id()).function_call( method_names::FAIL_ON_TIMEOUT.to_string(), vec![], - NearToken::from_near(0), + NearToken::from_yoctonear(0), fail_on_timeout_gas, ); near_sdk::PromiseOrValue::Promise(promise.as_return()) @@ -2575,7 +2575,7 @@ impl MpcContract { let promise = Promise::new(env::current_account_id()).function_call( method_names::FAIL_ON_TIMEOUT.to_string(), vec![], - NearToken::from_near(0), + NearToken::from_yoctonear(0), fail_on_timeout_gas, ); near_sdk::PromiseOrValue::Promise(promise.as_return()) From 5e4ea2828b99f97203bda246cf44d72509c36fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 9 Jul 2026 13:01:34 +0200 Subject: [PATCH 08/28] fix(node): treat in-flight attestation verification as executed A Dstack attestation is stored on the contract only after its async verification succeeds. The node confirms a submission by checking whether the attestation is stored; while verification is still in flight it isn't, so the node observed NotExecuted and resubmitted, hitting VerificationAlreadyPending until the verifier resolved. Add an is_verification_pending contract view and consult it in observe_tx_result: when the attestation isn't stored yet but a verification is pending for the submitter, count the submission as executed so the node does not resubmit mid-verification. The ABI snapshot (abi__abi_has_not_changed.snap) needs regenerating for the new view method; the local toolchain cannot build the contract wasm, so it must be updated in CI or on a 1.86-compatible toolchain. --- crates/contract/src/lib.rs | 7 ++++ .../src/method_names.rs | 1 + crates/node/src/indexer.rs | 39 ++++++++++++++++++- crates/node/src/indexer/tx_sender.rs | 17 +++++++- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index e106f18e93..af42d2a59f 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -962,6 +962,13 @@ impl MpcContract { })) } + /// Whether the account has an [`Attestation::Dstack`] submission awaiting + /// async verification, so a submitter can tell "in flight" from "never + /// landed" rather than resubmit and hit a [`TeeError::VerificationAlreadyPending`]. + pub fn is_verification_pending(&self, account_id: AccountId) -> bool { + self.pending_attestations.contains_key(&account_id) + } + /// 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. diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index fde652c2d8..78f02525ef 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -84,6 +84,7 @@ pub const GET_PENDING_CKD_REQUEST: &str = "get_pending_ckd_request"; pub const GET_PENDING_VERIFY_FOREIGN_TX_REQUEST: &str = "get_pending_verify_foreign_tx_request"; pub const GET_TEE_ACCOUNTS: &str = "get_tee_accounts"; pub const GET_ATTESTATION: &str = "get_attestation"; +pub const IS_VERIFICATION_PENDING: &str = "is_verification_pending"; pub const GET_SUPPORTED_FOREIGN_CHAINS: &str = "get_supported_foreign_chains"; pub const GET_FOREIGN_CHAIN_SUPPORT_BY_NODE: &str = "get_foreign_chain_support_by_node"; pub const GET_AVAILABLE_FOREIGN_CHAINS: &str = "get_available_foreign_chains"; diff --git a/crates/node/src/indexer.rs b/crates/node/src/indexer.rs index 0f39c404ed..a228846c0d 100644 --- a/crates/node/src/indexer.rs +++ b/crates/node/src/indexer.rs @@ -28,7 +28,7 @@ use near_mpc_contract_interface::method_names::{ ALLOWED_DOCKER_IMAGE_HASHES, ALLOWED_FOREIGN_CHAIN_PROVIDERS, ALLOWED_LAUNCHER_COMPOSE_HASHES, GET_ATTESTATION, GET_PENDING_CKD_REQUEST, GET_PENDING_REQUEST, GET_PENDING_VERIFY_FOREIGN_TX_REQUEST, GET_SUPPORTED_FOREIGN_CHAINS, GET_TEE_ACCOUNTS, - MIGRATION_INFO, STATE, + IS_VERIFICATION_PENDING, MIGRATION_INFO, STATE, }; use near_mpc_contract_interface::types::{self as dtos, YieldIndex}; use participants::ContractState; @@ -262,6 +262,43 @@ impl IndexerViewClient { } } + /// Whether an async Dstack attestation verification is in flight for the + /// account, distinguishing "still verifying" from "submission never landed". + pub(crate) async fn is_verification_pending( + &self, + mpc_contract_id: &AccountId, + account_id: &AccountId, + ) -> anyhow::Result { + let args = serde_json::to_vec(&serde_json::json!({ "account_id": account_id })) + .context("failed to serialize is_verification_pending args")?; + + let request = QueryRequest::CallFunction { + account_id: mpc_contract_id.clone(), + method_name: IS_VERIFICATION_PENDING.to_string(), + args: args.into(), + }; + let query = near_client::Query { + block_reference: BlockReference::Finality(Finality::Final), + request, + }; + + let query_response = self + .view_client + .send_async(query) + .await + .context("failed to query is_verification_pending")??; + + match query_response.kind { + QueryResponseKind::CallResult(call_result) => { + serde_json::from_slice::(&call_result.result) + .context("failed to deserialize is_verification_pending response") + } + _ => { + anyhow::bail!("Unexpected result from a view client function call"); + } + } + } + pub(crate) async fn get_supported_chains( &self, mpc_contract_id: &AccountId, diff --git a/crates/node/src/indexer/tx_sender.rs b/crates/node/src/indexer/tx_sender.rs index 3161b7a970..f555a45e66 100644 --- a/crates/node/src/indexer/tx_sender.rs +++ b/crates/node/src/indexer/tx_sender.rs @@ -186,6 +186,7 @@ async fn submit_tx( /// Confirms whether the intended effect of the transaction request has been observed on chain. async fn observe_tx_result( indexer_state: Arc, + signer_account_id: &AccountId, request: &ChainSendTransactionRequest, ) -> anyhow::Result { match request { @@ -249,7 +250,18 @@ async fn observe_tx_result( .await?; let Some(stored_attestation) = attestation_stored_on_contract else { - return Ok(TransactionStatus::NotExecuted); + // A Dstack attestation is stored only once its async verification + // succeeds; while it's in flight, count the submission as executed + // so we don't resubmit while the previous submission is still pending. + let verification_pending = indexer_state + .view_client + .is_verification_pending(&indexer_state.mpc_contract_id, signer_account_id) + .await?; + return Ok(if verification_pending { + TransactionStatus::Executed + } else { + TransactionStatus::NotExecuted + }); }; let submitted_attestation = @@ -369,7 +381,8 @@ async fn ensure_send_transaction( time::sleep(TRANSACTION_TIMEOUT).await; // Then try to check whether it had the intended effect - let transaction_status = observe_tx_result(indexer_state.clone(), &request).await; + let transaction_status = + observe_tx_result(indexer_state.clone(), tx_signer.account_id(), &request).await; let (outcome_label, recorded_status) = match &transaction_status { Ok(TransactionStatus::Executed) => ("succeeded", SubmittedTransactionStatus::Executed), From 87937dbefad54b3cccbe84271d50079002a2f816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 9 Jul 2026 13:07:48 +0200 Subject: [PATCH 09/28] test(contract): sync ABI snapshot with is_verification_pending view --- .../snapshots/abi__abi_has_not_changed.snap | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 4cc52a3414..67231e12b9 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -855,6 +855,29 @@ expression: abi ] } }, + { + "name": "is_verification_pending", + "doc": " Whether the account has an [`Attestation::Dstack`] submission awaiting\n async verification, so a submitter can tell \"in flight\" from \"never\n landed\" rather than resubmit and hit a [`TeeError::VerificationAlreadyPending`].", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "account_id", + "type_schema": { + "description": "NEAR Account Identifier.\n\nThis is a unique, syntactically valid, human-readable account identifier on the NEAR network.\n\n[See the crate-level docs for information about validation.](index.html#account-id-rules)\n\nAlso see [Error kind precedence](AccountId#error-kind-precedence).\n\n## Examples\n\n``` use near_account_id::AccountId;\n\nlet alice: AccountId = \"alice.near\".parse().unwrap();\n\nassert!(\"ƒelicia.near\".parse::().is_err()); // (ƒ is not f) ```", + "type": "string" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "boolean" + } + } + }, { "name": "latest_key_version", "doc": " Key versions refer new versions of the root key that we may choose to generate on cohort\n changes. Older key versions will always work but newer key versions were never held by\n older signers. Newer key versions may also add new security features, like only existing\n within a secure enclave. The signature_scheme parameter specifies which protocol\n we're querying the latest version for. The default is Secp256k1. The default is **NOT**\n to query across all protocols.", From d9d5066066d043e342168773e42c80e0761f07cb Mon Sep 17 00:00:00 2001 From: kevindeforth <32777623+kevindeforth@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:14:22 +0400 Subject: [PATCH 10/28] refactor: alternative without yield resume (#3766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Patryk Bęza --- crates/attestation/src/attestation.rs | 1 + crates/attestation/src/tcb_info.rs | 42 ++ crates/contract/src/config.rs | 7 - crates/contract/src/dto_mapping.rs | 5 - crates/contract/src/errors.rs | 8 +- crates/contract/src/lib.rs | 261 ++++-------- ...contract_borsh_schema_has_not_changed.snap | 8 - crates/contract/src/storage_keys.rs | 1 - crates/contract/src/tee.rs | 2 +- .../contract/src/tee/pending_attestation.rs | 55 --- .../contract/src/tee/verification_context.rs | 15 + crates/contract/src/v3_12_0_state.rs | 1 - crates/contract/src/v3_13_0_state.rs | 145 ------- .../tests/inprocess/attestation_submission.rs | 12 +- .../tests/sandbox/contract_configuration.rs | 1 - .../sandbox/upgrade_from_current_contract.rs | 1 - .../snapshots/abi__abi_has_not_changed.snap | 401 +++++++++++++----- .../src/method_names.rs | 2 - .../src/types/config.rs | 6 - crates/node/src/indexer.rs | 39 +- crates/node/src/indexer/tx_sender.rs | 17 +- crates/test-utils/src/contract_types.rs | 3 +- 22 files changed, 456 insertions(+), 577 deletions(-) delete mode 100644 crates/contract/src/tee/pending_attestation.rs create mode 100644 crates/contract/src/tee/verification_context.rs delete mode 100644 crates/contract/src/v3_13_0_state.rs diff --git a/crates/attestation/src/attestation.rs b/crates/attestation/src/attestation.rs index 457cc14ab8..a595e183cd 100644 --- a/crates/attestation/src/attestation.rs +++ b/crates/attestation/src/attestation.rs @@ -35,6 +35,7 @@ pub(crate) const KEY_PROVIDER_EVENT: &str = "key-provider"; const RTMR3_INDEX: u32 = 3; #[derive(Clone, Constructor, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] +#[cfg_attr(feature = "borsh-schema", derive(borsh::BorshSchema))] pub struct DstackAttestation { pub quote: QuoteBytes, pub collateral: Collateral, diff --git a/crates/attestation/src/tcb_info.rs b/crates/attestation/src/tcb_info.rs index 06acde3dd3..4d8fb4c2fc 100644 --- a/crates/attestation/src/tcb_info.rs +++ b/crates/attestation/src/tcb_info.rs @@ -1,4 +1,6 @@ use alloc::string::String; +#[cfg(feature = "borsh-schema")] +use alloc::string::ToString; use alloc::vec::Vec; use borsh::{BorshDeserialize, BorshSerialize}; #[cfg(any(test, feature = "dstack-conversions"))] @@ -16,6 +18,7 @@ pub enum ParsingError { #[serde_as] #[derive(Debug, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[cfg_attr(feature = "borsh-schema", derive(borsh::BorshSchema))] pub struct TcbInfo { pub mrtd: HexBytes<48>, pub rtmr0: HexBytes<48>, @@ -32,6 +35,7 @@ pub struct TcbInfo { #[serde_as] #[derive(Debug, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[cfg_attr(feature = "borsh-schema", derive(borsh::BorshSchema))] pub struct EventLog { pub imr: u32, pub event_type: u32, @@ -60,6 +64,33 @@ pub struct EventLog { #[serde(transparent)] pub struct HexBytes(#[serde_as(as = "Hex")] [u8; N]); +/// Manual impl because the derive drops the const parameter from the +/// declaration, so `HexBytes<48>` and `HexBytes<32>` in one schema collide +/// ("Redefining type schema for HexBytes"). +#[cfg(feature = "borsh-schema")] +impl borsh::BorshSchema for HexBytes { + fn declaration() -> borsh::schema::Declaration { + alloc::format!("HexBytes<{N}>") + } + + fn add_definitions_recursively( + definitions: &mut alloc::collections::BTreeMap< + borsh::schema::Declaration, + borsh::schema::Definition, + >, + ) { + let fields = borsh::schema::Fields::UnnamedFields(alloc::vec![ + <[u8; N] as borsh::BorshSchema>::declaration() + ]); + borsh::schema::add_definition( + Self::declaration(), + borsh::schema::Definition::Struct { fields }, + definitions, + ); + <[u8; N] as borsh::BorshSchema>::add_definitions_recursively(definitions); + } +} + #[serde_as] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum HexBytesOrEmpty { @@ -198,6 +229,17 @@ mod tests { use rstest::rstest; use serde_json; + /// `TcbInfo` holds both `HexBytes<48>` and `HexBytes<32>`; schema + /// generation panics if their declarations collide. + #[cfg(feature = "borsh-schema")] + #[test] + fn TcbInfo__should_generate_borsh_schema() { + let container = borsh::schema_container_of::(); + + assert!(container.get_definition("HexBytes<48>").is_some()); + assert!(container.get_definition("HexBytes<32>").is_some()); + } + #[test] fn TcbInfo__should_deserialize_from_real_test_data() { // Given diff --git a/crates/contract/src/config.rs b/crates/contract/src/config.rs index b94868caf9..ef952f116e 100644 --- a/crates/contract/src/config.rs +++ b/crates/contract/src/config.rs @@ -41,10 +41,6 @@ const DEFAULT_VERIFIER_TERA_GAS: u64 = 100; /// Prepaid gas for the `resolve_verification` callback. Carries the bulk of the /// post-DCAP work (allowlist match, RTMR3 replay, app-compose validation, store). const DEFAULT_RESOLVE_VERIFICATION_TERA_GAS: u64 = 60; -/// Prepaid gas for the `on_attestation_verified` yield-callback. Sized for its -/// heaviest (timeout) branch, which removes the pending entry and schedules both -/// a refund transfer and the `fail_attestation_submission` promise. -const DEFAULT_ON_ATTESTATION_VERIFIED_TERA_GAS: u64 = 10; /// Config for V2 of the contract. #[near(serializers=[borsh, json])] @@ -85,8 +81,6 @@ pub(crate) struct Config { pub(crate) verifier_tera_gas: u64, /// Prepaid gas for the `resolve_verification` callback. pub(crate) resolve_verification_tera_gas: u64, - /// Prepaid gas for the `on_attestation_verified` yield-callback. - pub(crate) on_attestation_verified_tera_gas: u64, } impl Default for Config { @@ -116,7 +110,6 @@ impl Default for Config { DEFAULT_REMOVE_NON_PARTICIPANT_TEE_VERIFIER_VOTES_TERA_GAS, verifier_tera_gas: DEFAULT_VERIFIER_TERA_GAS, resolve_verification_tera_gas: DEFAULT_RESOLVE_VERIFICATION_TERA_GAS, - on_attestation_verified_tera_gas: DEFAULT_ON_ATTESTATION_VERIFIED_TERA_GAS, } } } diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index e4a6f7c6a5..38d277ce00 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -499,9 +499,6 @@ impl From for Config { if let Some(v) = config_ext.resolve_verification_tera_gas { config.resolve_verification_tera_gas = v; } - if let Some(v) = config_ext.on_attestation_verified_tera_gas { - config.on_attestation_verified_tera_gas = v; - } config } @@ -534,7 +531,6 @@ impl From<&Config> for near_mpc_contract_interface::types::Config { .remove_non_participant_tee_verifier_votes_tera_gas, verifier_tera_gas: value.verifier_tera_gas, resolve_verification_tera_gas: value.resolve_verification_tera_gas, - on_attestation_verified_tera_gas: value.on_attestation_verified_tera_gas, } } } @@ -566,7 +562,6 @@ impl From for Config { .remove_non_participant_tee_verifier_votes_tera_gas, verifier_tera_gas: value.verifier_tera_gas, resolve_verification_tera_gas: value.resolve_verification_tera_gas, - on_attestation_verified_tera_gas: value.on_attestation_verified_tera_gas, } } } diff --git a/crates/contract/src/errors.rs b/crates/contract/src/errors.rs index f9bd9fbf96..a6fdc25201 100644 --- a/crates/contract/src/errors.rs +++ b/crates/contract/src/errors.rs @@ -29,14 +29,14 @@ pub enum TeeError { "Due to previously failed TEE validation, the network is not accepting new requests at this point in time. Try again later." )] TeeValidationFailed, - #[error( - "A Dstack attestation verification is already in flight for this account; wait for it to finish before resubmitting." - )] - VerificationAlreadyPending, #[error( "No TEE verifier is configured yet. Participants must vote one in via vote_tee_verifier_change before Dstack attestations can be submitted." )] VerifierNotConfigured, + #[error("The TEE verifier rejected the quote: {reason}")] + QuoteRejected { reason: String }, + #[error("The TEE verifier did not answer the verify_quote call.")] + VerifierUnavailable, } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index af42d2a59f..d7f8909b30 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -17,7 +17,6 @@ pub mod update; pub mod utils; pub mod v3_12_0_state; -pub mod v3_13_0_state; #[cfg(feature = "bench-contract-methods")] mod bench; @@ -49,8 +48,8 @@ use crate::{ votes::ProposalHash, }, storage_keys::StorageKey, - tee::pending_attestation::{AttestationResult, PendingAttestation}, tee::tee_state::{TeeQuoteStatus, TeeState}, + tee::verification_context::VerificationContext, tee::verifier_votes::{TeeVerifierVotes, VerifierChangeProposal}, update::{ProposeUpdateArgs, ProposedUpdates, Update, UpdateId}, }; @@ -144,10 +143,10 @@ fn require_deposit(minimum_deposit: NearToken, predecessor: &AccountId) { } } -/// Refunds an attestation submitter's attached deposit (no-op for a zero -/// deposit). Used when an [`Attestation::Dstack`] verification is rejected or -/// times out. -fn refund_attestation_deposit(account_id: &AccountId, deposit: NearToken) { +/// Returns `env::attached_deposit()` to `account_id` via a detached transfer +/// promise; no-op when zero. +fn refund_deposit_to(account_id: &AccountId) { + let deposit = env::attached_deposit(); if deposit > NearToken::from_yoctonear(0) { log!("refund attestation deposit {deposit} to {account_id}"); Promise::new(account_id.clone()).transfer(deposit).detach(); @@ -186,10 +185,6 @@ pub struct MpcContract { // non-optional via a migration that requires it be set. tee_verifier_account_id: Option, tee_verifier_votes: TeeVerifierVotes, - /// In-flight [`Attestation::Dstack`] verifications, one entry per submitter - /// account, held between the cross-contract verify-quote call and its - /// resolution (or the yield timeout). - pending_attestations: LookupMap, } #[near(serializers=[borsh])] @@ -787,7 +782,7 @@ impl MpcContract { &mut self, proposed_participant_attestation: dtos::Attestation, tls_public_key: dtos::Ed25519PublicKey, - ) -> Result<(), Error> { + ) -> Result, Error> { let proposed_participant_attestation = proposed_participant_attestation.try_into_contract_type()?; @@ -838,42 +833,31 @@ impl MpcContract { caller_is_participant, env::attached_deposit(), )?; - Ok(()) - } - Attestation::Dstack(dstack) => { - self.submit_dstack_attestation(node_id, dstack, caller_is_participant) + Ok(PromiseOrValue::Value(())) } + Attestation::Dstack(attestation) => Ok(PromiseOrValue::Promise( + self.submit_dstack_attestation(node_id, attestation, caller_is_participant)?, + )), } } - /// Async [`Attestation::Dstack`] submission: registers a yield, fires the - /// cross-contract verify-quote call, and resumes via - /// [`Self::resolve_verification`]. + /// Async [`Attestation::Dstack`] submission: spawns a promise calling + /// `verify_quote` on the trusted verifier contract, with + /// [`Self::resolve_verification`] chained as its callback. fn submit_dstack_attestation( &mut self, node_id: NodeId, - dstack: DstackAttestation, + attestation: DstackAttestation, caller_is_participant: bool, - ) -> Result<(), Error> { - let account_id = node_id.account_id.clone(); - - if self.pending_attestations.contains_key(&account_id) { - return Err(TeeError::VerificationAlreadyPending.into()); - } - + ) -> Result { let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { return Err(TeeError::VerifierNotConfigured.into()); }; - let attached_deposit = env::attached_deposit(); - let tls_public_key = node_id.tls_public_key.clone(); - - // Call the verifier; `resolve_verification` bridges its response back into - // the yield registered below - Promise::new(verifier_account_id) + Ok(Promise::new(verifier_account_id) .function_call( method_names::VERIFY_QUOTE.to_string(), - borsh::to_vec(&(&dstack.quote, &dstack.collateral)) + borsh::to_vec(&(&attestation.quote, &attestation.collateral)) .expect("borsh serialization of verify_quote args must succeed"), NearToken::from_yoctonear(0), Gas::from_tgas(self.config.verifier_tera_gas), @@ -881,30 +865,13 @@ impl MpcContract { .then( Self::ext(env::current_account_id()) .with_static_gas(Gas::from_tgas(self.config.resolve_verification_tera_gas)) - .resolve_verification(node_id), - ) - .detach(); - - self.enqueue_yield_request( - method_names::ON_ATTESTATION_VERIFIED, - serde_json::to_vec(&(&account_id,)) - .expect("json serialization of account_id must succeed"), - Gas::from_tgas(self.config.on_attestation_verified_tera_gas), - |this, data_id| { - this.pending_attestations.insert( - account_id.clone(), - PendingAttestation { - dstack, - tls_public_key, - attached_deposit, + .with_attached_deposit(env::attached_deposit()) + .resolve_verification(VerificationContext { + node_id, + attestation, caller_is_participant, - data_id, - }, - ); - }, - ); - - Ok(()) + }), + )) } fn charge_attestation_storage( @@ -919,7 +886,7 @@ impl MpcContract { matches!(insertion, ParticipantInsertion::NewlyInsertedParticipant); if caller_is_participant && !is_new_attestation { - refund_attestation_deposit(account_id, attached); + refund_deposit_to(account_id); return Ok(()); } @@ -962,13 +929,6 @@ impl MpcContract { })) } - /// Whether the account has an [`Attestation::Dstack`] submission awaiting - /// async verification, so a submitter can tell "in flight" from "never - /// landed" rather than resubmit and hit a [`TeeError::VerificationAlreadyPending`]. - pub fn is_verification_pending(&self, account_id: AccountId) -> bool { - self.pending_attestations.contains_key(&account_id) - } - /// 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. @@ -2070,7 +2030,6 @@ impl MpcContract { ), tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), - pending_attestations: LookupMap::new(StorageKey::PendingAttestations), }) } @@ -2150,7 +2109,6 @@ impl MpcContract { ), tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), - pending_attestations: LookupMap::new(StorageKey::PendingAttestations), }) } @@ -2166,14 +2124,6 @@ impl MpcContract { 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.13.0 state: {:?}", err); - } - }; - match try_state_read::() { Ok(Some(state)) => return Ok(state.into()), Ok(None) => return Err(InvalidState::ContractStateIsMissing.into()), @@ -2382,148 +2332,105 @@ impl MpcContract { } } - /// Verify-quote callback: maps the verifier's response to an [`AttestationResult`] - /// and resumes the yield. + /// Verify-quote callback: on a verifier verdict it runs the post-DCAP + /// checks, stores the attestation, and settles the deposit. #[private] + #[payable] pub fn resolve_verification( &mut self, - node_id: NodeId, + #[serializer(borsh)] context: VerificationContext, #[serializer(borsh)] #[callback_result] result: Result, - ) { - let account_id = node_id.account_id.clone(); - - let result = match result { - Ok(result) => result, - // No verdict (verifier unreachable, panicked, or out of gas). Don't resume; - // the yield timeout fires `on_attestation_verified` to clean up and refund. - Err(promise_err) => { - log!("verifier did not answer for {account_id}: {promise_err:?}"); - return; - } - }; - - // Take the pending entry now. A late verifier response can arrive after the - // ~200-block yield timeout already fired and `on_attestation_verified` removed - // the entry and resolved the yield; there is then nothing to do. - let Some(pending) = self.pending_attestations.remove(&account_id) else { - log!( - "resolve_verification: no pending attestation for {account_id} (late response or already cleaned up); ignoring" - ); - return; - }; + ) -> PromiseOrValue<()> { + let account_id = context.node_id.account_id.clone(); + log!("resolve_verification: account_id={account_id}"); let attestation_result = match result { - VerificationResult::Rejected(reason) => { + Ok(VerificationResult::Verified(report)) => { + self.verify_post_dcap_and_store(&context, &report) + } + Ok(VerificationResult::Rejected(reason)) => { log!("verifier rejected quote for {account_id}: {reason}"); - AttestationResult::Err(format!("verifier rejected quote: {reason}")) + Err(TeeError::QuoteRejected { + reason: reason.to_string(), + } + .into()) } - VerificationResult::Verified(report) => { - self.verify_post_dcap_and_store(&node_id, &pending, &report) + // No verdict (verifier unreachable, panicked, or out of gas) + Err(promise_err) => { + log!("verifier did not answer for {account_id}: {promise_err:?}"); + Err(TeeError::VerifierUnavailable.into()) } }; - if matches!(attestation_result, AttestationResult::Err(_)) { - refund_attestation_deposit(&account_id, pending.attached_deposit); + match attestation_result { + Ok(()) => PromiseOrValue::Value(()), + Err(err) => { + refund_deposit_to(&account_id); + // Fail the submitter's transaction from a separate receipt so + // the refund above commits (a panic here would roll it back) + let promise = Promise::new(env::current_account_id()).function_call( + method_names::FAIL_ATTESTATION_SUBMISSION.to_string(), + borsh::to_vec(&err.to_string()) + .expect("borsh serialization of reason must succeed"), + NearToken::from_yoctonear(0), + Gas::from_tgas(self.config.fail_attestation_submission_tera_gas), + ); + PromiseOrValue::Promise(promise.as_return()) + } } - // MUST be the last host call: anything after could panic and roll back - // the state mutations above - env::promise_yield_resume( - &pending.data_id, - serde_json::to_vec(&attestation_result) - .expect("json serialization of AttestationResult must succeed"), - ); } /// Runs the post-DCAP checks and stores the attestation for a - /// [`VerificationResult::Verified`] response, returning the outcome to resume - /// the yield with. On failure it reverts the store explicitly, since the - /// callback receipt commits regardless (unlike the synchronous path). + /// [`VerificationResult::Verified`] response. On failure it reverts the + /// store explicitly, since the callback receipt commits regardless + /// (unlike the synchronous path). fn verify_post_dcap_and_store( &mut self, - node_id: &NodeId, - pending: &PendingAttestation, + context: &VerificationContext, report: &VerifiedReport, - ) -> AttestationResult { - let account_id = &node_id.account_id; + ) -> Result<(), Error> { + let account_id = &context.node_id.account_id; let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); let initial_storage = env::storage_usage(); let insertion = match self.tee_state.verify_and_store_dstack( - node_id.clone(), - &pending.dstack, + context.node_id.clone(), + &context.attestation, report, tee_upgrade_deadline_duration, ) { Ok(insertion) => insertion, Err(err) => { log!("post-DCAP check failed for {account_id}: {err}"); - return AttestationResult::Err(format!("post-DCAP check failed: {err}")); + return Err(err.into()); } }; + // The charge is the measured storage delta, so it is only known after + // the store; an insufficient deposit reverts the store below. match self.charge_attestation_storage( account_id, initial_storage, &insertion, - pending.caller_is_participant, - pending.attached_deposit, + context.caller_is_participant, + env::attached_deposit(), ) { - Ok(()) => AttestationResult::Ok, + Ok(()) => Ok(()), Err(err) => { - // This receipt commits even though we resume the yield with an - // error, so the store above is NOT rolled back automatically - // (unlike the synchronous path). Undo it explicitly, or the - // caller would get storage for free plus a full refund. + // This receipt commits even though we return an error, so the + // store above is NOT rolled back automatically (unlike the + // synchronous path). Undo it explicitly, or the caller would + // get storage for free plus a full refund. self.tee_state - .revert_dstack_store(&pending.tls_public_key, insertion); - AttestationResult::Err(err.to_string()) + .revert_dstack_store(&context.node_id.tls_public_key, insertion); + Err(err) } } } - /// Yield-resume callback for an [`Attestation::Dstack`] submission. On - /// success it resolves the caller's transaction; on a rejection or the - /// ~200-block timeout it cleans up, refunds, and fails from a separate - /// receipt. - #[private] - pub fn on_attestation_verified( - &mut self, - account_id: AccountId, - #[callback_result] result: Result, - ) -> PromiseOrValue<()> { - let reason = match result { - Ok(resolved) => { - // `resolve_verification` removes the entry before resuming - debug_assert!(!self.pending_attestations.contains_key(&account_id)); - match resolved { - AttestationResult::Ok => return PromiseOrValue::Value(()), - AttestationResult::Err(reason) => reason, - } - } - Err(_promise_err) => { - // ~200-block yield-resume timeout. Clean it up and refund. - if let Some(pending) = self.pending_attestations.remove(&account_id) { - refund_attestation_deposit(&account_id, pending.attached_deposit); - log!("yield timeout for {account_id}: refunded and cleaned up"); - } - "verifier did not respond within the yield-resume window".to_string() - } - }; - - // Fail the submitter's transaction from a separate receipt so the - // cleanup above commits (a panic here would roll it back) - let promise = Promise::new(env::current_account_id()).function_call( - method_names::FAIL_ATTESTATION_SUBMISSION.to_string(), - borsh::to_vec(&reason).expect("borsh serialization of reason must succeed"), - NearToken::from_yoctonear(0), - Gas::from_tgas(self.config.fail_attestation_submission_tera_gas), - ); - 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, @@ -2592,6 +2499,7 @@ impl MpcContract { #[private] pub fn fail_attestation_submission(#[serializer(borsh)] reason: String) { + log!("fail_attestation_submission: {reason}"); env::panic_str(&reason); } @@ -4250,7 +4158,9 @@ mod tests { .build(); testing_env!(participant_context); - contract.submit_participant_info(Attestation::Mock(attestation), dto_public_key) + contract + .submit_participant_info(Attestation::Mock(attestation), dto_public_key) + .map(|_| ()) } fn submit_valid_attestations( @@ -4667,7 +4577,7 @@ mod tests { .build(); testing_env!(ctx); - contract + let _ = contract .submit_participant_info(valid_attestation, participant_info.tls_public_key.clone()) .expect("Expected panic if predecessor != signer"); } @@ -4694,7 +4604,7 @@ mod tests { .build(); testing_env!(ctx); - contract + let _ = contract .submit_participant_info(valid_attestation, dto_public_key) .expect("Outsider attestation submission should succeed"); @@ -4756,7 +4666,7 @@ mod tests { .build() ); - contract + let _ = contract .submit_participant_info(Attestation::Mock(MockAttestation::Valid), dto_public_key) .unwrap(); @@ -4838,7 +4748,6 @@ mod tests { ), tee_verifier_account_id: None, tee_verifier_votes: Default::default(), - pending_attestations: LookupMap::new(StorageKey::PendingAttestations), } } } diff --git a/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap b/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap index 8247fb153b..29eb9cd768 100644 --- a/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap +++ b/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap @@ -270,10 +270,6 @@ BorshSchemaContainer { "resolve_verification_tera_gas", "u64", ), - ( - "on_attestation_verified_tera_gas", - "u64", - ), ], ), }, @@ -727,10 +723,6 @@ BorshSchemaContainer { "tee_verifier_votes", "TeeVerifierVotes", ), - ( - "pending_attestations", - "LookupMap", - ), ], ), }, diff --git a/crates/contract/src/storage_keys.rs b/crates/contract/src/storage_keys.rs index 1bb1dc88e8..e4e0349c6d 100644 --- a/crates/contract/src/storage_keys.rs +++ b/crates/contract/src/storage_keys.rs @@ -34,5 +34,4 @@ pub enum StorageKey { ForeignChainMetadata, TeeVerifierVotesByVoter, TeeVerifierVotesByProposal, - PendingAttestations, } diff --git a/crates/contract/src/tee.rs b/crates/contract/src/tee.rs index 310f91b910..00b42f33b4 100644 --- a/crates/contract/src/tee.rs +++ b/crates/contract/src/tee.rs @@ -1,7 +1,7 @@ pub mod measurements; -pub mod pending_attestation; pub mod proposal; pub mod tee_state; #[cfg(any(test, feature = "test-utils"))] pub mod test_utils; +pub mod verification_context; pub mod verifier_votes; diff --git a/crates/contract/src/tee/pending_attestation.rs b/crates/contract/src/tee/pending_attestation.rs deleted file mode 100644 index 20f1374ec0..0000000000 --- a/crates/contract/src/tee/pending_attestation.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! State for an in-flight [`DstackAttestation`] submission. -//! -//! A [`DstackAttestation`] submission is asynchronous: it yields, fires a cross-contract -//! verify-quote call, and resumes from the response callback. What the callback -//! needs but cannot re-read from contract state is stashed here, keyed by the -//! submitter's account id, until the yield resolves. - -use borsh::{BorshDeserialize, BorshSerialize}; -use mpc_attestation::attestation::DstackAttestation; -use near_mpc_contract_interface::types::Ed25519PublicKey; -use near_sdk::{CryptoHash, NearToken, near}; - -/// One in-flight verification per submitter account. -#[derive(Debug, BorshSerialize, BorshDeserialize)] -pub struct PendingAttestation { - /// The submitted payload. Only `tcb_info` is read after the verifier callback; - /// `quote`/`collateral` are consumed once for the pre-callback `verify_quote` - /// call, so storing the whole struct holds ~2-8 KiB of dead bytes per in-flight - /// entry. TODO(#3720): stash only `tcb_info`. - pub(crate) dstack: DstackAttestation, - /// Checked against the quote's report-data during the post-DCAP checks. - pub(crate) tls_public_key: Ed25519PublicKey, - /// Stashed because the deposit is not visible from the callback receipt: - /// consumed for storage on success, refunded on failure. - pub(crate) attached_deposit: NearToken, - /// Participant status at submit time, which decides whether the caller pays - /// for storage. Captured because the callback receipt is no longer the - /// caller, so it can no longer be re-derived. - pub(crate) caller_is_participant: bool, - /// Yield handle, read back by the callback to resume the yield. - pub(crate) data_id: CryptoHash, -} - -#[near(serializers = [json])] -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AttestationResult { - Ok, - Err(String), -} - -#[cfg(test)] -#[expect(non_snake_case)] -mod tests { - use super::*; - use rstest::rstest; - - #[rstest] - #[case(AttestationResult::Ok)] - #[case(AttestationResult::Err("rejected".to_string()))] - fn attestation_result__should_round_trip_json(#[case] original: AttestationResult) { - let bytes = serde_json::to_vec(&original).expect("serialize"); - let decoded: AttestationResult = serde_json::from_slice(&bytes).expect("deserialize"); - assert_eq!(original, decoded); - } -} diff --git a/crates/contract/src/tee/verification_context.rs b/crates/contract/src/tee/verification_context.rs new file mode 100644 index 0000000000..cf2de409eb --- /dev/null +++ b/crates/contract/src/tee/verification_context.rs @@ -0,0 +1,15 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use mpc_attestation::attestation::DstackAttestation; + +use super::tee_state::NodeId; + +#[derive(Debug, BorshSerialize, BorshDeserialize)] +#[cfg_attr( + all(feature = "abi", not(target_arch = "wasm32")), + derive(borsh::BorshSchema) +)] +pub struct VerificationContext { + pub(crate) node_id: NodeId, + pub(crate) attestation: DstackAttestation, + pub(crate) caller_is_participant: bool, +} diff --git a/crates/contract/src/v3_12_0_state.rs b/crates/contract/src/v3_12_0_state.rs index 48bccc24a2..cfa9567bd5 100644 --- a/crates/contract/src/v3_12_0_state.rs +++ b/crates/contract/src/v3_12_0_state.rs @@ -125,7 +125,6 @@ impl From for crate::MpcContract { ), tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), - pending_attestations: LookupMap::new(StorageKey::PendingAttestations), } } } diff --git a/crates/contract/src/v3_13_0_state.rs b/crates/contract/src/v3_13_0_state.rs deleted file mode 100644 index e0170cd524..0000000000 --- a/crates/contract/src/v3_13_0_state.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! ## Overview -//! Shadows the contract state written by the `3.13.0` release so [`crate::MpcContract::migrate`] -//! can upgrade from it. See [`crate::v3_12_0_state`] for the rationale and guideline. -//! -//! `3.13.0` differs from the live layout by four appended `Config` gas fields -//! (`fail_attestation_submission_tera_gas`, `verifier_tera_gas`, -//! `resolve_verification_tera_gas`, `on_attestation_verified_tera_gas`), all -//! defaulted here, and the appended `MpcContract::pending_attestations` map. - -use borsh::{BorshDeserialize, BorshSerialize}; -use near_mpc_contract_interface::types::{Metrics, VerifyForeignTransactionRequest}; -use near_sdk::{ - AccountId, env, - store::{Lazy, LookupMap}, -}; - -use crate::{ - SupportedForeignChainsByNode, - foreign_chains_metadata::ForeignChainsMetadata, - node_migrations::NodeMigrations, - primitives::{ - ckd::CKDRequest, - domain::max_reconstruction_threshold, - signature::{SignatureRequest, YieldIndex}, - thresholds::ThresholdParameters, - }, - state::{ProtocolContractState, running::RunningContractState}, - storage_keys::StorageKey, - tee::{tee_state::TeeState, verifier_votes::TeeVerifierVotes}, - update::ProposedUpdates, -}; - -/// The `Config` layout written by the `3.13.0` contract, before -/// `fail_attestation_submission_tera_gas` and the verifier gas knobs were added. -#[derive(Debug, BorshSerialize, BorshDeserialize)] -pub struct OldConfig { - key_event_timeout_blocks: u64, - tee_upgrade_deadline_duration_seconds: u64, - contract_upgrade_deposit_tera_gas: u64, - sign_call_gas_attachment_requirement_tera_gas: u64, - ckd_call_gas_attachment_requirement_tera_gas: u64, - return_signature_and_clean_state_on_success_call_tera_gas: u64, - return_ck_and_clean_state_on_success_call_tera_gas: u64, - fail_on_timeout_tera_gas: u64, - clean_tee_status_tera_gas: u64, - clean_invalid_attestations_tera_gas: u64, - cleanup_orphaned_node_migrations_tera_gas: u64, - remove_non_participant_update_votes_tera_gas: u64, - clean_foreign_chain_data_tera_gas: u64, - remove_non_participant_tee_verifier_votes_tera_gas: u64, -} - -impl From for crate::Config { - fn from(old: OldConfig) -> Self { - crate::Config { - key_event_timeout_blocks: old.key_event_timeout_blocks, - tee_upgrade_deadline_duration_seconds: old.tee_upgrade_deadline_duration_seconds, - contract_upgrade_deposit_tera_gas: old.contract_upgrade_deposit_tera_gas, - sign_call_gas_attachment_requirement_tera_gas: old - .sign_call_gas_attachment_requirement_tera_gas, - ckd_call_gas_attachment_requirement_tera_gas: old - .ckd_call_gas_attachment_requirement_tera_gas, - return_signature_and_clean_state_on_success_call_tera_gas: old - .return_signature_and_clean_state_on_success_call_tera_gas, - return_ck_and_clean_state_on_success_call_tera_gas: old - .return_ck_and_clean_state_on_success_call_tera_gas, - fail_on_timeout_tera_gas: old.fail_on_timeout_tera_gas, - clean_tee_status_tera_gas: old.clean_tee_status_tera_gas, - clean_invalid_attestations_tera_gas: old.clean_invalid_attestations_tera_gas, - cleanup_orphaned_node_migrations_tera_gas: old - .cleanup_orphaned_node_migrations_tera_gas, - remove_non_participant_update_votes_tera_gas: old - .remove_non_participant_update_votes_tera_gas, - clean_foreign_chain_data_tera_gas: old.clean_foreign_chain_data_tera_gas, - remove_non_participant_tee_verifier_votes_tera_gas: old - .remove_non_participant_tee_verifier_votes_tera_gas, - // New in this version: the attestation fail-call and verifier-call gas - // knobs, added alongside the async attestation flow. - ..crate::Config::default() - } - } -} - -/// Keep this module in sync with [`crate::MpcContract`]: it is the `3.13.0` layout, -/// which differs only by the appended `pending_attestations` map. -#[derive(Debug, BorshSerialize, BorshDeserialize)] -pub struct MpcContract { - protocol_state: ProtocolContractState, - pending_signature_requests: LookupMap>, - pending_ckd_requests: LookupMap>, - pending_verify_foreign_tx_requests: LookupMap>, - proposed_updates: ProposedUpdates, - node_foreign_chain_support: SupportedForeignChainsByNode, - config: OldConfig, - tee_state: TeeState, - accept_requests: bool, - node_migrations: NodeMigrations, - metrics: Metrics, - foreign_chains: Lazy, - tee_verifier_account_id: Option, - tee_verifier_votes: TeeVerifierVotes, -} - -impl From for crate::MpcContract { - fn from(old: MpcContract) -> Self { - if let ProtocolContractState::Running(running) = &old.protocol_state { - validate_threshold_relation_on_migration(running); - } - - crate::MpcContract { - protocol_state: old.protocol_state, - pending_signature_requests: old.pending_signature_requests, - pending_ckd_requests: old.pending_ckd_requests, - pending_verify_foreign_tx_requests: old.pending_verify_foreign_tx_requests, - proposed_updates: old.proposed_updates, - node_foreign_chain_support: old.node_foreign_chain_support, - config: old.config.into(), - tee_state: old.tee_state, - accept_requests: old.accept_requests, - node_migrations: old.node_migrations, - metrics: old.metrics, - foreign_chains: old.foreign_chains, - tee_verifier_account_id: old.tee_verifier_account_id, - tee_verifier_votes: old.tee_verifier_votes, - pending_attestations: LookupMap::new(StorageKey::PendingAttestations), - } - } -} - -fn validate_threshold_relation_on_migration(running: &RunningContractState) { - let num_participants = running.parameters.participants().len() as u64; - let max_reconstruction_threshold = max_reconstruction_threshold(running.domains.domains()); - if let Err(err) = ThresholdParameters::validate_governance_against_reconstruction( - num_participants, - running.parameters.threshold(), - max_reconstruction_threshold, - ) { - env::panic_str(&format!( - "Migration aborted: existing state violates the GovernanceThreshold/ReconstructionThreshold relation ({err:?}). num_participants={}, governance_threshold={}, max_reconstruction_threshold={:?}. Correct it via vote_new_parameters before upgrading.", - num_participants, - running.parameters.threshold().value(), - max_reconstruction_threshold.map(|t| t.inner()), - )); - } -} diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index 42f88998aa..8175323364 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -223,6 +223,7 @@ impl TestSetup { testing_env!(context); self.contract .submit_participant_info(attestation, node_id.tls_public_key.clone()) + .map(|_| ()) } /// Switches testing context to a given participant at a specific timestamp @@ -332,10 +333,13 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { .attached_deposit(NearToken::from_near(1)) .build() ); - let attack_result = setup.contract.submit_participant_info( - Attestation::Mock(MockAttestation::Valid), - attacker_node.tls_public_key.clone(), - ); + let attack_result = setup + .contract + .submit_participant_info( + Attestation::Mock(MockAttestation::Valid), + attacker_node.tls_public_key.clone(), + ) + .map(|_| ()); // Then: the contract rejects the call with the TLS-ownership error and the victim's // entry is unchanged. diff --git a/crates/contract/tests/sandbox/contract_configuration.rs b/crates/contract/tests/sandbox/contract_configuration.rs index bcf4419223..b33c8db8cb 100644 --- a/crates/contract/tests/sandbox/contract_configuration.rs +++ b/crates/contract/tests/sandbox/contract_configuration.rs @@ -106,7 +106,6 @@ async fn contract_configuration_can_be_set_on_initialization() { remove_non_participant_tee_verifier_votes_tera_gas: Some(14), verifier_tera_gas: Some(15), resolve_verification_tera_gas: Some(16), - on_attestation_verified_tera_gas: Some(17), }; let SandboxTestSetup { contract, .. } = SandboxTestSetup::builder() diff --git a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs index eef1390cc2..8456600b0c 100644 --- a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs +++ b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs @@ -122,7 +122,6 @@ async fn test_propose_update_config() { remove_non_participant_tee_verifier_votes_tera_gas: 14, verifier_tera_gas: 15, resolve_verification_tera_gas: 16, - on_attestation_verified_tera_gas: 17, }; let mut proposals = Vec::with_capacity(mpc_signer_accounts.len()); diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 67231e12b9..6e5cfc78ca 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -855,29 +855,6 @@ expression: abi ] } }, - { - "name": "is_verification_pending", - "doc": " Whether the account has an [`Attestation::Dstack`] submission awaiting\n async verification, so a submitter can tell \"in flight\" from \"never\n landed\" rather than resubmit and hit a [`TeeError::VerificationAlreadyPending`].", - "kind": "view", - "params": { - "serialization_type": "json", - "args": [ - { - "name": "account_id", - "type_schema": { - "description": "NEAR Account Identifier.\n\nThis is a unique, syntactically valid, human-readable account identifier on the NEAR network.\n\n[See the crate-level docs for information about validation.](index.html#account-id-rules)\n\nAlso see [Error kind precedence](AccountId#error-kind-precedence).\n\n## Examples\n\n``` use near_account_id::AccountId;\n\nlet alice: AccountId = \"alice.near\".parse().unwrap();\n\nassert!(\"ƒelicia.near\".parse::().is_err()); // (ƒ is not f) ```", - "type": "string" - } - } - ] - }, - "result": { - "serialization_type": "json", - "type_schema": { - "type": "boolean" - } - } - }, { "name": "latest_key_version", "doc": " Key versions refer new versions of the root key that we may choose to generate on cohort\n changes. Older key versions will always work but newer key versions were never held by\n older signers. Newer key versions may also add new security features, like only existing\n within a secure enclave. The signature_scheme parameter specifies which protocol\n we're querying the latest version for. The default is Secp256k1. The default is **NOT**\n to query across all protocols.", @@ -976,40 +953,6 @@ expression: abi } } }, - { - "name": "on_attestation_verified", - "doc": " Yield-resume callback for an [`Attestation::Dstack`] submission. On\n success it resolves the caller's transaction; on a rejection or the\n ~200-block timeout it cleans up, refunds, and fails from a separate\n receipt.", - "kind": "call", - "modifiers": [ - "private" - ], - "params": { - "serialization_type": "json", - "args": [ - { - "name": "account_id", - "type_schema": { - "description": "NEAR Account Identifier.\n\nThis is a unique, syntactically valid, human-readable account identifier on the NEAR network.\n\n[See the crate-level docs for information about validation.](index.html#account-id-rules)\n\nAlso see [Error kind precedence](AccountId#error-kind-precedence).\n\n## Examples\n\n``` use near_account_id::AccountId;\n\nlet alice: AccountId = \"alice.near\".parse().unwrap();\n\nassert!(\"ƒelicia.near\".parse::().is_err()); // (ƒ is not f) ```", - "type": "string" - } - } - ] - }, - "callbacks": [ - { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/AttestationResult" - } - } - ], - "result": { - "serialization_type": "json", - "type_schema": { - "$ref": "#/definitions/PromiseOrValueNull" - } - } - }, { "name": "os_measurement_votes", "doc": " Returns the current OS measurement votes, showing each participant's vote.", @@ -1108,10 +1051,6 @@ expression: abi [ "resolve_verification_tera_gas", "u64" - ], - [ - "on_attestation_verified_tera_gas", - "u64" ] ] }, @@ -1367,18 +1306,302 @@ expression: abi }, { "name": "resolve_verification", - "doc": " Verify-quote callback: maps the verifier's response to an [`AttestationResult`]\n and resumes the yield.", + "doc": " Verify-quote callback: on a verifier verdict it runs the post-DCAP\n checks, stores the attestation, and settles the deposit.", "kind": "call", "modifiers": [ + "payable", "private" ], "params": { - "serialization_type": "json", + "serialization_type": "borsh", "args": [ { - "name": "node_id", + "name": "context", "type_schema": { - "$ref": "#/definitions/NodeId" + "declaration": "VerificationContext", + "definitions": { + "()": { + "Primitive": 0 + }, + "AccountId": { + "Struct": [ + "String" + ] + }, + "Collateral": { + "Struct": [ + [ + "pck_crl_issuer_chain", + "String" + ], + [ + "root_ca_crl", + "Vec" + ], + [ + "pck_crl", + "Vec" + ], + [ + "tcb_info_issuer_chain", + "String" + ], + [ + "tcb_info", + "String" + ], + [ + "tcb_info_signature", + "Vec" + ], + [ + "qe_identity_issuer_chain", + "String" + ], + [ + "qe_identity", + "String" + ], + [ + "qe_identity_signature", + "Vec" + ], + [ + "pck_certificate_chain", + "Option" + ] + ] + }, + "DstackAttestation": { + "Struct": [ + [ + "quote", + "QuoteBytes" + ], + [ + "collateral", + "Collateral" + ], + [ + "tcb_info", + "TcbInfo" + ] + ] + }, + "Ed25519PublicKey": { + "Struct": [ + "[u8; 32]" + ] + }, + "EventLog": { + "Struct": [ + [ + "imr", + "u32" + ], + [ + "event_type", + "u32" + ], + [ + "digest", + "HexBytes<48>" + ], + [ + "event", + "String" + ], + [ + "event_payload", + "String" + ] + ] + }, + "HexBytes<32>": { + "Struct": [ + "[u8; 32]" + ] + }, + "HexBytes<48>": { + "Struct": [ + "[u8; 48]" + ] + }, + "NodeId": { + "Struct": [ + [ + "account_id", + "AccountId" + ], + [ + "tls_public_key", + "Ed25519PublicKey" + ], + [ + "account_public_key", + "Ed25519PublicKey" + ] + ] + }, + "Option>": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "None", + "()" + ], + [ + 1, + "Some", + "HexBytes<32>" + ] + ] + } + }, + "Option": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "None", + "()" + ], + [ + 1, + "Some", + "String" + ] + ] + } + }, + "QuoteBytes": { + "Struct": [ + "Vec" + ] + }, + "String": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "TcbInfo": { + "Struct": [ + [ + "mrtd", + "HexBytes<48>" + ], + [ + "rtmr0", + "HexBytes<48>" + ], + [ + "rtmr1", + "HexBytes<48>" + ], + [ + "rtmr2", + "HexBytes<48>" + ], + [ + "rtmr3", + "HexBytes<48>" + ], + [ + "os_image_hash", + "Option>" + ], + [ + "compose_hash", + "HexBytes<32>" + ], + [ + "device_id", + "HexBytes<32>" + ], + [ + "app_compose", + "String" + ], + [ + "event_log", + "Vec" + ] + ] + }, + "Vec": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "EventLog" + } + }, + "Vec": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "VerificationContext": { + "Struct": [ + [ + "node_id", + "NodeId" + ], + [ + "attestation", + "DstackAttestation" + ], + [ + "caller_is_participant", + "bool" + ] + ] + }, + "[u8; 32]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 32, + "end": 32 + }, + "elements": "u8" + } + }, + "[u8; 48]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 48, + "end": 48 + }, + "elements": "u8" + } + }, + "bool": { + "Primitive": 1 + }, + "u32": { + "Primitive": 4 + }, + "u8": { + "Primitive": 1 + } + } } } ] @@ -1827,7 +2050,13 @@ expression: abi } } } - ] + ], + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/PromiseOrValueNull" + } + } }, { "name": "respond", @@ -2131,7 +2360,7 @@ expression: abi "result": { "serialization_type": "json", "type_schema": { - "type": "null" + "$ref": "#/definitions/PromiseOrValueNull" } } }, @@ -3012,28 +3241,6 @@ expression: abi } ] }, - "AttestationResult": { - "oneOf": [ - { - "type": "string", - "enum": [ - "Ok" - ] - }, - { - "type": "object", - "required": [ - "Err" - ], - "properties": { - "Err": { - "type": "string" - } - }, - "additionalProperties": false - } - ] - }, "AuthenticatedAccountId": { "description": "An account ID that has been authenticated (i.e., the caller is this account).", "type": "string" @@ -3309,7 +3516,6 @@ expression: abi "fail_attestation_submission_tera_gas", "fail_on_timeout_tera_gas", "key_event_timeout_blocks", - "on_attestation_verified_tera_gas", "remove_non_participant_tee_verifier_votes_tera_gas", "remove_non_participant_update_votes_tera_gas", "resolve_verification_tera_gas", @@ -3374,12 +3580,6 @@ expression: abi "format": "uint64", "minimum": 0.0 }, - "on_attestation_verified_tera_gas": { - "description": "Prepaid gas for the `on_attestation_verified` yield-callback.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, "remove_non_participant_tee_verifier_votes_tera_gas": { "description": "Prepaid gas for a `remove_non_participant_tee_verifier_votes` call.", "type": "integer", @@ -4021,15 +4221,6 @@ expression: abi "format": "uint64", "minimum": 0.0 }, - "on_attestation_verified_tera_gas": { - "description": "Prepaid gas for the `on_attestation_verified` yield-callback.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - }, "remove_non_participant_tee_verifier_votes_tera_gas": { "description": "Prepaid gas for a `remove_non_participant_tee_verifier_votes` call.", "type": [ diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index 78f02525ef..c2e14008e5 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -64,7 +64,6 @@ pub const RETURN_SIGNATURE_AND_CLEAN_STATE_ON_SUCCESS: &str = pub const RETURN_CK_AND_CLEAN_STATE_ON_SUCCESS: &str = "return_ck_and_clean_state_on_success"; pub const RETURN_VERIFY_FOREIGN_TX_AND_CLEAN_STATE_ON_SUCCESS: &str = "return_verify_foreign_tx_and_clean_state_on_success"; -pub const ON_ATTESTATION_VERIFIED: &str = "on_attestation_verified"; pub const RESOLVE_VERIFICATION: &str = "resolve_verification"; pub const FAIL_ATTESTATION_SUBMISSION: &str = "fail_attestation_submission"; @@ -84,7 +83,6 @@ pub const GET_PENDING_CKD_REQUEST: &str = "get_pending_ckd_request"; pub const GET_PENDING_VERIFY_FOREIGN_TX_REQUEST: &str = "get_pending_verify_foreign_tx_request"; pub const GET_TEE_ACCOUNTS: &str = "get_tee_accounts"; pub const GET_ATTESTATION: &str = "get_attestation"; -pub const IS_VERIFICATION_PENDING: &str = "is_verification_pending"; pub const GET_SUPPORTED_FOREIGN_CHAINS: &str = "get_supported_foreign_chains"; pub const GET_FOREIGN_CHAIN_SUPPORT_BY_NODE: &str = "get_foreign_chain_support_by_node"; pub const GET_AVAILABLE_FOREIGN_CHAINS: &str = "get_available_foreign_chains"; diff --git a/crates/near-mpc-contract-interface/src/types/config.rs b/crates/near-mpc-contract-interface/src/types/config.rs index 4816084bac..d1f2a3a1e1 100644 --- a/crates/near-mpc-contract-interface/src/types/config.rs +++ b/crates/near-mpc-contract-interface/src/types/config.rs @@ -55,8 +55,6 @@ pub struct InitConfig { pub verifier_tera_gas: Option, /// Prepaid gas for the `resolve_verification` callback. pub resolve_verification_tera_gas: Option, - /// Prepaid gas for the `on_attestation_verified` yield-callback. - pub on_attestation_verified_tera_gas: Option, } /// Configuration parameters of the contract. @@ -113,8 +111,6 @@ pub struct Config { pub verifier_tera_gas: u64, /// Prepaid gas for the `resolve_verification` callback. pub resolve_verification_tera_gas: u64, - /// Prepaid gas for the `on_attestation_verified` yield-callback. - pub on_attestation_verified_tera_gas: u64, } #[cfg(test)] @@ -142,7 +138,6 @@ mod tests { remove_non_participant_tee_verifier_votes_tera_gas: Some(5), verifier_tera_gas: Some(100), resolve_verification_tera_gas: Some(60), - on_attestation_verified_tera_gas: Some(10), }; let json = serde_json::to_string(&original_config).unwrap(); let serialized_and_deserialized_config: InitConfig = serde_json::from_str(&json).unwrap(); @@ -196,7 +191,6 @@ mod tests { remove_non_participant_tee_verifier_votes_tera_gas: None, verifier_tera_gas: None, resolve_verification_tera_gas: None, - on_attestation_verified_tera_gas: None, }; assert_eq!(default_config, config_with_all_values_as_none); diff --git a/crates/node/src/indexer.rs b/crates/node/src/indexer.rs index 87aa681bcf..b6f6ae774c 100644 --- a/crates/node/src/indexer.rs +++ b/crates/node/src/indexer.rs @@ -20,7 +20,7 @@ use near_mpc_contract_interface::method_names::{ ALLOWED_DOCKER_IMAGE_HASHES, ALLOWED_FOREIGN_CHAIN_PROVIDERS, ALLOWED_LAUNCHER_COMPOSE_HASHES, GET_ATTESTATION, GET_PENDING_CKD_REQUEST, GET_PENDING_REQUEST, GET_PENDING_VERIFY_FOREIGN_TX_REQUEST, GET_SUPPORTED_FOREIGN_CHAINS, GET_TEE_ACCOUNTS, - IS_VERIFICATION_PENDING, MIGRATION_INFO, STATE, + MIGRATION_INFO, STATE, }; use near_mpc_contract_interface::types::{self as dtos, YieldIndex}; use participants::ContractState; @@ -252,43 +252,6 @@ impl IndexerViewClient { } } - /// Whether an async Dstack attestation verification is in flight for the - /// account, distinguishing "still verifying" from "submission never landed". - pub(crate) async fn is_verification_pending( - &self, - mpc_contract_id: &AccountId, - account_id: &AccountId, - ) -> anyhow::Result { - let args = serde_json::to_vec(&serde_json::json!({ "account_id": account_id })) - .context("failed to serialize is_verification_pending args")?; - - let request = QueryRequest::CallFunction { - account_id: mpc_contract_id.clone(), - method_name: IS_VERIFICATION_PENDING.to_string(), - args: args.into(), - }; - let query = near_client::Query { - block_reference: BlockReference::Finality(Finality::Final), - request, - }; - - let query_response = self - .view_client - .send_async(query) - .await - .context("failed to query is_verification_pending")??; - - match query_response.kind { - QueryResponseKind::CallResult(call_result) => { - serde_json::from_slice::(&call_result.result) - .context("failed to deserialize is_verification_pending response") - } - _ => { - anyhow::bail!("Unexpected result from a view client function call"); - } - } - } - pub(crate) async fn get_supported_chains( &self, mpc_contract_id: &AccountId, diff --git a/crates/node/src/indexer/tx_sender.rs b/crates/node/src/indexer/tx_sender.rs index f555a45e66..3161b7a970 100644 --- a/crates/node/src/indexer/tx_sender.rs +++ b/crates/node/src/indexer/tx_sender.rs @@ -186,7 +186,6 @@ async fn submit_tx( /// Confirms whether the intended effect of the transaction request has been observed on chain. async fn observe_tx_result( indexer_state: Arc, - signer_account_id: &AccountId, request: &ChainSendTransactionRequest, ) -> anyhow::Result { match request { @@ -250,18 +249,7 @@ async fn observe_tx_result( .await?; let Some(stored_attestation) = attestation_stored_on_contract else { - // A Dstack attestation is stored only once its async verification - // succeeds; while it's in flight, count the submission as executed - // so we don't resubmit while the previous submission is still pending. - let verification_pending = indexer_state - .view_client - .is_verification_pending(&indexer_state.mpc_contract_id, signer_account_id) - .await?; - return Ok(if verification_pending { - TransactionStatus::Executed - } else { - TransactionStatus::NotExecuted - }); + return Ok(TransactionStatus::NotExecuted); }; let submitted_attestation = @@ -381,8 +369,7 @@ async fn ensure_send_transaction( time::sleep(TRANSACTION_TIMEOUT).await; // Then try to check whether it had the intended effect - let transaction_status = - observe_tx_result(indexer_state.clone(), tx_signer.account_id(), &request).await; + let transaction_status = observe_tx_result(indexer_state.clone(), &request).await; let (outcome_label, recorded_status) = match &transaction_status { Ok(TransactionStatus::Executed) => ("succeeded", SubmittedTransactionStatus::Executed), diff --git a/crates/test-utils/src/contract_types.rs b/crates/test-utils/src/contract_types.rs index 9b64d77631..95809b87d6 100644 --- a/crates/test-utils/src/contract_types.rs +++ b/crates/test-utils/src/contract_types.rs @@ -17,7 +17,6 @@ pub fn dummy_config(value: u64) -> near_mpc_contract_interface::types::Config { remove_non_participant_tee_verifier_votes_tera_gas: value + 13, verifier_tera_gas: value + 14, resolve_verification_tera_gas: value + 15, - on_attestation_verified_tera_gas: value + 16, - fail_attestation_submission_tera_gas: value + 17, + fail_attestation_submission_tera_gas: value + 16, } } From 9c1a346b1e491894dfd8a94be8a7fb446f42575c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 10 Jul 2026 12:33:13 +0200 Subject: [PATCH 11/28] docs(contract): update submit_participant_info doc for the no-yield flow --- crates/contract/src/lib.rs | 6 +++--- .../contract/tests/snapshots/abi__abi_has_not_changed.snap | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 298cbda53f..91b995bdf5 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -771,9 +771,9 @@ impl MpcContract { /// Submit a TEE attestation for a current or prospective participant. /// /// - [`Attestation::Mock`] is verified synchronously. - /// - [`Attestation::Dstack`] is verified asynchronously, by yielding on a - /// cross-contract verify-quote call. It rejects a second submission from - /// the same account while one is still in flight. + /// - [`Attestation::Dstack`] is verified asynchronously via a cross-contract + /// `verify_quote` call, with [`Self::resolve_verification`] chained as its + /// callback to run the post-DCAP checks and settle the deposit. /// /// The attached deposit pays for storage on success, and is refunded on failure. #[payable] diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index fe2fa4ff46..422d471297 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -2335,7 +2335,7 @@ expression: abi }, { "name": "submit_participant_info", - "doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously, by yielding on a\n cross-contract verify-quote call. It rejects a second submission from\n the same account while one is still in flight.\n\n The attached deposit pays for storage on success, and is refunded on failure.", + "doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously via a cross-contract\n `verify_quote` call, with [`Self::resolve_verification`] chained as its\n callback to run the post-DCAP checks and settle the deposit.\n\n The attached deposit pays for storage on success, and is refunded on failure.", "kind": "call", "modifiers": [ "payable" From 290b3dc22cae60efaf46eff864b953b2b981863d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 10 Jul 2026 14:27:17 +0200 Subject: [PATCH 12/28] refactor(contract): read attached deposit from env in charge_attestation_storage --- crates/contract/src/lib.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 91b995bdf5..8c8f2ccb23 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -831,7 +831,6 @@ impl MpcContract { initial_storage, &insertion, caller_is_participant, - env::attached_deposit(), )?; Ok(PromiseOrValue::Value(())) } @@ -880,7 +879,6 @@ impl MpcContract { initial_storage: u64, insertion: &ParticipantInsertion, caller_is_participant: bool, - attached: NearToken, ) -> Result<(), Error> { let is_new_attestation = matches!(insertion, ParticipantInsertion::NewlyInsertedParticipant); @@ -893,6 +891,7 @@ impl MpcContract { // `saturating_sub`: if a re-submission shrinks the entry, charge nothing // rather than underflow. Intentional asymmetry: we do not refund freed // bytes either, since the caller already paid for the larger entry. + let attached = env::attached_deposit(); let storage_used = env::storage_usage().saturating_sub(initial_storage); let cost = env::storage_byte_cost().saturating_mul(storage_used as u128); @@ -2415,7 +2414,6 @@ impl MpcContract { initial_storage, &insertion, context.caller_is_participant, - env::attached_deposit(), ) { Ok(()) => Ok(()), Err(err) => { From b4fa873ed2608f8c0d0dc938d8004cab999a1702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 10 Jul 2026 15:45:14 +0200 Subject: [PATCH 13/28] fix(contract): shadow deployed 3.13.0 Config in migration so migrate() doesn't panic --- crates/contract/src/v3_13_0_state.rs | 60 ++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/contract/src/v3_13_0_state.rs b/crates/contract/src/v3_13_0_state.rs index 83f727d076..ec8c90368a 100644 --- a/crates/contract/src/v3_13_0_state.rs +++ b/crates/contract/src/v3_13_0_state.rs @@ -15,7 +15,8 @@ use near_sdk::{ }; use crate::{ - Config, SupportedForeignChainsByNode, + SupportedForeignChainsByNode, + config::Config, foreign_chains_metadata::ForeignChainsMetadata, node_migrations::NodeMigrations, primitives::{ @@ -27,6 +28,59 @@ use crate::{ update::ProposedUpdates, }; +/// Shadow of the `3.13.0` [`Config`]: the deployed layout predates the async +/// attestation gas fields (`fail_attestation_submission_tera_gas`, +/// `verifier_tera_gas`, `resolve_verification_tera_gas`), so migrating state +/// written by `3.13.0` must deserialize the old field set and then default the +/// new ones. +#[derive(Debug, BorshSerialize, BorshDeserialize)] +struct OldConfig { + key_event_timeout_blocks: u64, + tee_upgrade_deadline_duration_seconds: u64, + contract_upgrade_deposit_tera_gas: u64, + sign_call_gas_attachment_requirement_tera_gas: u64, + ckd_call_gas_attachment_requirement_tera_gas: u64, + return_signature_and_clean_state_on_success_call_tera_gas: u64, + return_ck_and_clean_state_on_success_call_tera_gas: u64, + fail_on_timeout_tera_gas: u64, + clean_tee_status_tera_gas: u64, + clean_invalid_attestations_tera_gas: u64, + cleanup_orphaned_node_migrations_tera_gas: u64, + remove_non_participant_update_votes_tera_gas: u64, + clean_foreign_chain_data_tera_gas: u64, + remove_non_participant_tee_verifier_votes_tera_gas: u64, +} + +impl From for Config { + fn from(old: OldConfig) -> Self { + // Carry the deployed values; the async attestation gas fields are new in + // this release, so take their defaults. + Config { + key_event_timeout_blocks: old.key_event_timeout_blocks, + tee_upgrade_deadline_duration_seconds: old.tee_upgrade_deadline_duration_seconds, + contract_upgrade_deposit_tera_gas: old.contract_upgrade_deposit_tera_gas, + sign_call_gas_attachment_requirement_tera_gas: old + .sign_call_gas_attachment_requirement_tera_gas, + ckd_call_gas_attachment_requirement_tera_gas: old + .ckd_call_gas_attachment_requirement_tera_gas, + return_signature_and_clean_state_on_success_call_tera_gas: old + .return_signature_and_clean_state_on_success_call_tera_gas, + return_ck_and_clean_state_on_success_call_tera_gas: old + .return_ck_and_clean_state_on_success_call_tera_gas, + fail_on_timeout_tera_gas: old.fail_on_timeout_tera_gas, + clean_tee_status_tera_gas: old.clean_tee_status_tera_gas, + clean_invalid_attestations_tera_gas: old.clean_invalid_attestations_tera_gas, + cleanup_orphaned_node_migrations_tera_gas: old.cleanup_orphaned_node_migrations_tera_gas, + remove_non_participant_update_votes_tera_gas: old + .remove_non_participant_update_votes_tera_gas, + clean_foreign_chain_data_tera_gas: old.clean_foreign_chain_data_tera_gas, + remove_non_participant_tee_verifier_votes_tera_gas: old + .remove_non_participant_tee_verifier_votes_tera_gas, + ..Config::default() + } + } +} + /// Keep this module in sync with [`crate::MpcContract`]: the moment a field's borsh /// layout diverges, shadow the old type here (see this module's history for examples) so /// state written by the `3.13.0` contract still deserializes during migration. @@ -38,7 +92,7 @@ pub struct MpcContract { pending_verify_foreign_tx_requests: LookupMap>, proposed_updates: ProposedUpdates, node_foreign_chain_support: SupportedForeignChainsByNode, - config: Config, + config: OldConfig, tee_state: TeeState, accept_requests: bool, node_migrations: NodeMigrations, @@ -61,7 +115,7 @@ impl From for crate::MpcContract { pending_verify_foreign_tx_requests: old.pending_verify_foreign_tx_requests, proposed_updates: old.proposed_updates, node_foreign_chain_support: old.node_foreign_chain_support, - config: old.config, + config: old.config.into(), tee_state: old.tee_state, accept_requests: old.accept_requests, node_migrations: old.node_migrations, From 6a6a1a989b83fa16f6d9e1a8c3b14217d37d5974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 10 Jul 2026 15:52:09 +0200 Subject: [PATCH 14/28] style(contract): rustfmt v3_13_0_state migration shadow --- crates/contract/src/v3_13_0_state.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/contract/src/v3_13_0_state.rs b/crates/contract/src/v3_13_0_state.rs index ec8c90368a..9fc2fd10fd 100644 --- a/crates/contract/src/v3_13_0_state.rs +++ b/crates/contract/src/v3_13_0_state.rs @@ -70,7 +70,8 @@ impl From for Config { fail_on_timeout_tera_gas: old.fail_on_timeout_tera_gas, clean_tee_status_tera_gas: old.clean_tee_status_tera_gas, clean_invalid_attestations_tera_gas: old.clean_invalid_attestations_tera_gas, - cleanup_orphaned_node_migrations_tera_gas: old.cleanup_orphaned_node_migrations_tera_gas, + cleanup_orphaned_node_migrations_tera_gas: old + .cleanup_orphaned_node_migrations_tera_gas, remove_non_participant_update_votes_tera_gas: old .remove_non_participant_update_votes_tera_gas, clean_foreign_chain_data_tera_gas: old.clean_foreign_chain_data_tera_gas, From 9b0851ee53eb5881727ae6d0d2991b5604f548ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 10 Jul 2026 17:03:57 +0200 Subject: [PATCH 15/28] refactor(contract): drop participant-refresh special case in charge_attestation_storage --- crates/contract/src/lib.rs | 38 +++---------------- .../contract/src/tee/verification_context.rs | 1 - .../snapshots/abi__abi_has_not_changed.snap | 7 ---- 3 files changed, 6 insertions(+), 40 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 8bc92edda0..e9ba1723ab 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -93,7 +93,7 @@ use tee_verifier_interface::{VerificationResult, VerifiedReport}; use state::{ProtocolContractState, running::RunningContractState}; use tee::{ proposal::{LauncherVoteAction, NodeImageHash}, - tee_state::{NodeId, ParticipantInsertion, TeeValidationResult}, + tee_state::{NodeId, TeeValidationResult}, }; /// Register used to receive data id from `promise_await_data`. @@ -811,31 +811,22 @@ impl MpcContract { tls_public_key, account_public_key, }; - // Decides who pays for storage. Captured now because the async Dstack - // path checks it in a later callback, where the caller is the contract - // itself and participant status can no longer be derived. - let caller_is_participant = self.voter_account().is_ok(); match proposed_participant_attestation { Attestation::Mock(mock) => { let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); let initial_storage = env::storage_usage(); - let insertion = self.tee_state.verify_and_store_mock( + self.tee_state.verify_and_store_mock( node_id, mock, tee_upgrade_deadline_duration, )?; - self.charge_attestation_storage( - &account_id, - initial_storage, - &insertion, - caller_is_participant, - )?; + self.charge_attestation_storage(&account_id, initial_storage)?; Ok(PromiseOrValue::Value(())) } Attestation::Dstack(attestation) => Ok(PromiseOrValue::Promise( - self.submit_dstack_attestation(node_id, attestation, caller_is_participant)?, + self.submit_dstack_attestation(node_id, attestation)?, )), } } @@ -847,7 +838,6 @@ impl MpcContract { &mut self, node_id: NodeId, attestation: DstackAttestation, - caller_is_participant: bool, ) -> Result { let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { return Err(TeeError::VerifierNotConfigured.into()); @@ -868,7 +858,6 @@ impl MpcContract { .resolve_verification(VerificationContext { node_id, attestation, - caller_is_participant, }), )) } @@ -877,17 +866,7 @@ impl MpcContract { &self, account_id: &AccountId, initial_storage: u64, - insertion: &ParticipantInsertion, - caller_is_participant: bool, ) -> Result<(), Error> { - let is_new_attestation = - matches!(insertion, ParticipantInsertion::NewlyInsertedParticipant); - - if caller_is_participant && !is_new_attestation { - refund_deposit_to(account_id); - return Ok(()); - } - // `saturating_sub`: if a re-submission shrinks the entry, charge nothing // rather than underflow. Intentional asymmetry: we do not refund freed // bytes either, since the caller already paid for the larger entry. @@ -2409,12 +2388,7 @@ impl MpcContract { // The charge is the measured storage delta, so it is only known after // the store; an insufficient deposit reverts the store below. - match self.charge_attestation_storage( - account_id, - initial_storage, - &insertion, - context.caller_is_participant, - ) { + match self.charge_attestation_storage(account_id, initial_storage) { Ok(()) => Ok(()), Err(err) => { // This receipt commits even though we return an error, so the @@ -2856,7 +2830,7 @@ mod tests { KeyProviderEventDigest, MrtdHash, Rtmr0Hash, Rtmr1Hash, Rtmr2Hash, }; use crate::tee::proposal::{LauncherVoteAction, get_docker_compose_hash}; - use crate::tee::tee_state::{NodeAttestation, NodeId}; + use crate::tee::tee_state::{NodeAttestation, NodeId, ParticipantInsertion}; use assert_matches::assert_matches; use dtos::{Attestation, Ed25519PublicKey, ForeignTxSignPayload, MockAttestation}; use dtos::{Curve, DomainConfig, DomainId, Payload, Protocol, ReconstructionThreshold, Tweak}; diff --git a/crates/contract/src/tee/verification_context.rs b/crates/contract/src/tee/verification_context.rs index cf2de409eb..6450f60c04 100644 --- a/crates/contract/src/tee/verification_context.rs +++ b/crates/contract/src/tee/verification_context.rs @@ -11,5 +11,4 @@ use super::tee_state::NodeId; pub struct VerificationContext { pub(crate) node_id: NodeId, pub(crate) attestation: DstackAttestation, - pub(crate) caller_is_participant: bool, } diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 422d471297..7f6d767d5e 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -1565,10 +1565,6 @@ expression: abi [ "attestation", "DstackAttestation" - ], - [ - "caller_is_participant", - "bool" ] ] }, @@ -1592,9 +1588,6 @@ expression: abi "elements": "u8" } }, - "bool": { - "Primitive": 1 - }, "u32": { "Primitive": 4 }, From 6d2765073742247bfa1fde0f9a8eece7867ed9e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 10 Jul 2026 17:14:21 +0200 Subject: [PATCH 16/28] refactor(contract): unify deposit refunds into a generic refund_to helper --- crates/contract/src/lib.rs | 33 +++++++++++---------------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index e9ba1723ab..f7b135ed31 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -134,22 +134,15 @@ fn require_deposit(minimum_deposit: NearToken, predecessor: &AccountId) { .to_string(), ); } - Some(diff) => { - if diff > NearToken::from_yoctonear(0) { - log!("refund excess deposit {diff} to {predecessor}"); - Promise::new(predecessor.clone()).transfer(diff).detach(); - } - } + Some(diff) => refund_to(predecessor, diff), } } -/// Returns `env::attached_deposit()` to `account_id` via a detached transfer -/// promise; no-op when zero. -fn refund_deposit_to(account_id: &AccountId) { - let deposit = env::attached_deposit(); - if deposit > NearToken::from_yoctonear(0) { - log!("refund attestation deposit {deposit} to {account_id}"); - Promise::new(account_id.clone()).transfer(deposit).detach(); +/// Transfers `amount` to `account_id` via a detached promise; no-op when zero. +fn refund_to(account_id: &AccountId, amount: NearToken) { + if amount > NearToken::from_yoctonear(0) { + log!("refund {amount} to {account_id}"); + Promise::new(account_id.clone()).transfer(amount).detach(); } } @@ -882,10 +875,8 @@ impl MpcContract { .into()); } - if let Some(diff) = attached.checked_sub(cost) - && diff > NearToken::from_yoctonear(0) - { - Promise::new(account_id.clone()).transfer(diff).detach(); + if let Some(diff) = attached.checked_sub(cost) { + refund_to(account_id, diff); } Ok(()) } @@ -1363,10 +1354,8 @@ impl MpcContract { ); // 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(); + if let Some(diff) = attached.checked_sub(required) { + refund_to(&proposer, diff); } Ok(id) @@ -2344,7 +2333,7 @@ impl MpcContract { match attestation_result { Ok(()) => PromiseOrValue::Value(()), Err(err) => { - refund_deposit_to(&account_id); + refund_to(&account_id, env::attached_deposit()); // Fail the submitter's transaction from a separate receipt so // the refund above commits (a panic here would roll it back) let promise = Promise::new(env::current_account_id()).function_call( From ee9f82eb3de2faac8404b0be2ebf622f84ba116a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 13 Jul 2026 17:47:30 +0200 Subject: [PATCH 17/28] fix(contract): flush attestation store before charging storage `stored_attestations` is a `near_sdk::store::IterableMap`, which buffers writes and only serializes them on flush/Drop. Both attestation charge paths read the `env::storage_usage()` delta immediately after the insert, before the buffered write reaches the trie, so the delta was always zero: `submit_participant_info` accepted a new attestation with zero deposit and refunded it in full, letting any account grow contract storage for free. Flush the map in the shared `store_verified_attestation` tail so the insert is visible to the delta measured by `charge_attestation_storage`, covering both the synchronous mock path and the async dstack path. --- crates/contract/src/lib.rs | 4 +- crates/contract/src/tee/tee_state.rs | 31 ++++++++ crates/contract/tests/sandbox/tee.rs | 102 +++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index f7b135ed31..9b42746444 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -864,8 +864,10 @@ impl MpcContract { // rather than underflow. Intentional asymmetry: we do not refund freed // bytes either, since the caller already paid for the larger entry. let attached = env::attached_deposit(); + // Relies on the attestation store having flushed its insert already; it + // defers writes to flush-on-Drop, so an unflushed insert reads as a zero delta let storage_used = env::storage_usage().saturating_sub(initial_storage); - let cost = env::storage_byte_cost().saturating_mul(storage_used as u128); + let cost = env::storage_byte_cost().saturating_mul(u128::from(storage_used)); if attached < cost { return Err(InvalidParameters::InsufficientDeposit { diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index f743d7e403..ee7ac15fca 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -235,6 +235,10 @@ impl TeeState { }, ); + // Materialize the insert before the storage-usage charge reads it; + // `IterableMap` otherwise defers the write to flush-on-Drop + self.stored_attestations.flush(); + Ok(match previous { Some(previous) => ParticipantInsertion::UpdatedExistingParticipant(previous), None => ParticipantInsertion::NewlyInsertedParticipant, @@ -893,6 +897,33 @@ mod tests { ); } + #[test] + fn verify_and_store_mock__should_flush_so_storage_usage_grows() { + // Given + testing_env!(VMContextBuilder::new().build()); + let mut tee_state = TeeState::default(); + let node_id = NodeId { + account_id: "alice.near".parse().unwrap(), + tls_public_key: bogus_ed25519_public_key(), + account_public_key: bogus_ed25519_public_key(), + }; + let storage_before = env::storage_usage(); + + // When + tee_state + .verify_and_store_mock(node_id, MockAttestation::Valid, Duration::from_secs(0)) + .unwrap(); + + // Then: the store must flush the insert so the charged storage delta is + // nonzero. Without the flush the write defers to drop and this reads zero, + // letting a caller store an attestation without paying for it. + let storage_after = env::storage_usage(); + assert!( + storage_after > storage_before, + "env::storage_usage() should grow after the store ({storage_before} -> {storage_after})" + ); + } + #[test] fn verify_and_store_mock__should_index_by_tls_key() { // given diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index 1ab3d32a7d..675365a3d6 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -984,3 +984,105 @@ async fn verify_tee__should_keep_participants_and_stop_signing_when_kickout_drop Ok(()) } + +/// Regression test for the lazy-collection storage-charge bug: a brand-new +/// attestation submitted with zero deposit must be rejected and not persisted. +#[tokio::test] +async fn submit_participant_info__should_reject_new_attestation_with_zero_deposit() -> Result<()> { + // Given + let SandboxTestSetup { + worker, contract, .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .build() + .await; + let outsider = worker.dev_create_account().await?; + let fresh_tls_key = bogus_ed25519_public_key(); + let storage_before = worker.view_account(contract.id()).await?.storage_usage; + + // When + let result = outsider + .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) + .args_json(( + Attestation::Mock(MockAttestation::Valid), + fresh_tls_key.clone(), + )) + .deposit(NearToken::from_yoctonear(0)) + .max_gas() + .transact() + .await?; + + // Then + assert!( + !result.is_success(), + "zero-deposit submission of a new attestation must fail: {result:?}" + ); + let error_msg = format!("{:?}", result.into_result()); + assert!( + error_msg.contains("Attached deposit is lower than required"), + "expected an insufficient-deposit error, got: {error_msg}" + ); + let stored = get_participant_attestation(&contract, &fresh_tls_key).await?; + assert!( + stored.is_none(), + "no attestation should be stored when the deposit is rejected" + ); + let storage_after = worker.view_account(contract.id()).await?.storage_usage; + assert_eq!( + storage_after, storage_before, + "contract storage must not grow when the submission is rejected" + ); + Ok(()) +} + +/// A funded submission stores the attestation and refunds the deposit that exceeds +/// the measured storage cost, so the caller pays only for storage (plus gas). +#[tokio::test] +async fn submit_participant_info__should_store_and_refund_excess_with_sufficient_deposit() +-> Result<()> { + // Given + const ATTACHED_DEPOSIT: NearToken = NearToken::from_near(1); + let SandboxTestSetup { + worker, contract, .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .build() + .await; + let outsider = worker.dev_create_account().await?; + let fresh_tls_key = bogus_ed25519_public_key(); + let balance_before = outsider.view_account().await?.balance; + + // When + let result = outsider + .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) + .args_json(( + Attestation::Mock(MockAttestation::Valid), + fresh_tls_key.clone(), + )) + .deposit(ATTACHED_DEPOSIT) + .max_gas() + .transact() + .await?; + + // Then + assert!( + result.is_success(), + "funded submission of a new attestation should succeed: {result:?}" + ); + let stored = get_participant_attestation(&contract, &fresh_tls_key).await?; + assert!( + stored.is_some(), + "the attestation entry should be stored on-chain" + ); + // A single mock attestation is a few hundred bytes; at the sandbox storage + // price it costs a tiny fraction of the 1 NEAR attached. The caller's net + // balance drop (storage cost + gas) must therefore be far below the full + // deposit, proving the excess was refunded rather than kept. + let balance_after = outsider.view_account().await?.balance; + let spent = balance_before.saturating_sub(balance_after); + assert!( + spent < NearToken::from_millinear(100), + "caller should be refunded all but the storage cost; spent {spent} of {ATTACHED_DEPOSIT}" + ); + Ok(()) +} From aa462615288c057665c17f6548b501810c7a4d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 13 Jul 2026 18:04:20 +0200 Subject: [PATCH 18/28] fixup! fix(contract): flush attestation store before charging storage --- crates/contract/tests/sandbox/tee.rs | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index 675365a3d6..691e06a1c0 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -1035,12 +1035,14 @@ async fn submit_participant_info__should_reject_new_attestation_with_zero_deposi Ok(()) } -/// A funded submission stores the attestation and refunds the deposit that exceeds -/// the measured storage cost, so the caller pays only for storage (plus gas). +/// Mirror of the zero-deposit test with a funded submission: the attestation is +/// stored, storage grows, and the caller is charged a non-zero amount for it (the +/// bug charged zero). #[tokio::test] -async fn submit_participant_info__should_store_and_refund_excess_with_sufficient_deposit() +async fn submit_participant_info__should_store_new_attestation_and_charge_with_sufficient_deposit() -> Result<()> { - // Given + // Given: the sandbox storage price, 1e19 yocto per byte. + const STORAGE_COST_PER_BYTE: u128 = 10u128.pow(19); const ATTACHED_DEPOSIT: NearToken = NearToken::from_near(1); let SandboxTestSetup { worker, contract, .. @@ -1050,6 +1052,7 @@ async fn submit_participant_info__should_store_and_refund_excess_with_sufficient .await; let outsider = worker.dev_create_account().await?; let fresh_tls_key = bogus_ed25519_public_key(); + let storage_before = worker.view_account(contract.id()).await?.storage_usage; let balance_before = outsider.view_account().await?.balance; // When @@ -1074,15 +1077,19 @@ async fn submit_participant_info__should_store_and_refund_excess_with_sufficient stored.is_some(), "the attestation entry should be stored on-chain" ); - // A single mock attestation is a few hundred bytes; at the sandbox storage - // price it costs a tiny fraction of the 1 NEAR attached. The caller's net - // balance drop (storage cost + gas) must therefore be far below the full - // deposit, proving the excess was refunded rather than kept. + let storage_after = worker.view_account(contract.id()).await?.storage_usage; + let bytes_grown = storage_after - storage_before; + assert!( + bytes_grown > 0, + "contract storage should grow ({storage_before} -> {storage_after})" + ); + + let storage_stake = NearToken::from_yoctonear(u128::from(bytes_grown) * STORAGE_COST_PER_BYTE); let balance_after = outsider.view_account().await?.balance; let spent = balance_before.saturating_sub(balance_after); assert!( - spent < NearToken::from_millinear(100), - "caller should be refunded all but the storage cost; spent {spent} of {ATTACHED_DEPOSIT}" + spent >= storage_stake, + "caller must be charged at least the storage stake ({storage_stake}) for {bytes_grown} new bytes, spent {spent}" ); Ok(()) } From d11cb857a62faafc0cadb9edc4eff135e765d63c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 13 Jul 2026 18:18:59 +0200 Subject: [PATCH 19/28] fixup! fix(contract): flush attestation store before charging storage --- .../tests/inprocess/attestation_submission.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index c2cf694f24..0cfddd190c 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -28,6 +28,8 @@ use std::{str::FromStr, time::Duration}; const SECOND: Duration = Duration::from_secs(1); const NANOS_IN_SECOND: u64 = SECOND.as_nanos() as u64; +const ATTESTATION_STORAGE_DEPOSIT: NearToken = NearToken::from_near(1); + const DEFAULT_PARTICIPANT_COUNT: usize = 3; const DEFAULT_THRESHOLD_SIZE: u64 = 2; const DEFAUTL_CONTRACT_PROTOCOL_STATE: ContractProtocolState = ContractProtocolState::Running; @@ -274,6 +276,7 @@ fn create_context_for_participant(account_id: &AccountId) -> VMContext { .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .block_timestamp(near_sdk::env::block_timestamp()) + .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) .build() } @@ -298,7 +301,7 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { testing_env!( VMContextBuilder::new() - .attached_deposit(NearToken::from_near(1)) + .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) .build() ); @@ -330,7 +333,7 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { VMContextBuilder::new() .signer_account_id(attacker_node.account_id.clone()) .predecessor_account_id(attacker_node.account_id.clone()) - .attached_deposit(NearToken::from_near(1)) + .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) .build() ); let attack_result = setup @@ -368,7 +371,7 @@ fn clean_tee_status__should_not_touch_attestations() { testing_env!( VMContextBuilder::new() - .attached_deposit(NearToken::from_near(1)) + .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) .build() ); @@ -453,7 +456,7 @@ fn clean_invalid_attestations__should_remove_expired_entries() { testing_env!( VMContextBuilder::new() - .attached_deposit(NearToken::from_near(1)) + .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) .block_timestamp(0) .build() ); @@ -515,7 +518,7 @@ fn clean_invalid_attestations__should_reject_when_not_running() { // Given: contract sitting in Initializing state. testing_env!( VMContextBuilder::new() - .attached_deposit(NearToken::from_near(1)) + .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) .block_timestamp(0) .build() ); From 721dc6c2d01edfe6e6e665e1a63dfb6c20af6364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 13 Jul 2026 18:31:47 +0200 Subject: [PATCH 20/28] fixup! fix(contract): flush attestation store before charging storage --- crates/contract/tests/sandbox/utils/consts.rs | 4 ++++ crates/contract/tests/sandbox/utils/mpc_contract.rs | 2 ++ 2 files changed, 6 insertions(+) diff --git a/crates/contract/tests/sandbox/utils/consts.rs b/crates/contract/tests/sandbox/utils/consts.rs index b122dad2f6..822c91aca3 100644 --- a/crates/contract/tests/sandbox/utils/consts.rs +++ b/crates/contract/tests/sandbox/utils/consts.rs @@ -45,4 +45,8 @@ pub const MAX_GAS_FOR_THRESHOLD_VOTE: Gas = Gas::from_tgas(190); /// TODO(#2756): Reduce this to the minimal value possible pub const CURRENT_CONTRACT_DEPLOY_DEPOSIT: NearToken = NearToken::from_millinear(17000); +/// Attached to `submit_participant_info` calls; dwarfs a single attestation entry's +/// storage cost so the storage charge always succeeds. +pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT: NearToken = NearToken::from_near(1); + pub const DEFAULT_MAX_TIMEOUT_TX_INCLUDED: Duration = Duration::from_secs(3); diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index ce9690131e..8e5aa06c80 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -1,5 +1,6 @@ use std::collections::BTreeSet; +use super::consts::SUBMIT_PARTICIPANT_INFO_DEPOSIT; use super::transactions::all_receipts_successful; use mpc_contract::tee::tee_state::NodeId; use mpc_primitives::hash::{LauncherImageHash, NodeImageHash}; @@ -50,6 +51,7 @@ pub async fn submit_participant_info( let result = account .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) .args_json((attestation, tls_key)) + .deposit(SUBMIT_PARTICIPANT_INFO_DEPOSIT) .max_gas() .transact() .await?; From 756c5981e5fabcfd0d0d391ec3c953bcfa4aa018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 14 Jul 2026 12:07:44 +0200 Subject: [PATCH 21/28] feat(node): attach submit_participant_info storage deposit The node, tee-context, and e2e/test harnesses previously submitted submit_participant_info with a zero deposit, so the contract's storage charge would fail once submissions started paying for their own storage. Add a shared SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR in the interface crate's new deposits module and use it everywhere: - node: ChainSendTransactionRequest::deposit_required(), threaded through submit_tx and the TransactionSigner - tee-context: submit_attestation attaches it - e2e: init_contract submits with gas + deposit - contract tests: sandbox and inprocess consts derive from the shared value The contract charges the measured storage cost and refunds the excess. --- .../tests/inprocess/attestation_submission.rs | 13 ++++++---- crates/contract/tests/sandbox/utils/consts.rs | 11 +++++--- crates/e2e-tests/src/cluster.rs | 22 +++++++++++----- .../src/deposits.rs | 6 +++++ crates/near-mpc-contract-interface/src/lib.rs | 1 + crates/node/src/indexer/tx_sender.rs | 5 +++- crates/node/src/indexer/tx_signer.rs | 3 ++- crates/node/src/indexer/types.rs | 26 ++++++++++++++----- crates/tee-context/src/lib.rs | 17 ++++++------ 9 files changed, 71 insertions(+), 33 deletions(-) create mode 100644 crates/near-mpc-contract-interface/src/deposits.rs diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index 0cfddd190c..600d7fc6eb 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -12,11 +12,13 @@ use mpc_contract::{ }, tee::tee_state::{AttestationSubmissionError, NodeId}, }; -use near_mpc_contract_interface::types::{ - Attestation, InitConfig, MockAttestation, Protocol, ProtocolContractState, - ReconstructionThreshold, +use near_mpc_contract_interface::{ + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR, + types::{ + Attestation, DomainConfig, DomainId, DomainPurpose, InitConfig, MockAttestation, Protocol, + ProtocolContractState, ReconstructionThreshold, + }, }; -use near_mpc_contract_interface::types::{DomainConfig, DomainId, DomainPurpose}; use std::collections::BTreeMap; use assert_matches::assert_matches; @@ -28,7 +30,8 @@ use std::{str::FromStr, time::Duration}; const SECOND: Duration = Duration::from_secs(1); const NANOS_IN_SECOND: u64 = SECOND.as_nanos() as u64; -const ATTESTATION_STORAGE_DEPOSIT: NearToken = NearToken::from_near(1); +const ATTESTATION_STORAGE_DEPOSIT: NearToken = + NearToken::from_yoctonear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR); const DEFAULT_PARTICIPANT_COUNT: usize = 3; const DEFAULT_THRESHOLD_SIZE: u64 = 2; diff --git a/crates/contract/tests/sandbox/utils/consts.rs b/crates/contract/tests/sandbox/utils/consts.rs index 822c91aca3..590ef3496c 100644 --- a/crates/contract/tests/sandbox/utils/consts.rs +++ b/crates/contract/tests/sandbox/utils/consts.rs @@ -1,6 +1,8 @@ use std::time::Duration; -use near_mpc_contract_interface::types::Protocol; +use near_mpc_contract_interface::{ + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR, types::Protocol, +}; use near_sdk::{Gas, NearToken}; /* --- Protocol defaults --- */ @@ -45,8 +47,9 @@ pub const MAX_GAS_FOR_THRESHOLD_VOTE: Gas = Gas::from_tgas(190); /// TODO(#2756): Reduce this to the minimal value possible pub const CURRENT_CONTRACT_DEPLOY_DEPOSIT: NearToken = NearToken::from_millinear(17000); -/// Attached to `submit_participant_info` calls; dwarfs a single attestation entry's -/// storage cost so the storage charge always succeeds. -pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT: NearToken = NearToken::from_near(1); +/// Attached to `submit_participant_info`; the contract charges the measured storage +/// cost and refunds the excess. +pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT: NearToken = + NearToken::from_yoctonear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR); pub const DEFAULT_MAX_TIMEOUT_TX_INCLUDED: Duration = Duration::from_secs(3); diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index a7967d50f0..f4a74b6161 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -6,12 +6,15 @@ use anyhow::Context; use backon::{ConstantBuilder, Retryable}; use ed25519_dalek::SigningKey; use near_kit::AccountId; -use near_mpc_contract_interface::method_names; -use near_mpc_contract_interface::types::{ - AccountId as ContractAccountId, CKDAppPublicKey, DomainConfig, DomainId, DomainPurpose, - Ed25519PublicKey, EpochId, ParticipantId, ParticipantInfo, Participants, - ProposedThresholdParameters, Protocol, ProtocolContractState, ReconstructionThreshold, - Threshold, ThresholdParameters, +use near_mpc_contract_interface::{ + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR, + method_names, + types::{ + AccountId as ContractAccountId, CKDAppPublicKey, DomainConfig, DomainId, DomainPurpose, + Ed25519PublicKey, EpochId, ParticipantId, ParticipantInfo, Participants, + ProposedThresholdParameters, Protocol, ProtocolContractState, ReconstructionThreshold, + Threshold, ThresholdParameters, + }, }; use rand::SeedableRng; use rand::rngs::StdRng; @@ -46,6 +49,9 @@ const SIGN_DEPOSIT: near_kit::NearToken = near_kit::NearToken::from_yoctonear(1) const KEY_EVENT_TIMEOUT_BLOCKS: u64 = 240; const CONTRACT_UPDATE_DEPOSIT: near_kit::NearToken = near_kit::NearToken::from_millinear(17_000); const CONTRACT_UPDATE_GAS: near_kit::Gas = near_kit::Gas::from_tgas(300); +const SUBMIT_PARTICIPANT_INFO_DEPOSIT: near_kit::NearToken = + near_kit::NearToken::from_yoctonear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR); +const SUBMIT_PARTICIPANT_INFO_GAS: near_kit::Gas = near_kit::Gas::from_tgas(300); const CONTRACT_DEPLOY_TIMEOUT: Duration = Duration::from_secs(15); const PROPOSER_NODE_INDEX: usize = 0; @@ -1186,13 +1192,15 @@ async fn init_contract( let pubkey = near_mpc_crypto_types::Ed25519PublicKey::from(p2p_keys[i].verifying_key().to_bytes()); contract - .call_from( + .call_from_with_deposit( &client, method_names::SUBMIT_PARTICIPANT_INFO, json!({ "proposed_participant_attestation": { "Mock": "Valid" }, "tls_public_key": pubkey, }), + SUBMIT_PARTICIPANT_INFO_GAS, + SUBMIT_PARTICIPANT_INFO_DEPOSIT, ) .await .with_context(|| format!("failed to submit attestation for node {i}"))?; diff --git a/crates/near-mpc-contract-interface/src/deposits.rs b/crates/near-mpc-contract-interface/src/deposits.rs new file mode 100644 index 0000000000..144ce28489 --- /dev/null +++ b/crates/near-mpc-contract-interface/src/deposits.rs @@ -0,0 +1,6 @@ +//! Deposit amounts to attach to contract methods, in yoctoNEAR. One shared value +//! for node, tests, and e2e. + +/// Deposit for `submit_participant_info`. The contract charges the actual storage +/// cost and refunds the excess. +pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR: u128 = 1_000_000_000_000_000_000_000_000; diff --git a/crates/near-mpc-contract-interface/src/lib.rs b/crates/near-mpc-contract-interface/src/lib.rs index bc09356bc1..e14fd44e4a 100644 --- a/crates/near-mpc-contract-interface/src/lib.rs +++ b/crates/near-mpc-contract-interface/src/lib.rs @@ -2,6 +2,7 @@ #[cfg(feature = "call-args")] pub mod call_args; +pub mod deposits; pub mod method_names; pub mod types { pub use attestation::{ diff --git a/crates/node/src/indexer/tx_sender.rs b/crates/node/src/indexer/tx_sender.rs index f566815500..e94e7a4000 100644 --- a/crates/node/src/indexer/tx_sender.rs +++ b/crates/node/src/indexer/tx_sender.rs @@ -10,7 +10,7 @@ use crate::types::{ use anyhow::Context; use ed25519_dalek::SigningKey; use near_account_id::AccountId; -use near_indexer_primitives::types::Gas; +use near_indexer_primitives::types::{Balance, Gas}; use near_mpc_contract_interface::types::{Attestation, Ed25519PublicKey, VerifiedAttestation}; use near_time::Clock; use std::future::Future; @@ -148,6 +148,7 @@ async fn submit_tx( method: String, params_ser: String, gas: Gas, + deposit: Balance, ) -> anyhow::Result { let block = indexer_state.view_client.latest_final_block().await?; @@ -156,6 +157,7 @@ async fn submit_tx( method, params_ser.into(), gas, + deposit, block.header.hash, block.header.height, ); @@ -340,6 +342,7 @@ async fn ensure_send_transaction( method.to_string(), params_ser.clone(), request.gas_required(), + request.deposit_required(), ) .await; diff --git a/crates/node/src/indexer/tx_signer.rs b/crates/node/src/indexer/tx_signer.rs index 5d6de6b842..9a956c1068 100644 --- a/crates/node/src/indexer/tx_signer.rs +++ b/crates/node/src/indexer/tx_signer.rs @@ -42,6 +42,7 @@ impl TransactionSigner { method_name: String, args: Vec, gas: Gas, + deposit: Balance, block_hash: CryptoHash, block_height: u64, ) -> SignedTransaction { @@ -49,7 +50,7 @@ impl TransactionSigner { method_name, args, gas, - deposit: Balance::from_near(0), + deposit, }; let verifying_key = self.signing_key.verifying_key(); diff --git a/crates/node/src/indexer/types.rs b/crates/node/src/indexer/types.rs index 1448a32a03..cd5572ac36 100644 --- a/crates/node/src/indexer/types.rs +++ b/crates/node/src/indexer/types.rs @@ -6,14 +6,17 @@ use k256::{ ecdsa::RecoveryId, elliptic_curve::{Curve, CurveArithmetic, ops::Reduce, point::AffineCoordinates}, }; -use near_indexer_primitives::types::Gas; -use near_mpc_contract_interface::call_args as contract_args; -use near_mpc_contract_interface::method_names::{ - CONCLUDE_NODE_MIGRATION, RESPOND, RESPOND_CKD, RESPOND_VERIFY_FOREIGN_TX, - START_KEYGEN_INSTANCE, START_RESHARE_INSTANCE, SUBMIT_PARTICIPANT_INFO, VERIFY_TEE, - VOTE_ABORT_KEY_EVENT_INSTANCE, VOTE_PK, VOTE_RESHARED, +use near_indexer_primitives::types::{Balance, Gas}; +use near_mpc_contract_interface::{ + call_args as contract_args, + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR, + method_names::{ + CONCLUDE_NODE_MIGRATION, RESPOND, RESPOND_CKD, RESPOND_VERIFY_FOREIGN_TX, + START_KEYGEN_INSTANCE, START_RESHARE_INSTANCE, SUBMIT_PARTICIPANT_INFO, VERIFY_TEE, + VOTE_ABORT_KEY_EVENT_INSTANCE, VOTE_PK, VOTE_RESHARED, + }, + types::{self as dtos}, }; -use near_mpc_contract_interface::types::{self as dtos}; use serde::Serialize; use threshold_signatures::ecdsa::Signature; use threshold_signatures::frost_ed25519; @@ -121,6 +124,15 @@ impl ChainSendTransactionRequest { | Self::VerifyForeignTransactionRespond(_) => MAX_GAS, } } + + pub fn deposit_required(&self) -> Balance { + match self { + Self::SubmitParticipantInfo { .. } => { + Balance::from_yoctonear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR) + } + _ => Balance::from_yoctonear(0), + } + } } /// Extension trait for constructing SignatureRespond arguments from node-internal types. diff --git a/crates/tee-context/src/lib.rs b/crates/tee-context/src/lib.rs index 0088af5cda..5119f2b65f 100644 --- a/crates/tee-context/src/lib.rs +++ b/crates/tee-context/src/lib.rs @@ -11,13 +11,14 @@ use chain_gateway::{ types::FunctionCallArgs, }; use near_account_id::AccountId; -use near_mpc_contract_interface::call_args as contract_args; -use near_mpc_contract_interface::method_names::{ - ALLOWED_DOCKER_IMAGE_HASHES, ALLOWED_LAUNCHER_COMPOSE_HASHES, SUBMIT_PARTICIPANT_INFO, - VERIFY_TEE, -}; -use near_mpc_contract_interface::types::{ - AllowedMpcDockerImageHash, Attestation, Ed25519PublicKey, +use near_mpc_contract_interface::{ + call_args as contract_args, + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR, + method_names::{ + ALLOWED_DOCKER_IMAGE_HASHES, ALLOWED_LAUNCHER_COMPOSE_HASHES, SUBMIT_PARTICIPANT_INFO, + VERIFY_TEE, + }, + types::{AllowedMpcDockerImageHash, Attestation, Ed25519PublicKey}, }; use serde::Deserialize; use tokio::sync::watch; @@ -133,7 +134,7 @@ where method_name: SUBMIT_PARTICIPANT_INFO.to_string(), args: args_json, gas: SUBMIT_ATTESTATION_GAS, - deposit: NearToken::from_yoctonear(0), + deposit: NearToken::from_yoctonear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR), }, ) .await From 64a3e94c2f1933332787b0ed6d1965ca7a57e03a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 14 Jul 2026 12:26:22 +0200 Subject: [PATCH 22/28] fixup! feat(node): attach submit_participant_info storage deposit --- crates/node/src/indexer/tx_signer.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/node/src/indexer/tx_signer.rs b/crates/node/src/indexer/tx_signer.rs index 9a956c1068..1152fc6d72 100644 --- a/crates/node/src/indexer/tx_signer.rs +++ b/crates/node/src/indexer/tx_signer.rs @@ -36,6 +36,7 @@ impl TransactionSigner { new_nonce } + #[expect(clippy::too_many_arguments)] pub(crate) fn create_and_sign_function_call_tx( &self, receiver_id: AccountId, From 8e757458747c2b0fc86dea1833220c84f69aa002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 14 Jul 2026 14:29:09 +0200 Subject: [PATCH 23/28] fixup! refactor(contract): unify zero-NearToken spelling to from_yoctonear(0) --- crates/contract/src/update.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/contract/src/update.rs b/crates/contract/src/update.rs index 980a794e32..5826936d2d 100644 --- a/crates/contract/src/update.rs +++ b/crates/contract/src/update.rs @@ -206,7 +206,7 @@ impl ProposedUpdates { promise = promise.deploy_contract(code).function_call( method_names::MIGRATE, Vec::new(), - NearToken::from_near(0), + NearToken::from_yoctonear(0), gas, ); } @@ -218,7 +218,7 @@ impl ProposedUpdates { promise = promise.function_call( method_names::UPDATE_CONFIG, serde_json::to_vec(&(&config,)).unwrap(), - NearToken::from_near(0), + NearToken::from_yoctonear(0), new_config_gas_value, ); } From f2f470a91d0432dbcf6e512dd93bf8ed76b60c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 14 Jul 2026 14:57:46 +0200 Subject: [PATCH 24/28] refactor(contract): express NEAR amounts via from_near, not from_yoctonear Prefer from_near for legible NEAR amounts: - rename SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR (= 10^24) to SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR (= 1), and switch its call sites from from_yoctonear to from_near; the 25-digit yocto literal was unreadable - convert every zero deposit from from_yoctonear(0) to from_near(0) Reverses the direction of the earlier from_near(0) -> from_yoctonear(0) unification: from_near reads more clearly for whole-NEAR and zero amounts. Non-zero yocto values (from_yoctonear(1) minimum-deposit sentinels, storage cost math) are unchanged. Behavior is identical. --- .../src/transaction_sender/signer.rs | 2 +- .../src/transaction_sender/traits.rs | 2 +- crates/contract/src/lib.rs | 32 +++++++++---------- crates/contract/src/update.rs | 4 +-- .../tests/inprocess/attestation_submission.rs | 4 +-- crates/contract/tests/sandbox/tee.rs | 2 +- crates/contract/tests/sandbox/utils/consts.rs | 4 +-- crates/e2e-tests/src/cluster.rs | 4 +-- .../src/deposits.rs | 4 +-- crates/node/src/indexer/types.rs | 6 ++-- crates/tee-context/src/lib.rs | 6 ++-- 11 files changed, 35 insertions(+), 35 deletions(-) diff --git a/crates/chain-gateway/src/transaction_sender/signer.rs b/crates/chain-gateway/src/transaction_sender/signer.rs index c955f5d18f..a7741d4f6a 100644 --- a/crates/chain-gateway/src/transaction_sender/signer.rs +++ b/crates/chain-gateway/src/transaction_sender/signer.rs @@ -144,7 +144,7 @@ mod tests { method_name: "do_something".to_string(), args: b"test args".to_vec(), gas: TEST_GAS, - deposit: NearToken::from_yoctonear(0), + deposit: NearToken::from_near(0), } } diff --git a/crates/chain-gateway/src/transaction_sender/traits.rs b/crates/chain-gateway/src/transaction_sender/traits.rs index 99d52c1744..4b173903e7 100644 --- a/crates/chain-gateway/src/transaction_sender/traits.rs +++ b/crates/chain-gateway/src/transaction_sender/traits.rs @@ -86,7 +86,7 @@ mod tests { let mut args = vec![0u8; 16]; rng.fill(&mut args[..]); let gas = NearGas::from_gas(300); - let deposit = NearToken::from_yoctonear(0); + let deposit = NearToken::from_near(0); ( receiver_id, FunctionCallArgs { diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 9b42746444..8e64af06a3 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -140,7 +140,7 @@ fn require_deposit(minimum_deposit: NearToken, predecessor: &AccountId) { /// Transfers `amount` to `account_id` via a detached promise; no-op when zero. fn refund_to(account_id: &AccountId, amount: NearToken) { - if amount > NearToken::from_yoctonear(0) { + if amount > NearToken::from_near(0) { log!("refund {amount} to {account_id}"); Promise::new(account_id.clone()).transfer(amount).detach(); } @@ -841,7 +841,7 @@ impl MpcContract { method_names::VERIFY_QUOTE.to_string(), borsh::to_vec(&(&attestation.quote, &attestation.collateral)) .expect("borsh serialization of verify_quote args must succeed"), - NearToken::from_yoctonear(0), + NearToken::from_near(0), Gas::from_tgas(self.config.verifier_tera_gas), ) .then( @@ -1210,7 +1210,7 @@ impl MpcContract { .function_call( method_names::REMOVE_NON_PARTICIPANT_UPDATE_VOTES.to_string(), vec![], - NearToken::from_yoctonear(0), + NearToken::from_near(0), Gas::from_tgas(self.config.remove_non_participant_update_votes_tera_gas), ) .detach(); @@ -1219,7 +1219,7 @@ impl MpcContract { .function_call( method_names::CLEAN_TEE_STATUS.to_string(), vec![], - NearToken::from_yoctonear(0), + NearToken::from_near(0), Gas::from_tgas(self.config.clean_tee_status_tera_gas), ) .detach(); @@ -1231,7 +1231,7 @@ impl MpcContract { "max_scan": RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN })) .unwrap(), - NearToken::from_yoctonear(0), + NearToken::from_near(0), Gas::from_tgas(self.config.clean_invalid_attestations_tera_gas), ) .detach(); @@ -1240,7 +1240,7 @@ impl MpcContract { .function_call( method_names::CLEANUP_ORPHANED_NODE_MIGRATIONS.to_string(), vec![], - NearToken::from_yoctonear(0), + NearToken::from_near(0), Gas::from_tgas(self.config.cleanup_orphaned_node_migrations_tera_gas), ) .detach(); @@ -1249,7 +1249,7 @@ impl MpcContract { .function_call( method_names::CLEAN_FOREIGN_CHAIN_DATA.to_string(), vec![], - NearToken::from_yoctonear(0), + NearToken::from_near(0), Gas::from_tgas(self.config.clean_foreign_chain_data_tera_gas), ) .detach(); @@ -1258,7 +1258,7 @@ impl MpcContract { .function_call( method_names::REMOVE_NON_PARTICIPANT_TEE_VERIFIER_VOTES.to_string(), vec![], - NearToken::from_yoctonear(0), + NearToken::from_near(0), Gas::from_tgas( self.config .remove_non_participant_tee_verifier_votes_tera_gas, @@ -2292,7 +2292,7 @@ impl MpcContract { let promise = Promise::new(env::current_account_id()).function_call( method_names::FAIL_ON_TIMEOUT.to_string(), vec![], - NearToken::from_yoctonear(0), + NearToken::from_near(0), fail_on_timeout_gas, ); near_sdk::PromiseOrValue::Promise(promise.as_return()) @@ -2342,7 +2342,7 @@ impl MpcContract { method_names::FAIL_ATTESTATION_SUBMISSION.to_string(), borsh::to_vec(&err.to_string()) .expect("borsh serialization of reason must succeed"), - NearToken::from_yoctonear(0), + NearToken::from_near(0), Gas::from_tgas(self.config.fail_attestation_submission_tera_gas), ); PromiseOrValue::Promise(promise.as_return()) @@ -2418,7 +2418,7 @@ impl MpcContract { let promise = Promise::new(env::current_account_id()).function_call( method_names::FAIL_ON_TIMEOUT.to_string(), vec![], - NearToken::from_yoctonear(0), + NearToken::from_near(0), fail_on_timeout_gas, ); near_sdk::PromiseOrValue::Promise(promise.as_return()) @@ -2451,7 +2451,7 @@ impl MpcContract { let promise = Promise::new(env::current_account_id()).function_call( method_names::FAIL_ON_TIMEOUT.to_string(), vec![], - NearToken::from_yoctonear(0), + NearToken::from_near(0), fail_on_timeout_gas, ); near_sdk::PromiseOrValue::Promise(promise.as_return()) @@ -3673,7 +3673,7 @@ mod tests { #[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)); + override_context_for_preconditions(NearToken::from_near(0), Gas::from_tgas(300)); contract.check_request_preconditions( DomainId::default(), DomainPurpose::Sign, @@ -4151,7 +4151,7 @@ mod tests { 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)) + .attached_deposit(NearToken::from_near(0)) .build(); testing_env!(voting_context); @@ -4308,7 +4308,7 @@ mod tests { VMContextBuilder::new() .signer_account_id(signer.clone()) .predecessor_account_id(signer.clone()) - .attached_deposit(NearToken::from_yoctonear(0)) + .attached_deposit(NearToken::from_near(0)) .build() ); contract.vote_new_parameters(EpochId::new(1), proposal.into_dto_type()) @@ -4449,7 +4449,7 @@ mod tests { let ctx = VMContextBuilder::new() .signer_account_id(first_participant_id) .predecessor_account_id("forwarder.near".parse().unwrap()) - .attached_deposit(NearToken::from_yoctonear(0)) + .attached_deposit(NearToken::from_near(0)) .build(); testing_env!(ctx); diff --git a/crates/contract/src/update.rs b/crates/contract/src/update.rs index 5826936d2d..980a794e32 100644 --- a/crates/contract/src/update.rs +++ b/crates/contract/src/update.rs @@ -206,7 +206,7 @@ impl ProposedUpdates { promise = promise.deploy_contract(code).function_call( method_names::MIGRATE, Vec::new(), - NearToken::from_yoctonear(0), + NearToken::from_near(0), gas, ); } @@ -218,7 +218,7 @@ impl ProposedUpdates { promise = promise.function_call( method_names::UPDATE_CONFIG, serde_json::to_vec(&(&config,)).unwrap(), - NearToken::from_yoctonear(0), + NearToken::from_near(0), new_config_gas_value, ); } diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index 600d7fc6eb..b84b5752b6 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -13,7 +13,7 @@ use mpc_contract::{ tee::tee_state::{AttestationSubmissionError, NodeId}, }; use near_mpc_contract_interface::{ - deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR, + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR, types::{ Attestation, DomainConfig, DomainId, DomainPurpose, InitConfig, MockAttestation, Protocol, ProtocolContractState, ReconstructionThreshold, @@ -31,7 +31,7 @@ const SECOND: Duration = Duration::from_secs(1); const NANOS_IN_SECOND: u64 = SECOND.as_nanos() as u64; const ATTESTATION_STORAGE_DEPOSIT: NearToken = - NearToken::from_yoctonear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR); + NearToken::from_near(SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR); const DEFAULT_PARTICIPANT_COUNT: usize = 3; const DEFAULT_THRESHOLD_SIZE: u64 = 2; diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index 691e06a1c0..f38123fcb7 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -1007,7 +1007,7 @@ async fn submit_participant_info__should_reject_new_attestation_with_zero_deposi Attestation::Mock(MockAttestation::Valid), fresh_tls_key.clone(), )) - .deposit(NearToken::from_yoctonear(0)) + .deposit(NearToken::from_near(0)) .max_gas() .transact() .await?; diff --git a/crates/contract/tests/sandbox/utils/consts.rs b/crates/contract/tests/sandbox/utils/consts.rs index 590ef3496c..ad577ee425 100644 --- a/crates/contract/tests/sandbox/utils/consts.rs +++ b/crates/contract/tests/sandbox/utils/consts.rs @@ -1,7 +1,7 @@ use std::time::Duration; use near_mpc_contract_interface::{ - deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR, types::Protocol, + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR, types::Protocol, }; use near_sdk::{Gas, NearToken}; @@ -50,6 +50,6 @@ pub const CURRENT_CONTRACT_DEPLOY_DEPOSIT: NearToken = NearToken::from_millinear /// Attached to `submit_participant_info`; the contract charges the measured storage /// cost and refunds the excess. pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT: NearToken = - NearToken::from_yoctonear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR); + NearToken::from_near(SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR); pub const DEFAULT_MAX_TIMEOUT_TX_INCLUDED: Duration = Duration::from_secs(3); diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index f4a74b6161..30b6e4929b 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -7,7 +7,7 @@ use backon::{ConstantBuilder, Retryable}; use ed25519_dalek::SigningKey; use near_kit::AccountId; use near_mpc_contract_interface::{ - deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR, + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR, method_names, types::{ AccountId as ContractAccountId, CKDAppPublicKey, DomainConfig, DomainId, DomainPurpose, @@ -50,7 +50,7 @@ const KEY_EVENT_TIMEOUT_BLOCKS: u64 = 240; const CONTRACT_UPDATE_DEPOSIT: near_kit::NearToken = near_kit::NearToken::from_millinear(17_000); const CONTRACT_UPDATE_GAS: near_kit::Gas = near_kit::Gas::from_tgas(300); const SUBMIT_PARTICIPANT_INFO_DEPOSIT: near_kit::NearToken = - near_kit::NearToken::from_yoctonear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR); + near_kit::NearToken::from_near(SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR); const SUBMIT_PARTICIPANT_INFO_GAS: near_kit::Gas = near_kit::Gas::from_tgas(300); const CONTRACT_DEPLOY_TIMEOUT: Duration = Duration::from_secs(15); const PROPOSER_NODE_INDEX: usize = 0; diff --git a/crates/near-mpc-contract-interface/src/deposits.rs b/crates/near-mpc-contract-interface/src/deposits.rs index 144ce28489..c29eba3701 100644 --- a/crates/near-mpc-contract-interface/src/deposits.rs +++ b/crates/near-mpc-contract-interface/src/deposits.rs @@ -1,6 +1,6 @@ -//! Deposit amounts to attach to contract methods, in yoctoNEAR. One shared value +//! Deposit amounts to attach to contract methods, in NEAR. One shared value //! for node, tests, and e2e. /// Deposit for `submit_participant_info`. The contract charges the actual storage /// cost and refunds the excess. -pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR: u128 = 1_000_000_000_000_000_000_000_000; +pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR: u128 = 1; diff --git a/crates/node/src/indexer/types.rs b/crates/node/src/indexer/types.rs index cd5572ac36..a9ae116ff8 100644 --- a/crates/node/src/indexer/types.rs +++ b/crates/node/src/indexer/types.rs @@ -9,7 +9,7 @@ use k256::{ use near_indexer_primitives::types::{Balance, Gas}; use near_mpc_contract_interface::{ call_args as contract_args, - deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR, + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR, method_names::{ CONCLUDE_NODE_MIGRATION, RESPOND, RESPOND_CKD, RESPOND_VERIFY_FOREIGN_TX, START_KEYGEN_INSTANCE, START_RESHARE_INSTANCE, SUBMIT_PARTICIPANT_INFO, VERIFY_TEE, @@ -128,9 +128,9 @@ impl ChainSendTransactionRequest { pub fn deposit_required(&self) -> Balance { match self { Self::SubmitParticipantInfo { .. } => { - Balance::from_yoctonear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR) + Balance::from_near(SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR) } - _ => Balance::from_yoctonear(0), + _ => Balance::from_near(0), } } } diff --git a/crates/tee-context/src/lib.rs b/crates/tee-context/src/lib.rs index 5119f2b65f..23dbe320bd 100644 --- a/crates/tee-context/src/lib.rs +++ b/crates/tee-context/src/lib.rs @@ -13,7 +13,7 @@ use chain_gateway::{ use near_account_id::AccountId; use near_mpc_contract_interface::{ call_args as contract_args, - deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR, + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR, method_names::{ ALLOWED_DOCKER_IMAGE_HASHES, ALLOWED_LAUNCHER_COMPOSE_HASHES, SUBMIT_PARTICIPANT_INFO, VERIFY_TEE, @@ -134,7 +134,7 @@ where method_name: SUBMIT_PARTICIPANT_INFO.to_string(), args: args_json, gas: SUBMIT_ATTESTATION_GAS, - deposit: NearToken::from_yoctonear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_YOCTONEAR), + deposit: NearToken::from_near(SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR), }, ) .await @@ -152,7 +152,7 @@ where method_name: VERIFY_TEE.to_string(), args: b"{}".to_vec(), gas: VERIFY_TEE_GAS, - deposit: NearToken::from_yoctonear(0), + deposit: NearToken::from_near(0), }, ) .await From 6d228ab914ce7b27ddf2015aa6ac32a5fb1a8203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 14 Jul 2026 15:08:09 +0200 Subject: [PATCH 25/28] docs(contract): document store_verified_attestation flush behavior The function flushes the insert to storage before returning so a caller's subsequent env::storage_usage() reflects it, which the mock and dstack storage-delta charges rely on. Surface this as a doc comment since it is caller-relevant, and trim the inline comment to the IterableMap mechanic. --- crates/contract/src/tee/tee_state.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index ee7ac15fca..8b1c211ef0 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -210,6 +210,10 @@ impl TeeState { report_data.into() } + /// Stores `verified_attestation` under `node_id`'s TLS key and flushes the write to storage + /// before returning, so a caller's subsequent [`env::storage_usage`] reflects the insert + /// (used to charge the storage delta). Rejects a submission whose TLS key is already registered + /// to a different account with [`AttestationSubmissionError::TlsKeyOwnedByOtherAccount`]. fn store_verified_attestation( &mut self, node_id: NodeId, @@ -235,8 +239,7 @@ impl TeeState { }, ); - // Materialize the insert before the storage-usage charge reads it; - // `IterableMap` otherwise defers the write to flush-on-Drop + // `IterableMap` defers the write to flush-on-Drop; force it now self.stored_attestations.flush(); Ok(match previous { From 2b4ea859a80a725567f12a486acfcc5abc7971d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 16 Jul 2026 15:46:38 +0200 Subject: [PATCH 26/28] refactor(contract): charge a flat fee for attestation storage Replace the runtime storage-delta measurement in submit_participant_info with a fixed MINIMUM_ATTESTATION_STORAGE_DEPOSIT (0.1 NEAR). The stored attestation entry is bounded (worst-case ~520 bytes), so metering the exact cost at runtime buys nothing and forces the insert-then-charge-then- maybe-revert machinery. The node now attaches exactly the fee, so there is no excess to refund. Removes charge_attestation_storage, revert_dstack_store, the ParticipantInsertion payload, both env::storage_usage() snapshots, and the measurement-coupled flush(). Adds an up-front deposit guard that rejects an under-funded submission before any store or the verifier promise, and a drift-guard unit test asserting the fee covers the worst-case entry at today's storage price. The verdict-failure refund path in resolve_verification is unchanged. --- crates/contract/src/lib.rs | 129 ++++++++++-------- crates/contract/src/tee/tee_state.rs | 72 ++-------- .../tests/inprocess/attestation_submission.rs | 4 +- crates/contract/tests/sandbox/tee.rs | 44 +++--- crates/contract/tests/sandbox/utils/consts.rs | 8 +- .../snapshots/abi__abi_has_not_changed.snap | 4 +- crates/e2e-tests/src/cluster.rs | 4 +- .../src/deposits.rs | 10 +- crates/node/src/indexer/types.rs | 4 +- crates/tee-context/src/lib.rs | 4 +- 10 files changed, 115 insertions(+), 168 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 88679a0fd0..89c1952835 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -106,6 +106,12 @@ 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); +/// Flat fee a node attaches to [`MpcContract::submit_participant_info`] for its +/// stored attestation entry. The entry is bounded, so the fee is fixed and +/// nothing is refunded; its margin over the true cost absorbs storage-price and +/// layout changes. A unit test asserts it covers the worst-case entry. +const MINIMUM_ATTESTATION_STORAGE_DEPOSIT: NearToken = NearToken::from_millinear(100); + /// 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; @@ -766,9 +772,10 @@ impl MpcContract { /// - [`Attestation::Mock`] is verified synchronously. /// - [`Attestation::Dstack`] is verified asynchronously via a cross-contract /// `verify_quote` call, with [`Self::resolve_verification`] chained as its - /// callback to run the post-DCAP checks and settle the deposit. + /// callback to run the post-DCAP checks and store the attestation. /// - /// The attached deposit pays for storage on success, and is refunded on failure. + /// The caller must attach a flat 0.1 NEAR fee for the stored entry; the whole + /// fee is kept on success and refunded if the attestation is not accepted. #[payable] #[handle_result] pub fn submit_participant_info( @@ -805,17 +812,24 @@ impl MpcContract { account_public_key, }; + let attached = env::attached_deposit(); + if attached < MINIMUM_ATTESTATION_STORAGE_DEPOSIT { + return Err(InvalidParameters::InsufficientDeposit { + attached: attached.as_yoctonear(), + required: MINIMUM_ATTESTATION_STORAGE_DEPOSIT.as_yoctonear(), + } + .into()); + } + match proposed_participant_attestation { Attestation::Mock(mock) => { let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); - let initial_storage = env::storage_usage(); self.tee_state.verify_and_store_mock( node_id, mock, tee_upgrade_deadline_duration, )?; - self.charge_attestation_storage(&account_id, initial_storage)?; Ok(PromiseOrValue::Value(())) } Attestation::Dstack(attestation) => Ok(PromiseOrValue::Promise( @@ -855,34 +869,6 @@ impl MpcContract { )) } - fn charge_attestation_storage( - &self, - account_id: &AccountId, - initial_storage: u64, - ) -> Result<(), Error> { - // `saturating_sub`: if a re-submission shrinks the entry, charge nothing - // rather than underflow. Intentional asymmetry: we do not refund freed - // bytes either, since the caller already paid for the larger entry. - let attached = env::attached_deposit(); - // Relies on the attestation store having flushed its insert already; it - // defers writes to flush-on-Drop, so an unflushed insert reads as a zero delta - let storage_used = env::storage_usage().saturating_sub(initial_storage); - let cost = env::storage_byte_cost().saturating_mul(u128::from(storage_used)); - - if attached < cost { - return Err(InvalidParameters::InsufficientDeposit { - attached: attached.as_yoctonear(), - required: cost.as_yoctonear(), - } - .into()); - } - - if let Some(diff) = attached.checked_sub(cost) { - refund_to(account_id, diff); - } - Ok(()) - } - #[handle_result] pub fn get_attestation( &self, @@ -2301,7 +2287,8 @@ impl MpcContract { } /// Verify-quote callback: on a verifier verdict it runs the post-DCAP - /// checks, stores the attestation, and settles the deposit. + /// checks and stores the attestation, refunding the flat fee if the + /// attestation is not accepted. #[private] #[payable] pub fn resolve_verification( @@ -2351,9 +2338,9 @@ impl MpcContract { } /// Runs the post-DCAP checks and stores the attestation for a - /// [`VerificationResult::Verified`] response. On failure it reverts the - /// store explicitly, since the callback receipt commits regardless - /// (unlike the synchronous path). + /// [`VerificationResult::Verified`] response. The deposit was already + /// checked against the flat fee in [`Self::submit_participant_info`], so this + /// only verifies and stores. fn verify_post_dcap_and_store( &mut self, context: &VerificationContext, @@ -2363,34 +2350,17 @@ impl MpcContract { let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); - let initial_storage = env::storage_usage(); - let insertion = match self.tee_state.verify_and_store_dstack( + if let Err(err) = self.tee_state.verify_and_store_dstack( context.node_id.clone(), &context.attestation, report, tee_upgrade_deadline_duration, ) { - Ok(insertion) => insertion, - Err(err) => { - log!("post-DCAP check failed for {account_id}: {err}"); - return Err(err.into()); - } - }; - - // The charge is the measured storage delta, so it is only known after - // the store; an insufficient deposit reverts the store below. - match self.charge_attestation_storage(account_id, initial_storage) { - Ok(()) => Ok(()), - Err(err) => { - // This receipt commits even though we return an error, so the - // store above is NOT rolled back automatically (unlike the - // synchronous path). Undo it explicitly, or the caller would - // get storage for free plus a full refund. - self.tee_state - .revert_dstack_store(&context.node_id.tls_public_key, insertion); - Err(err) - } + log!("post-DCAP check failed for {account_id}: {err}"); + return Err(err.into()); } + + Ok(()) } /// Yield-resume callback for a single queued CKD request. @@ -2829,7 +2799,8 @@ mod tests { use elliptic_curve::Group; use k256::{self, Secp256k1, ecdsa::SigningKey, elliptic_curve}; use mpc_attestation::attestation::{ - MockAttestation as MpcMockAttestation, VerifiedAttestation, + MockAttestation as MpcMockAttestation, ValidatedDstackAttestation, VerifiedAttestation, + default_measurements, }; use near_mpc_bounded_collections::{NonEmptyBTreeMap, NonEmptyBTreeSet}; use near_mpc_contract_interface::types::BackupServiceInfo; @@ -7879,4 +7850,44 @@ mod tests { assert!(configs.contains_key(&tls_key_a), "node A config must exist"); assert!(configs.contains_key(&tls_key_b), "node B config must exist"); } + + // Catches only entry-size growth: fails if a schema change makes the stored entry + // cost more than the fee at today's storage_byte_cost. It cannot see a future + // storage_byte_cost increase on a live contract; the fee's margin covers that. + #[test] + fn minimum_attestation_storage_deposit__should_cover_worst_case_entry() { + // Given: the largest entry a submission can store. NEAR caps an account id + // at 64 bytes; every other field is fixed-size, so this is the worst case. + testing_env!(VMContextBuilder::new().build()); + let node_id = create_node_id( + &"a".repeat(64).parse().unwrap(), + &bogus_ed25519_public_key(), + ); + let worst_case = NodeAttestation { + node_id: node_id.clone(), + verified_attestation: VerifiedAttestation::Dstack(ValidatedDstackAttestation { + mpc_image_hash: [0xff; 32].into(), + launcher_compose_hash: [0xff; 32].into(), + expiry_timestamp_seconds: u64::MAX, + measurements: default_measurements()[0], + }), + }; + + // When: the entry is inserted and flushed, so storage_usage reflects it. + let mut tee_state = TeeState::default(); + let storage_before = env::storage_usage(); + tee_state + .stored_attestations + .insert(node_id.tls_public_key.clone(), worst_case); + tee_state.stored_attestations.flush(); + let bytes_grown = env::storage_usage() - storage_before; + let worst_case_cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); + + // Then: the flat fee covers the worst-case cost with headroom to spare. + assert!( + MINIMUM_ATTESTATION_STORAGE_DEPOSIT >= worst_case_cost, + "flat fee {MINIMUM_ATTESTATION_STORAGE_DEPOSIT} must cover the worst-case entry \ + ({bytes_grown} bytes, {worst_case_cost}) at today's storage price" + ); + } } diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index dba92fcbbe..c3fb05f0f8 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -48,12 +48,9 @@ pub enum AttestationSubmissionError { } #[derive(Debug)] -#[expect(clippy::large_enum_variant)] pub(crate) enum ParticipantInsertion { NewlyInsertedParticipant, - /// Holds the overwritten entry so [`TeeState::revert_dstack_store`] can put - /// it back if the async store is rolled back. - UpdatedExistingParticipant(NodeAttestation), + UpdatedExistingParticipant, } #[derive(Debug)] @@ -210,10 +207,10 @@ impl TeeState { report_data.into() } - /// Stores `verified_attestation` under `node_id`'s TLS key and flushes the write to storage - /// before returning, so a caller's subsequent [`env::storage_usage`] reflects the insert - /// (used to charge the storage delta). Rejects a submission whose TLS key is already registered - /// to a different account with [`AttestationSubmissionError::TlsKeyOwnedByOtherAccount`]. + /// Stores `verified_attestation` under `node_id`'s TLS key, reporting whether the + /// entry was newly inserted or updated an existing one. Rejects a submission whose + /// TLS key is already registered to a different account with + /// [`AttestationSubmissionError::TlsKeyOwnedByOtherAccount`]. fn store_verified_attestation( &mut self, node_id: NodeId, @@ -239,35 +236,12 @@ impl TeeState { }, ); - // `IterableMap` defers the write to flush-on-Drop; force it now - self.stored_attestations.flush(); - Ok(match previous { - Some(previous) => ParticipantInsertion::UpdatedExistingParticipant(previous), + Some(_) => ParticipantInsertion::UpdatedExistingParticipant, None => ParticipantInsertion::NewlyInsertedParticipant, }) } - /// Undoes a [`Self::verify_and_store_dstack`] store: restores the displaced - /// entry, or removes the newly-inserted one if there was none. Used by the - /// async flow when the storage charge fails after the store, so a caller - /// can't get storage for free in a receipt that still commits. - pub(crate) fn revert_dstack_store( - &mut self, - tls_public_key: &Ed25519PublicKey, - insertion: ParticipantInsertion, - ) { - match insertion { - ParticipantInsertion::UpdatedExistingParticipant(previous) => { - self.stored_attestations - .insert(tls_public_key.clone(), previous); - } - ParticipantInsertion::NewlyInsertedParticipant => { - self.stored_attestations.remove(tls_public_key); - } - } - } - /// reverifies stored participant attestations. pub(crate) fn reverify_participants( &self, @@ -844,7 +818,7 @@ mod tests { // then assert_matches!( re_insertion_result, - Ok(ParticipantInsertion::UpdatedExistingParticipant(_)) + Ok(ParticipantInsertion::UpdatedExistingParticipant) ); } @@ -868,33 +842,6 @@ mod tests { ); } - #[test] - fn verify_and_store_mock__should_flush_so_storage_usage_grows() { - // Given - testing_env!(VMContextBuilder::new().build()); - let mut tee_state = TeeState::default(); - let node_id = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; - let storage_before = env::storage_usage(); - - // When - tee_state - .verify_and_store_mock(node_id, MockAttestation::Valid, Duration::from_secs(0)) - .unwrap(); - - // Then: the store must flush the insert so the charged storage delta is - // nonzero. Without the flush the write defers to drop and this reads zero, - // letting a caller store an attestation without paying for it. - let storage_after = env::storage_usage(); - assert!( - storage_after > storage_before, - "env::storage_usage() should grow after the store ({storage_before} -> {storage_after})" - ); - } - #[test] fn verify_and_store_mock__should_index_by_tls_key() { // given @@ -1427,10 +1374,7 @@ mod tests { ); // Then: the update is accepted and the stored entry reflects the new key. - assert_matches!( - result, - Ok(ParticipantInsertion::UpdatedExistingParticipant(_)) - ); + assert_matches!(result, Ok(ParticipantInsertion::UpdatedExistingParticipant)); let stored = tee_state .stored_attestations .get(&rotated_node.tls_public_key) diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index 31a0556c33..1e510b0857 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -13,7 +13,7 @@ use mpc_contract::{ tee::tee_state::{AttestationSubmissionError, NodeId}, }; use near_mpc_contract_interface::{ - deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR, + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR, types::{ Attestation, DomainConfig, DomainId, DomainPurpose, InitConfig, MockAttestation, Protocol, ProtocolContractState, ReconstructionThreshold, @@ -31,7 +31,7 @@ const SECOND: Duration = Duration::from_secs(1); const NANOS_IN_SECOND: u64 = SECOND.as_nanos() as u64; const ATTESTATION_STORAGE_DEPOSIT: NearToken = - NearToken::from_near(SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR); + NearToken::from_millinear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR); const DEFAULT_PARTICIPANT_COUNT: usize = 3; const DEFAULT_THRESHOLD_SIZE: u64 = 2; diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index f38123fcb7..d28ab3541d 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -3,7 +3,7 @@ use crate::sandbox::{ common::{SandboxTestSetup, build_sandbox_node_ids, gen_accounts, submit_tee_attestations}, utils::{ - consts::ALL_PROTOCOLS, + consts::{ALL_PROTOCOLS, SUBMIT_PARTICIPANT_INFO_DEPOSIT}, interface::IntoContractType, mpc_contract::{ assert_running_return_participants, assert_running_return_threshold, @@ -985,10 +985,10 @@ async fn verify_tee__should_keep_participants_and_stop_signing_when_kickout_drop Ok(()) } -/// Regression test for the lazy-collection storage-charge bug: a brand-new -/// attestation submitted with zero deposit must be rejected and not persisted. +/// A submission attaching less than the flat storage fee is rejected before the +/// entry is stored. #[tokio::test] -async fn submit_participant_info__should_reject_new_attestation_with_zero_deposit() -> Result<()> { +async fn submit_participant_info__should_reject_new_attestation_below_flat_fee() -> Result<()> { // Given let SandboxTestSetup { worker, contract, .. @@ -999,6 +999,7 @@ async fn submit_participant_info__should_reject_new_attestation_with_zero_deposi let outsider = worker.dev_create_account().await?; let fresh_tls_key = bogus_ed25519_public_key(); let storage_before = worker.view_account(contract.id()).await?.storage_usage; + let below_fee = SUBMIT_PARTICIPANT_INFO_DEPOSIT.saturating_sub(NearToken::from_yoctonear(1)); // When let result = outsider @@ -1007,7 +1008,7 @@ async fn submit_participant_info__should_reject_new_attestation_with_zero_deposi Attestation::Mock(MockAttestation::Valid), fresh_tls_key.clone(), )) - .deposit(NearToken::from_near(0)) + .deposit(below_fee) .max_gas() .transact() .await?; @@ -1015,7 +1016,7 @@ async fn submit_participant_info__should_reject_new_attestation_with_zero_deposi // Then assert!( !result.is_success(), - "zero-deposit submission of a new attestation must fail: {result:?}" + "submission below the flat fee must fail: {result:?}" ); let error_msg = format!("{:?}", result.into_result()); assert!( @@ -1035,15 +1036,13 @@ async fn submit_participant_info__should_reject_new_attestation_with_zero_deposi Ok(()) } -/// Mirror of the zero-deposit test with a funded submission: the attestation is -/// stored, storage grows, and the caller is charged a non-zero amount for it (the -/// bug charged zero). +/// A submission attaching exactly the flat fee is stored, and the caller is +/// charged the whole fee with no excess refunded (the fee far exceeds the true +/// storage cost by design). #[tokio::test] -async fn submit_participant_info__should_store_new_attestation_and_charge_with_sufficient_deposit() +async fn submit_participant_info__should_store_new_attestation_and_charge_the_flat_fee() -> Result<()> { - // Given: the sandbox storage price, 1e19 yocto per byte. - const STORAGE_COST_PER_BYTE: u128 = 10u128.pow(19); - const ATTACHED_DEPOSIT: NearToken = NearToken::from_near(1); + // Given let SandboxTestSetup { worker, contract, .. } = SandboxTestSetup::builder() @@ -1052,7 +1051,6 @@ async fn submit_participant_info__should_store_new_attestation_and_charge_with_s .await; let outsider = worker.dev_create_account().await?; let fresh_tls_key = bogus_ed25519_public_key(); - let storage_before = worker.view_account(contract.id()).await?.storage_usage; let balance_before = outsider.view_account().await?.balance; // When @@ -1062,7 +1060,7 @@ async fn submit_participant_info__should_store_new_attestation_and_charge_with_s Attestation::Mock(MockAttestation::Valid), fresh_tls_key.clone(), )) - .deposit(ATTACHED_DEPOSIT) + .deposit(SUBMIT_PARTICIPANT_INFO_DEPOSIT) .max_gas() .transact() .await?; @@ -1070,26 +1068,20 @@ async fn submit_participant_info__should_store_new_attestation_and_charge_with_s // Then assert!( result.is_success(), - "funded submission of a new attestation should succeed: {result:?}" + "submission attaching the flat fee should succeed: {result:?}" ); let stored = get_participant_attestation(&contract, &fresh_tls_key).await?; assert!( stored.is_some(), "the attestation entry should be stored on-chain" ); - let storage_after = worker.view_account(contract.id()).await?.storage_usage; - let bytes_grown = storage_after - storage_before; - assert!( - bytes_grown > 0, - "contract storage should grow ({storage_before} -> {storage_after})" - ); - - let storage_stake = NearToken::from_yoctonear(u128::from(bytes_grown) * STORAGE_COST_PER_BYTE); + // The whole flat fee is consumed (no excess refund); `spent` also covers gas, + // so it must be at least the fee. let balance_after = outsider.view_account().await?.balance; let spent = balance_before.saturating_sub(balance_after); assert!( - spent >= storage_stake, - "caller must be charged at least the storage stake ({storage_stake}) for {bytes_grown} new bytes, spent {spent}" + spent >= SUBMIT_PARTICIPANT_INFO_DEPOSIT, + "caller must be charged the full flat fee ({SUBMIT_PARTICIPANT_INFO_DEPOSIT}), spent {spent}" ); Ok(()) } diff --git a/crates/contract/tests/sandbox/utils/consts.rs b/crates/contract/tests/sandbox/utils/consts.rs index ad577ee425..bd9d862484 100644 --- a/crates/contract/tests/sandbox/utils/consts.rs +++ b/crates/contract/tests/sandbox/utils/consts.rs @@ -1,7 +1,7 @@ use std::time::Duration; use near_mpc_contract_interface::{ - deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR, types::Protocol, + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR, types::Protocol, }; use near_sdk::{Gas, NearToken}; @@ -47,9 +47,9 @@ pub const MAX_GAS_FOR_THRESHOLD_VOTE: Gas = Gas::from_tgas(190); /// TODO(#2756): Reduce this to the minimal value possible pub const CURRENT_CONTRACT_DEPLOY_DEPOSIT: NearToken = NearToken::from_millinear(17000); -/// Attached to `submit_participant_info`; the contract charges the measured storage -/// cost and refunds the excess. +/// Attached to `submit_participant_info`; the contract requires exactly this flat +/// fee to store the bounded attestation entry, with no refund. pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT: NearToken = - NearToken::from_near(SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR); + NearToken::from_millinear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR); pub const DEFAULT_MAX_TIMEOUT_TX_INCLUDED: Duration = Duration::from_secs(3); diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 35799f42f8..a8308ede24 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -1314,7 +1314,7 @@ expression: abi }, { "name": "resolve_verification", - "doc": " Verify-quote callback: on a verifier verdict it runs the post-DCAP\n checks, stores the attestation, and settles the deposit.", + "doc": " Verify-quote callback: on a verifier verdict it runs the post-DCAP\n checks and stores the attestation, refunding the flat fee if the\n attestation is not accepted.", "kind": "call", "modifiers": [ "payable", @@ -2336,7 +2336,7 @@ expression: abi }, { "name": "submit_participant_info", - "doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously via a cross-contract\n `verify_quote` call, with [`Self::resolve_verification`] chained as its\n callback to run the post-DCAP checks and settle the deposit.\n\n The attached deposit pays for storage on success, and is refunded on failure.", + "doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously via a cross-contract\n `verify_quote` call, with [`Self::resolve_verification`] chained as its\n callback to run the post-DCAP checks and store the attestation.\n\n The caller must attach a flat 0.1 NEAR fee for the stored entry; the whole\n fee is kept on success and refunded if the attestation is not accepted.", "kind": "call", "modifiers": [ "payable" diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index 30b6e4929b..564a47b49c 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -7,7 +7,7 @@ use backon::{ConstantBuilder, Retryable}; use ed25519_dalek::SigningKey; use near_kit::AccountId; use near_mpc_contract_interface::{ - deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR, + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR, method_names, types::{ AccountId as ContractAccountId, CKDAppPublicKey, DomainConfig, DomainId, DomainPurpose, @@ -50,7 +50,7 @@ const KEY_EVENT_TIMEOUT_BLOCKS: u64 = 240; const CONTRACT_UPDATE_DEPOSIT: near_kit::NearToken = near_kit::NearToken::from_millinear(17_000); const CONTRACT_UPDATE_GAS: near_kit::Gas = near_kit::Gas::from_tgas(300); const SUBMIT_PARTICIPANT_INFO_DEPOSIT: near_kit::NearToken = - near_kit::NearToken::from_near(SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR); + near_kit::NearToken::from_millinear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR); const SUBMIT_PARTICIPANT_INFO_GAS: near_kit::Gas = near_kit::Gas::from_tgas(300); const CONTRACT_DEPLOY_TIMEOUT: Duration = Duration::from_secs(15); const PROPOSER_NODE_INDEX: usize = 0; diff --git a/crates/near-mpc-contract-interface/src/deposits.rs b/crates/near-mpc-contract-interface/src/deposits.rs index c29eba3701..51a1e91587 100644 --- a/crates/near-mpc-contract-interface/src/deposits.rs +++ b/crates/near-mpc-contract-interface/src/deposits.rs @@ -1,6 +1,6 @@ -//! Deposit amounts to attach to contract methods, in NEAR. One shared value -//! for node, tests, and e2e. +//! Deposit amounts to attach to contract methods, in milli-NEAR. One shared +//! value for node, tests, and e2e. -/// Deposit for `submit_participant_info`. The contract charges the actual storage -/// cost and refunds the excess. -pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR: u128 = 1; +/// Deposit for `submit_participant_info`. The contract requires exactly this +/// flat fee to store the bounded attestation entry; nothing is refunded. +pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR: u128 = 100; diff --git a/crates/node/src/indexer/types.rs b/crates/node/src/indexer/types.rs index a9ae116ff8..de0c2c1ef3 100644 --- a/crates/node/src/indexer/types.rs +++ b/crates/node/src/indexer/types.rs @@ -9,7 +9,7 @@ use k256::{ use near_indexer_primitives::types::{Balance, Gas}; use near_mpc_contract_interface::{ call_args as contract_args, - deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR, + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR, method_names::{ CONCLUDE_NODE_MIGRATION, RESPOND, RESPOND_CKD, RESPOND_VERIFY_FOREIGN_TX, START_KEYGEN_INSTANCE, START_RESHARE_INSTANCE, SUBMIT_PARTICIPANT_INFO, VERIFY_TEE, @@ -128,7 +128,7 @@ impl ChainSendTransactionRequest { pub fn deposit_required(&self) -> Balance { match self { Self::SubmitParticipantInfo { .. } => { - Balance::from_near(SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR) + Balance::from_millinear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR) } _ => Balance::from_near(0), } diff --git a/crates/tee-context/src/lib.rs b/crates/tee-context/src/lib.rs index 23dbe320bd..f941478c18 100644 --- a/crates/tee-context/src/lib.rs +++ b/crates/tee-context/src/lib.rs @@ -13,7 +13,7 @@ use chain_gateway::{ use near_account_id::AccountId; use near_mpc_contract_interface::{ call_args as contract_args, - deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR, + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR, method_names::{ ALLOWED_DOCKER_IMAGE_HASHES, ALLOWED_LAUNCHER_COMPOSE_HASHES, SUBMIT_PARTICIPANT_INFO, VERIFY_TEE, @@ -134,7 +134,7 @@ where method_name: SUBMIT_PARTICIPANT_INFO.to_string(), args: args_json, gas: SUBMIT_ATTESTATION_GAS, - deposit: NearToken::from_near(SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR), + deposit: NearToken::from_millinear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR), }, ) .await From 8ab4f4ccfabc0c9310fb133a44025a2862f68675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 16 Jul 2026 17:39:39 +0200 Subject: [PATCH 27/28] test(contract): attach the flat fee when submitting attestations in tests The merged shared `common::participant_context` attaches no deposit, but `submit_participant_info` now requires the flat storage fee, so every attestation-submitting test failed with InsufficientDeposit. Build the submission context with ATTESTATION_STORAGE_DEPOSIT attached; vote calls keep using the deposit-free context. --- .../tests/inprocess/attestation_submission.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index f2dcce8fb2..0d3d0fac17 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -149,7 +149,16 @@ impl TestSetup { node_id: &NodeId, attestation: Attestation, ) -> Result<(), mpc_contract::errors::Error> { - testing_env!(common::participant_context(&node_id.account_id)); + // `submit_participant_info` requires the flat storage fee, unlike the + // deposit-free calls `common::participant_context` is built for. + testing_env!( + VMContextBuilder::new() + .signer_account_id(node_id.account_id.clone()) + .predecessor_account_id(node_id.account_id.clone()) + .block_timestamp(near_sdk::env::block_timestamp()) + .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) + .build() + ); self.contract .submit_participant_info(attestation, node_id.tls_public_key.clone()) .map(|_| ()) From 4c3b8a170e2f3b8dd46a09a7d96724cb4278b7f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 20 Jul 2026 11:58:56 +0200 Subject: [PATCH 28/28] test(contract): pin the max borsh size of a stored NodeAttestation Guarantee the storage budget the flat attestation fee is sized against by asserting the exact worst-case borsh size of a stored NodeAttestation per verified-attestation variant (Dstack 445 bytes, Mock 450 bytes), with the account id at NEAR's 64-byte cap and every other field at its maximum. Every field is fixed-size or hard-capped, so the sizes are deterministic; an exact match fails on any growth or shrink and forces a re-check of the fee coverage test. --- crates/contract/src/lib.rs | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index b263bd9207..64c6607831 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -7878,6 +7878,46 @@ mod tests { assert!(configs.contains_key(&tls_key_b), "node B config must exist"); } + #[test] + fn node_attestation__should_match_expected_max_borsh_size() { + // Given: the largest entry each variant can produce (64-byte account id, + // all fields present at their maximum). + let max_dstack_borsh_size = 445; + let max_mock_borsh_size = 450; + let node_id = create_node_id( + &"a".repeat(64).parse().unwrap(), + &bogus_ed25519_public_key(), + ); + let dstack = NodeAttestation { + node_id: node_id.clone(), + verified_attestation: VerifiedAttestation::Dstack(ValidatedDstackAttestation { + mpc_image_hash: [0xff; 32].into(), + launcher_compose_hash: [0xff; 32].into(), + expiry_timestamp_seconds: u64::MAX, + measurements: default_measurements()[0], + }), + }; + let mock = NodeAttestation { + node_id, + verified_attestation: VerifiedAttestation::Mock( + mpc_attestation::attestation::MockAttestation::WithConstraints { + mpc_docker_image_hash: Some([0xff; 32].into()), + launcher_docker_compose_hash: Some([0xff; 32].into()), + expiry_timestamp_seconds: Some(u64::MAX), + expected_measurements: Some(default_measurements()[0]), + }, + ), + }; + + // When + let dstack_size = borsh::to_vec(&dstack).unwrap().len(); + let mock_size = borsh::to_vec(&mock).unwrap().len(); + + // Then + assert_eq!(dstack_size, max_dstack_borsh_size); + assert_eq!(mock_size, max_mock_borsh_size); + } + // Catches only entry-size growth: fails if a schema change makes the stored entry // cost more than the fee at today's storage_byte_cost. It cannot see a future // storage_byte_cost increase on a live contract; the fee's margin covers that.