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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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 839fb4c973b125d490279b3f886da23072f31e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 30 Jun 2026 18:59:49 +0200 Subject: [PATCH 26/44] test(contract): sandbox coverage + stub verifier for async attestation Adds the test-tee-verifier stub contract (a wire-compatible verify_quote that returns a test-chosen response instead of running dcap-qvl) and sandbox tests exercising the async submit_participant_info branches: verifier-not-configured, Rejected, and no-verdict (yield timeout). Also adds the has_pending_attestation sandbox view and the design doc for the verifier-contract flow. Builds on the async-verification feature PR. --- Cargo.lock | 10 + Cargo.toml | 1 + crates/contract/src/sandbox_test_methods.rs | 6 +- crates/contract/tests/sandbox/mod.rs | 1 + crates/contract/tests/sandbox/tee_verifier.rs | 228 ++++++++++++++++++ .../tests/sandbox/utils/contract_build.rs | 7 + .../tests/sandbox/utils/mpc_contract.rs | 68 +++++- crates/test-tee-verifier/Cargo.toml | 31 +++ crates/test-tee-verifier/src/lib.rs | 82 +++++++ docs/design/attestation-verifier-contract.md | 111 +++++---- docs/localnet/tee-localnet.md | 2 +- ...nning-an-mpc-node-in-tdx-external-guide.md | 2 +- 12 files changed, 488 insertions(+), 61 deletions(-) create mode 100644 crates/contract/tests/sandbox/tee_verifier.rs create mode 100644 crates/test-tee-verifier/Cargo.toml create mode 100644 crates/test-tee-verifier/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index ec50796950..17994c904f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11363,6 +11363,16 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "test-tee-verifier" +version = "3.13.0" +dependencies = [ + "borsh", + "getrandom 0.2.17", + "near-sdk", + "tee-verifier-interface", +] + [[package]] name = "test-utils" version = "3.13.0" diff --git a/Cargo.toml b/Cargo.toml index ede93a9e3c..8b5791ebaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ members = [ "crates/test-migration-contract", "crates/test-parallel-contract", "crates/test-port-allocator", + "crates/test-tee-verifier", "crates/test-utils", "crates/threshold-signatures", "crates/tls", diff --git a/crates/contract/src/sandbox_test_methods.rs b/crates/contract/src/sandbox_test_methods.rs index 28997cd7af..63120a02ad 100644 --- a/crates/contract/src/sandbox_test_methods.rs +++ b/crates/contract/src/sandbox_test_methods.rs @@ -11,7 +11,7 @@ use crate::MpcContract; use crate::primitives::ckd::CKDRequest; use crate::primitives::signature::SignatureRequest; -use near_sdk::near; +use near_sdk::{AccountId, near}; // Import the generated extension trait from near use crate::MpcContractExt; @@ -48,4 +48,8 @@ impl MpcContract { u32::try_from(len) .expect("queue length must fit in u32 — bounded by MAX_PENDING_REQUEST_FAN_OUT") } + + pub fn has_pending_attestation(&self, account_id: AccountId) -> bool { + self.pending_attestations.contains_key(&account_id) + } } diff --git a/crates/contract/tests/sandbox/mod.rs b/crates/contract/tests/sandbox/mod.rs index c4e4cbaf3d..e0f25b397a 100644 --- a/crates/contract/tests/sandbox/mod.rs +++ b/crates/contract/tests/sandbox/mod.rs @@ -6,6 +6,7 @@ pub mod participants_gas; pub mod sign; pub mod tee; pub mod tee_cleanup_after_resharing; +pub mod tee_verifier; pub mod update_votes_cleanup_after_resharing; pub mod upgrade_from_current_contract; pub mod upgrade_to_current_contract; diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs new file mode 100644 index 0000000000..511fd0a23d --- /dev/null +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -0,0 +1,228 @@ +//! Sandbox tests for the async `submit_participant_info` flow that offloads DCAP +//! verification to a separate `tee-verifier` contract. +//! +//! These deploy the `test-tee-verifier` stub (which returns a test-chosen +//! `verify_quote` answer instead of running real `dcap-qvl`) and point +//! `mpc-contract` at it via `vote_tee_verifier_change`, then exercise each +//! resolution branch of the yield-resume flow: +//! +//! - verifier not configured → submission rejected, nothing stored. +//! - `Rejected` → submission fails, deposit refunded, no stored attestation. +//! - no-verdict (stub panics) → the ~200-block yield timeout cleans up. +#![allow(non_snake_case)] + +use crate::sandbox::{ + common::SandboxTestSetup, + utils::{ + consts::ALL_PROTOCOLS, + contract_build::stub_tee_verifier_contract, + mpc_contract::{ + get_participant_attestation, has_pending_attestation, submit_participant_info, + submit_participant_info_with_deposit, vote_tee_verifier_change, + }, + }, +}; +use anyhow::Result; +use borsh::BorshSerialize; +use near_mpc_contract_interface::types::{self as dtos, Attestation}; +use near_workspaces::{Account, Contract, Worker, network::Sandbox, types::NearToken}; +use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key}; + +/// Blocks to fast-forward past the ~200-block yield-resume timeout so the +/// runtime fires `on_attestation_verified`'s timeout branch. +const YIELD_TIMEOUT_BLOCKS: u64 = 250; + +/// Mirror of `test_tee_verifier::StubResponse`. Re-declared here (rather than +/// depending on the stub crate) so the test only needs its Borsh encoding to +/// initialize the deployed stub; the stub is a separate `#[near]` contract and +/// linking its crate into this test binary would collide on ABI symbols. +#[expect(clippy::large_enum_variant)] +#[derive(BorshSerialize)] +enum StubResponse { + #[expect(dead_code)] + Verified(tee_verifier_interface::VerifiedReport), + Rejected(String), + Panic, +} + +/// Deploys the stub verifier with the given response, initializes it, and votes +/// it in as `mpc-contract`'s trusted verifier (all participants vote so the +/// change crosses threshold). +async fn deploy_and_trust_stub( + worker: &Worker, + contract: &Contract, + participants: &[Account], + response: StubResponse, +) -> Result { + let stub = worker.dev_deploy(stub_tee_verifier_contract()).await?; + stub.call("new") + .args_borsh(response) + .transact() + .await? + .into_result()?; + + // The contract only consumes `candidate_account_id`; the hash is a voter + // commitment, so any agreed value works for the test. + let expected_code_hash = [7u8; 32]; + for account in participants { + vote_tee_verifier_change(account, contract, stub.id(), expected_code_hash).await?; + } + Ok(stub) +} + +fn dstack_attestation() -> Attestation { + mock_dto_dstack_attestation() +} + +fn tls_key() -> dtos::Ed25519PublicKey { + p2p_tls_key().into() +} + +#[tokio::test] +async fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() -> Result<()> +{ + // Given: a running contract with no verifier voted in. + let SandboxTestSetup { + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .build() + .await; + + // When: a participant submits a Dstack attestation. + let result = submit_participant_info( + &mpc_signer_accounts[0], + &contract, + &dstack_attestation(), + &tls_key(), + ) + .await?; + + // Then: it is rejected (no verifier configured) and nothing is stored. + assert!( + result.is_failure(), + "Dstack submit must fail when no verifier is configured: {result:#?}" + ); + let stored = get_participant_attestation(&contract, &tls_key()).await?; + assert!(stored.is_none(), "no attestation should be stored"); + Ok(()) +} + +#[tokio::test] +async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_rejection() +-> Result<()> { + // Given: a contract whose trusted verifier always rejects. + let SandboxTestSetup { + worker, + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .with_sandbox_test_methods() + .build() + .await; + deploy_and_trust_stub( + &worker, + &contract, + &mpc_signer_accounts, + StubResponse::Rejected("test rejection".to_string()), + ) + .await?; + + // When: a participant submits a Dstack attestation with a 1 NEAR deposit. + let submitter = &mpc_signer_accounts[0]; + let balance_before = submitter.view_account().await?.balance; + let _ = submit_participant_info_with_deposit( + submitter, + &contract, + &dstack_attestation(), + &tls_key(), + NearToken::from_near(1), + ) + .await?; + + // Then: nothing is stored, the pending entry is cleaned up, and the deposit + // is refunded. The rejection resolves in the verifier's response receipt (a + // later receipt than the original call), so the outcome is observable in + // state rather than on the original transaction's result. + let stored = get_participant_attestation(&contract, &tls_key()).await?; + assert!(stored.is_none(), "a rejected quote must not be stored"); + assert!( + !has_pending_attestation(&contract, submitter.id()).await?, + "the pending entry must be cleaned up on rejection" + ); + assert_deposit_refunded(submitter, balance_before).await?; + Ok(()) +} + +#[tokio::test] +async fn submit_participant_info__should_clean_up_on_verifier_crash() -> Result<()> { + // Given: a contract whose trusted verifier panics (no verdict). + let SandboxTestSetup { + worker, + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .with_sandbox_test_methods() + .build() + .await; + deploy_and_trust_stub( + &worker, + &contract, + &mpc_signer_accounts, + StubResponse::Panic, + ) + .await?; + + // When: a participant submits, the verifier crashes (no resume lands), and + // the chain advances past the ~200-block yield timeout so the runtime fires + // `on_attestation_verified`'s timeout branch. + let submitter = &mpc_signer_accounts[0]; + let balance_before = submitter.view_account().await?.balance; + // Unlike the rejection test, the outer-tx result isn't asserted here: the + // failure only resolves when the yield times out, which `near-workspaces` + // does not surface on the original `transact()`, so we assert state instead. + let _ = submit_participant_info_with_deposit( + submitter, + &contract, + &dstack_attestation(), + &tls_key(), + NearToken::from_near(1), + ) + .await?; + worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await?; + + // Then: nothing is stored, and the timeout cleanup actually committed: the + // pending entry is gone and the deposit refunded. (Guards the regression + // where the cleanup was rolled back by a panic in the same receipt, leaking + // the entry and locking the account out of resubmitting.) + let stored = get_participant_attestation(&contract, &tls_key()).await?; + assert!( + stored.is_none(), + "nothing should be stored when the verifier crashes" + ); + assert!( + !has_pending_attestation(&contract, submitter.id()).await?, + "the pending entry must be cleaned up after the yield timeout" + ); + assert_deposit_refunded(submitter, balance_before).await?; + Ok(()) +} + +/// Asserts the 1 NEAR storage deposit was returned: the net spend since +/// `balance_before` is well under 1 NEAR (only gas), rather than the full +/// deposit being retained by the contract. +async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) -> Result<()> { + let balance_after = account.view_account().await?.balance; + let net_spent = balance_before.saturating_sub(balance_after); + assert!( + net_spent < NearToken::from_near(1), + "deposit should be refunded (net spent {net_spent} should be < 1 NEAR, gas only)" + ); + Ok(()) +} diff --git a/crates/contract/tests/sandbox/utils/contract_build.rs b/crates/contract/tests/sandbox/utils/contract_build.rs index cdaedf6e4d..1f9361694e 100644 --- a/crates/contract/tests/sandbox/utils/contract_build.rs +++ b/crates/contract/tests/sandbox/utils/contract_build.rs @@ -4,6 +4,7 @@ use test_utils::contract_build::ContractBuilder; const MPC_CONTRACT_MANIFEST: &str = "crates/contract/Cargo.toml"; const MIGRATION_CONTRACT_MANIFEST: &str = "crates/test-migration-contract/Cargo.toml"; const PARALLEL_CONTRACT_MANIFEST: &str = "crates/test-parallel-contract/Cargo.toml"; +const STUB_TEE_VERIFIER_MANIFEST: &str = "crates/test-tee-verifier/Cargo.toml"; const MPC_CONTRACT_OUT_DIR: &str = "target/near/contract-noabi"; const MPC_CONTRACT_BENCH_OUT_DIR: &str = "target/near/contract-noabi-bench"; const MPC_CONTRACT_SANDBOX_OUT_DIR: &str = "target/near/contract-noabi-sandbox"; @@ -13,6 +14,7 @@ static CONTRACT_WITH_BENCH_METHODS: OnceLock> = OnceLock::new(); static CONTRACT_WITH_SANDBOX_TEST_METHODS: OnceLock> = OnceLock::new(); static MIGRATION_CONTRACT: OnceLock> = OnceLock::new(); static PARALLEL_CONTRACT: OnceLock> = OnceLock::new(); +static STUB_TEE_VERIFIER_CONTRACT: OnceLock> = OnceLock::new(); /// Returns the current contract WASM without benchmark utilities. /// Use this for most sandbox tests. @@ -54,3 +56,8 @@ pub fn migration_contract() -> &'static [u8] { pub fn parallel_contract() -> &'static [u8] { PARALLEL_CONTRACT.get_or_init(|| ContractBuilder::new(PARALLEL_CONTRACT_MANIFEST).build()) } + +pub fn stub_tee_verifier_contract() -> &'static [u8] { + STUB_TEE_VERIFIER_CONTRACT + .get_or_init(|| ContractBuilder::new(STUB_TEE_VERIFIER_MANIFEST).build()) +} diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index 8e5aa06c80..ef6f5e484e 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -3,12 +3,14 @@ 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}; -use near_mpc_contract_interface::method_names; -use near_mpc_contract_interface::types::{ - Attestation, Ed25519PublicKey, Participants, ProtocolContractState, Threshold, +use mpc_primitives::hash::{LauncherImageHash, NodeImageHash, TeeVerifierCodeHash}; +use near_mpc_contract_interface::{ + method_names, + types::{Attestation, Ed25519PublicKey, Participants, ProtocolContractState, Threshold}, +}; +use near_workspaces::{ + Account, AccountId, Contract, result::ExecutionFinalResult, types::NearToken, }; -use near_workspaces::{Account, Contract, result::ExecutionFinalResult}; pub async fn get_state(contract: &Contract) -> ProtocolContractState { contract @@ -41,22 +43,66 @@ pub async fn get_tee_accounts(contract: &Contract) -> anyhow::Result anyhow::Result { - let result = account + submit_participant_info_with_deposit( + account, + contract, + attestation, + tls_key, + SUBMIT_PARTICIPANT_INFO_DEPOSIT, + ) + .await +} + +pub async fn submit_participant_info_with_deposit( + account: &Account, + contract: &Contract, + attestation: &Attestation, + tls_key: &Ed25519PublicKey, + deposit: NearToken, +) -> anyhow::Result { + Ok(account .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) .args_json((attestation, tls_key)) - .deposit(SUBMIT_PARTICIPANT_INFO_DEPOSIT) + .deposit(deposit) .max_gas() .transact() - .await?; - dbg!(&result); - Ok(result) + .await?) +} + +pub async fn has_pending_attestation( + contract: &Contract, + account_id: &AccountId, +) -> anyhow::Result { + Ok(contract + .view("has_pending_attestation") + .args_json(serde_json::json!({ "account_id": account_id })) + .await? + .json()?) +} + +pub async fn vote_tee_verifier_change( + account: &Account, + contract: &Contract, + candidate_account_id: &AccountId, + expected_code_hash: [u8; 32], +) -> anyhow::Result<()> { + let expected_code_hash = TeeVerifierCodeHash::new(expected_code_hash); + all_receipts_successful( + account + .call(contract.id(), method_names::VOTE_TEE_VERIFIER_CHANGE) + .args_json(serde_json::json!({ + "candidate_account_id": candidate_account_id, + "expected_code_hash": expected_code_hash, + })) + .transact() + .await?, + ) } pub async fn get_participant_attestation( diff --git a/crates/test-tee-verifier/Cargo.toml b/crates/test-tee-verifier/Cargo.toml new file mode 100644 index 0000000000..f9612d5569 --- /dev/null +++ b/crates/test-tee-verifier/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "test-tee-verifier" +version = { workspace = true } +license = { workspace = true } +edition = { workspace = true } + +# A test-only stub of the `tee-verifier` contract: `verify_quote` returns a +# response the test chose at init, instead of running real `dcap-qvl`. Lets the +# `mpc-contract` sandbox tests drive every branch of the async attestation flow +# (Verified / Rejected / post-DCAP failure / no-verdict) deterministically. +# Speaks the same `tee-verifier-interface` Borsh DTOs as the real verifier, so +# `mpc-contract` cannot tell them apart. + +[lib] +crate-type = ["cdylib", "lib"] + +[features] +# Enabled by `cargo near build` / `--all-features` for ABI generation, mirroring +# the real `tee-verifier`: pulls in the borsh schema for the wire DTOs. +abi = ["borsh/unstable__schema", "tee-verifier-interface/borsh-schema"] + +[dependencies] +borsh = { workspace = true } +near-sdk = { workspace = true } +tee-verifier-interface = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { workspace = true, features = ["custom"] } + +[lints] +workspace = true diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs new file mode 100644 index 0000000000..bd5168f1ef --- /dev/null +++ b/crates/test-tee-verifier/src/lib.rs @@ -0,0 +1,82 @@ +//! Test-only stub of the `tee-verifier` contract. +//! +//! `verify_quote` ignores its inputs and returns a response fixed at init time, +//! instead of running real `dcap_qvl::verify`. This lets `mpc-contract` sandbox +//! tests drive every branch of the async attestation flow deterministically: +//! a `Verified` report (which the test supplies so it matches the fixture's +//! post-DCAP expectations), a `Rejected` verdict, or a panic (the no-verdict / +//! verifier-unreachable path). +//! +//! It speaks the same `tee-verifier-interface` Borsh DTOs and uses the same +//! `#[result_serializer(borsh)]` as the real verifier, so `mpc-contract` cannot +//! tell the two apart. + +use borsh::{BorshDeserialize, BorshSerialize}; +use near_sdk::{env, near}; +use tee_verifier_interface::{Collateral, QuoteBytes, VerificationResult, VerifierError}; + +// Match the real verifier's getrandom handling on wasm so the crate links. +#[cfg(target_arch = "wasm32")] +fn randomness_unsupported(_buf: &mut [u8]) -> Result<(), getrandom::Error> { + Err(getrandom::Error::UNSUPPORTED) +} +#[cfg(target_arch = "wasm32")] +getrandom::register_custom_getrandom!(randomness_unsupported); + +/// What the stub's `verify_quote` should do, chosen by the test at deploy time. +#[expect(clippy::large_enum_variant)] +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +#[cfg_attr( + all(feature = "abi", not(target_arch = "wasm32")), + derive(borsh::BorshSchema) +)] +pub enum StubResponse { + /// Return `VerificationResult::Verified` with this exact report. Tests that + /// want the post-DCAP checks to pass supply the report obtained from the + /// real fixture quote (e.g. via `DstackAttestation::dcap_report`). + Verified(tee_verifier_interface::VerifiedReport), + /// Return `VerificationResult::Rejected` with this reason. + Rejected(String), + /// Panic, simulating an unreachable / crashing verifier (the no-verdict + /// path that `mpc-contract` resolves via the yield timeout). + Panic, +} + +#[derive(Debug)] +#[near(contract_state)] +pub struct TestTeeVerifier { + response: StubResponse, +} + +impl Default for TestTeeVerifier { + fn default() -> Self { + // A contract must be initialized via `new`; default would never be used + // by a test, but `#[near(contract_state)]` requires the bound. + env::panic_str("TestTeeVerifier must be initialized with `new`") + } +} + +#[near] +impl TestTeeVerifier { + #[init] + pub fn new(#[serializer(borsh)] response: StubResponse) -> Self { + Self { response } + } + + /// Stub mirror of `tee_verifier::verify_quote`: ignores `quote`/`collateral` + /// and returns the canned response. Panics on `StubResponse::Panic`. + #[result_serializer(borsh)] + pub fn verify_quote( + &self, + #[serializer(borsh)] _quote: QuoteBytes, + #[serializer(borsh)] _collateral: Collateral, + ) -> VerificationResult { + match &self.response { + StubResponse::Verified(report) => VerificationResult::Verified(report.clone()), + StubResponse::Rejected(reason) => { + VerificationResult::Rejected(VerifierError::DcapVerification(reason.to_string())) + } + StubResponse::Panic => env::panic_str("stub verifier: simulated crash"), + } + } +} diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 0346ce6c69..9f14734746 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -337,12 +337,14 @@ The contract gains two new state fields: pub struct MpcContract { // ... existing fields ... - /// The locked account `mpc-contract` currently trusts as the verifier. - /// `submit_participant_info` calls `verify_quote` on this account. - /// Mutated only by the threshold-crossing vote above; the mutation - /// re-routes future submissions and does not touch already-stored - /// attestations. - tee_verifier_account_id: AccountId, + /// The locked account `mpc-contract` currently trusts as the verifier, or + /// `None` until participants vote one in (a `Dstack` `submit_participant_info` + /// is then rejected with `VerifierNotConfigured`). `submit_participant_info` + /// calls `verify_quote` on this account. Mutated only by the threshold-crossing + /// vote above; the mutation re-routes future submissions and does not touch + /// already-stored attestations. (Making this non-`Option` once a verifier is + /// voted in is the follow-up #3639.) + tee_verifier_account_id: Option, /// Pending votes for changing `tee_verifier_account_id`. Each voter is an /// active MPC participant; each proposal is hashed from @@ -381,7 +383,7 @@ sequenceDiagram ### `mpc-contract::submit_participant_info` -The method splits across three receipts joined by yield-resume — see [§Submission flow](#submission-flow) above for the architecture. The return type is [`PromiseOrValue<()>`](https://docs.rs/near-sdk/5.26.1/near_sdk/enum.PromiseOrValue.html), `near-sdk`'s "sometimes synchronous, sometimes a Promise chain" type: `Mock` attestations return `Value(())` immediately, and `Dstack` attestations return the yielded `Promise` from [`env::promise_yield_create`][promise-yield-create], which the runtime resolves either when `resolve_verification` calls [`env::promise_yield_resume`][promise-yield-resume] or after ~200 blocks of silence. The post-DCAP checks and the `stored_attestations` insert live in `resolve_verification`, not in the yield-callback — that's what keeps `on_attestation_verified` trivial enough to match the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]. Draft implementation: +The method splits across three receipts joined by yield-resume — see [§Submission flow](#submission-flow) above for the architecture. It returns `Result<(), Error>`, like the existing yield producers (`sign` / `request_app_private_key` / `verify_foreign_transaction`): `Mock` attestations are verified synchronously and return `Ok(())`; `Dstack` attestations register a yield via [`env::promise_yield_create`][promise-yield-create] and end on `enqueue_yield_request` so that its `env::promise_return` is the method's result. The runtime resolves the yield either when `resolve_verification` calls [`env::promise_yield_resume`][promise-yield-resume] or after ~200 blocks of silence. The post-DCAP checks and the `stored_attestations` insert live in `resolve_verification`, not in the yield-callback — that's what keeps `on_attestation_verified` trivial enough to match the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]. Draft implementation: ```rust impl MpcContract { @@ -389,15 +391,15 @@ impl MpcContract { &mut self, attestation: Attestation, tls_pk: Ed25519PublicKey, - ) -> PromiseOrValue<()> { + ) -> Result<(), Error> { // Existing convention: caller must be the signer of this transaction, // not a relayer or proxy. let account_id = Self::assert_caller_is_signer(); match attestation { - // Unchanged from today. + // Synchronous: no DCAP, verified and stored in this call. Attestation::Mock(mock) => { - self.verify_mock_synchronously(mock, tls_pk); - PromiseOrValue::Value(()) + self.tee_state.add_mock_participant(node_id, mock, ...)?; + Ok(()) } // Dstack: yield-resume. Attestation::Dstack(dstack) => { @@ -406,18 +408,45 @@ impl MpcContract { // runtime timeout) is rejected outright — same shape as // duplicate sign requests. if self.pending_attestations.contains_key(&account_id) { - env::panic_str("verification already pending"); + return Err(TeeError::VerificationAlreadyPending.into()); } + // Refuse until a verifier is voted in: there is no account to + // call `verify_quote` on. + let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { + return Err(TeeError::VerifierNotConfigured.into()); + }; - let (quote, collateral) = extract_dcap_inputs(&dstack); let attached_deposit = env::attached_deposit(); - // Reuses the existing `enqueue_yield_request` helper that - // wraps `env::promise_yield_create`. The helper allocates - // `data_id`, registers `on_attestation_verified` as the - // yield-callback, and surfaces `data_id` via the `insert` - // closure so we can stash it together with the rest of the - // `PendingAttestation` fields. + // Cross-contract call to the verifier, built first so the + // `enqueue_yield_request` below stays the final host call. Its + // `.then` callback (`resolve_verification`) is the bridge that + // turns the verifier's response into a `promise_yield_resume` on + // the yield this method registers next. Quote/collateral are + // serialized by reference so `dstack` can move into the pending + // entry without cloning the (large) payload. + Promise::new(verifier_account_id) + .function_call( + "verify_quote".into(), + borsh::to_vec(&(&dstack.quote, &dstack.collateral)).unwrap(), + NearToken::from_yoctonear(0), + Gas::from_tgas(VERIFIER_GAS_TGAS), + ) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(Gas::from_tgas(RESOLVE_GAS_TGAS)) + .resolve_verification(node_id.clone()), + ) + .detach(); + + // Reuses the existing `enqueue_yield_request` helper that wraps + // `env::promise_yield_create`. The helper allocates `data_id`, + // registers `on_attestation_verified` as the yield-callback, and + // surfaces `data_id` via the `insert` closure so we can stash it + // together with the rest of the `PendingAttestation` fields. It + // calls `env::promise_return` last, making the yield the method's + // result — so we just return `Ok(())` (no `value_return` that + // would override it). self.enqueue_yield_request( "on_attestation_verified", borsh::to_vec(&account_id).unwrap(), @@ -434,28 +463,7 @@ impl MpcContract { ); }, ); - - // Cross-contract call to the verifier. Its `.then` callback - // (`resolve_verification`) is the bridge that turns the - // verifier's response into a `promise_yield_resume` on the - // yield this method registered above. - Promise::new(self.tee_verifier_account_id.clone()) - .function_call( - "verify_quote".into(), - borsh::to_vec(&(quote, collateral)).unwrap(), - NearToken::from_yoctonear(0), - Gas::from_tgas(VERIFIER_GAS_TGAS), - ) - .then( - Self::ext(env::current_account_id()) - .with_static_gas(Gas::from_tgas(RESOLVE_GAS_TGAS)) - .resolve_verification(account_id), - ); - - // The yield handle was returned by `enqueue_yield_request` - // via `env::promise_return`, so the caller's `Promise` - // resolves with whatever the yield-callback returns. - PromiseOrValue::Value(()) + Ok(()) } } } @@ -562,18 +570,27 @@ impl MpcContract { &mut self, account_id: AccountId, #[callback_result] result: Result, - ) -> Result<(), String> { - match result { - Ok(FinalOutcome::Ok) => Ok(()), - Ok(FinalOutcome::Err(reason)) => Err(reason), + ) -> PromiseOrValue<()> { + let reason = match result { + Ok(FinalOutcome::Ok) => return PromiseOrValue::Value(()), + Ok(FinalOutcome::Err(reason)) => reason, Err(_promise_err) => { if let Some(pending) = self.pending_attestations.remove(&account_id) { refund_deposit(&account_id, pending.attached_deposit); log!("yield timeout for {account_id}: refunded and cleaned up"); } - Err("verifier did not respond within yield-resume window".to_string()) + "verifier did not respond within yield-resume window".to_string() } - } + }; + // Fail the submitter's transaction from a SEPARATE receipt: a panic here + // would roll back the cleanup above. + let promise = Promise::new(env::current_account_id()).function_call( + "fail_attestation_submission".into(), + borsh::to_vec(&reason).unwrap(), + NearToken::from_near(0), + Gas::from_tgas(FAIL_GAS_TGAS), + ); + PromiseOrValue::Promise(promise.as_return()) } } diff --git a/docs/localnet/tee-localnet.md b/docs/localnet/tee-localnet.md index 054a39042f..70ab1573b7 100644 --- a/docs/localnet/tee-localnet.md +++ b/docs/localnet/tee-localnet.md @@ -276,7 +276,7 @@ near transaction view-status network-config mpc-localnet ``` ``` -(ExecutionError("Smart contract panicked: Invalid TEE Remote Attestation.: TeeQuoteStatus is invalid: the allowed mpc image hashes list is empty" +(ExecutionError("Smart contract panicked: Invalid TEE Remote Attestation.: attestation verification failed: the allowed mpc image hashes list is empty" ``` ### Vote Commands diff --git a/docs/running-an-mpc-node-in-tdx-external-guide.md b/docs/running-an-mpc-node-in-tdx-external-guide.md index 3c455fc1f5..e6413e02df 100644 --- a/docs/running-an-mpc-node-in-tdx-external-guide.md +++ b/docs/running-an-mpc-node-in-tdx-external-guide.md @@ -2062,7 +2062,7 @@ The error after `err=` is the NEAR runtime error. Common ones: If the transaction reaches execution and the contract panics, the node logs only the generic retry line above; the actual message lives in the transaction receipt. Find the tx on `https://testnet.nearblocks.io/address/` and open the failed `submit_participant_info` call — the error appears under the action's status / logs. The contract wraps the attestation-side error like this: ``` -Invalid TEE Remote Attestation: TeeQuoteStatus is invalid: +Invalid TEE Remote Attestation: attestation verification failed: the submitted attestation failed verification, reason: Custom("...") ``` From 99ee906e864152d84db07fa92d1d36d0979446be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 1 Jul 2026 13:13:42 +0200 Subject: [PATCH 27/44] test(contract): unit-cover VerifierNotConfigured and revert_dstack_store Adds in-process coverage the reviewer asked for on the async attestation flow: - submit_participant_info rejects a Dstack submission with VerifierNotConfigured when no verifier is voted in (fails before the yield) - TeeState::revert_dstack_store restores the displaced entry on an update and removes a newly-inserted one The VerificationAlreadyPending / pending-insert and InsufficientDeposit invariants aren't unit-testable: near_sdk's mock VM does not support promise_yield_create and does not simulate storage_usage() deltas. Those paths are exercised by the sandbox tee_verifier tests. --- crates/contract/src/tee/tee_state.rs | 69 +++++++++++++++++++ .../tests/inprocess/attestation_submission.rs | 22 +++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 8b1c211ef0..38567b97fa 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -1527,6 +1527,75 @@ mod tests { assert_eq!(stored.node_id, rotated_node); } + #[test] + fn revert_dstack_store__restores_the_displaced_entry_on_update() { + // Given: `alice` has an attestation, then updates it — the second insertion + // returns the displaced original wrapped in `UpdatedExistingParticipant`. + const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); + let mut tee_state = TeeState::default(); + let tls_public_key = bogus_ed25519_public_key(); + let original_node = NodeId { + account_id: "alice.near".parse().unwrap(), + tls_public_key: tls_public_key.clone(), + account_public_key: bogus_ed25519_public_key(), + }; + tee_state + .verify_and_store_mock( + original_node.clone(), + MockAttestation::Valid, + TEE_UPGRADE_DURATION, + ) + .expect("initial insertion should succeed"); + let updated_node = NodeId { + account_id: "alice.near".parse().unwrap(), + tls_public_key: tls_public_key.clone(), + account_public_key: bogus_ed25519_public_key(), + }; + let insertion = tee_state + .verify_and_store_mock(updated_node, MockAttestation::Valid, TEE_UPGRADE_DURATION) + .expect("update should succeed"); + assert_matches!( + insertion, + ParticipantInsertion::UpdatedExistingParticipant(_) + ); + + // When: the store is reverted. + tee_state.revert_dstack_store(&tls_public_key, insertion); + + // Then: the original (displaced) entry is back in place. + let stored = tee_state + .stored_attestations + .get(&tls_public_key) + .expect("original entry must be restored"); + assert_eq!(stored.node_id, original_node); + } + + #[test] + fn revert_dstack_store__removes_the_newly_inserted_entry() { + // Given: a brand-new attestation for `alice` (no prior entry displaced). + const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); + let mut tee_state = TeeState::default(); + let tls_public_key = bogus_ed25519_public_key(); + let node = NodeId { + account_id: "alice.near".parse().unwrap(), + tls_public_key: tls_public_key.clone(), + account_public_key: bogus_ed25519_public_key(), + }; + let insertion = tee_state + .verify_and_store_mock(node, MockAttestation::Valid, TEE_UPGRADE_DURATION) + .expect("insertion should succeed"); + assert_matches!(insertion, ParticipantInsertion::NewlyInsertedParticipant); + + // When: the store is reverted. + tee_state.revert_dstack_store(&tls_public_key, insertion); + + // Then: the entry is gone. + assert!( + tee_state.stored_attestations.get(&tls_public_key).is_none(), + "newly inserted entry must be removed on revert" + ); + } + #[test] fn verify_and_store_mock__should_reject_invalid_attestations() { let mut tee_state = TeeState::default(); diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index b84b5752b6..be06b4b748 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -3,7 +3,7 @@ use mpc_contract::{ MpcContract, crypto_shared::types::PublicKeyExtended, - errors::Error, + errors::{Error, TeeError}, primitives::{ key_state::{AttemptId, EpochId, KeyForDomain, Keyset}, participants::{ParticipantId, ParticipantInfo}, @@ -26,6 +26,7 @@ use near_account_id::AccountId; use near_sdk::{NearToken, VMContext, test_utils::VMContextBuilder, testing_env}; use rstest::rstest; use std::{str::FromStr, time::Duration}; +use test_utils::attestation::mock_dto_dstack_attestation; const SECOND: Duration = Duration::from_secs(1); const NANOS_IN_SECOND: u64 = SECOND.as_nanos() as u64; @@ -363,6 +364,25 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { assert_eq!(stored_before, stored_after); } +/// **Test that a `Dstack` submission is rejected when no verifier is configured.** The +/// async path has nowhere to offload DCAP verification, so it must fail up front (before +/// registering a yield) rather than leave a submission that can never resolve. +#[test] +fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() { + // Given: a running contract with no TEE verifier voted in. + let mut setup = TestSetupBuilder::new().build(); + let node = setup.get_participant_node_ids()[0].clone(); + + // When: that participant submits a Dstack attestation. + let result = setup.try_submit_attestation_for_node(&node, mock_dto_dstack_attestation()); + + // Then: it is rejected with `VerifierNotConfigured`. + assert_matches!( + &result, + Err(Error::TeeError(TeeError::VerifierNotConfigured)) + ); +} + /// **Test that `clean_tee_status()` is vote-only** — attestations for non-participants /// remain in `stored_attestations` after the call. Attestation pruning is handled by the /// separate `clean_invalid_attestations` endpoint. From 49ac8427f5ee6f8d2981ba3316a7574d3a56fd3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 2 Jul 2026 12:21:09 +0200 Subject: [PATCH 28/44] test(contract): cover OOG inside resolve_verification Adds a sandbox test that configures resolve_verification_tera_gas far below what the post-DCAP work needs, so the callback receipt runs out of gas and rolls back atomically without resuming the yield. Asserts the ~200-block timeout branch of on_attestation_verified still cleans up the pending entry and refunds the deposit, guarding the invariant that a partial resolve_verification receipt cannot leave a refunded-but-still-pending entry that wedges the account --- crates/contract/tests/sandbox/tee_verifier.rs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 511fd0a23d..b78a7c4f08 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -9,6 +9,8 @@ //! - verifier not configured → submission rejected, nothing stored. //! - `Rejected` → submission fails, deposit refunded, no stored attestation. //! - no-verdict (stub panics) → the ~200-block yield timeout cleans up. +//! - `resolve_verification` runs out of gas → its receipt rolls back atomically +//! and the same ~200-block timeout cleans up (no half-committed state). #![allow(non_snake_case)] use crate::sandbox::{ @@ -214,6 +216,72 @@ async fn submit_participant_info__should_clean_up_on_verifier_crash() -> Result< Ok(()) } +#[tokio::test] +async fn submit_participant_info__should_clean_up_when_resolve_verification_runs_out_of_gas() +-> Result<()> { + // Given: a contract configured with a `resolve_verification` gas budget far + // too small to run the post-DCAP work and resume the yield, so the callback + // receipt runs out of gas mid-execution and rolls back atomically. The stub + // answers (here, a rejection) so `resolve_verification` actually runs rather + // than hitting the no-verdict early return. + let init_config = dtos::InitConfig { + resolve_verification_tera_gas: Some(1), + ..Default::default() + }; + let SandboxTestSetup { + worker, + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .with_sandbox_test_methods() + .with_init_config(init_config) + .build() + .await; + deploy_and_trust_stub( + &worker, + &contract, + &mpc_signer_accounts, + StubResponse::Rejected("would-refund-if-resolve-had-gas".to_string()), + ) + .await?; + + // When: a participant submits. The verifier answers, but `resolve_verification` + // runs out of gas before `promise_yield_resume`, so its whole receipt — the + // pending-entry removal and the refund included — rolls back and the yield is + // never resumed. The chain then advances past the ~200-block timeout so the + // runtime fires `on_attestation_verified`'s timeout branch. + let submitter = &mpc_signer_accounts[0]; + let balance_before = submitter.view_account().await?.balance; + let _ = submit_participant_info_with_deposit( + submitter, + &contract, + &dstack_attestation(), + &tls_key(), + NearToken::from_near(1), + ) + .await?; + worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await?; + + // Then: an out-of-gas `resolve_verification` is recovered exactly like an + // unreachable verifier — nothing stored, the pending entry cleaned up by the + // timeout branch, and the deposit refunded. This is the guarantee that a + // partial `resolve_verification` receipt cannot leave a refunded-but-still- + // pending entry: the receipt is atomic, so the account is not wedged. + let stored = get_participant_attestation(&contract, &tls_key()).await?; + assert!( + stored.is_none(), + "nothing should be stored when resolve_verification runs out of gas" + ); + assert!( + !has_pending_attestation(&contract, submitter.id()).await?, + "the pending entry must be cleaned up by the yield timeout after an OOG resolve_verification" + ); + assert_deposit_refunded(submitter, balance_before).await?; + Ok(()) +} + /// Asserts the 1 NEAR storage deposit was returned: the net spend since /// `balance_before` is well under 1 NEAR (only gas), rather than the full /// deposit being retained by the contract. From 04faa63a4ef1f74a1eef0576321dff43c71fd1ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 2 Jul 2026 13:03:20 +0200 Subject: [PATCH 29/44] test(contract): address review on async attestation tests - assert_deposit_refunded: use raw subtraction instead of saturating_sub so an over-refund (balance_after > balance_before) panics rather than clamping to 0 and silently passing (CLAUDE.md forbids saturating arithmetic in tests) - document the has_pending_attestation sandbox view, matching its siblings - fix a stray period in the example panic string in tee-localnet.md (Invalid TEE Remote Attestation: ..., no period before the colon) --- crates/contract/src/sandbox_test_methods.rs | 5 +++++ crates/contract/tests/sandbox/tee_verifier.rs | 9 ++++++--- docs/localnet/tee-localnet.md | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/contract/src/sandbox_test_methods.rs b/crates/contract/src/sandbox_test_methods.rs index 63120a02ad..f20652873b 100644 --- a/crates/contract/src/sandbox_test_methods.rs +++ b/crates/contract/src/sandbox_test_methods.rs @@ -49,6 +49,11 @@ impl MpcContract { .expect("queue length must fit in u32 — bounded by MAX_PENDING_REQUEST_FAN_OUT") } + /// Whether an in-flight attestation entry exists for `account_id`. + /// + /// Used by the yield-resume sandbox tests to assert the pending entry is + /// cleaned up after a rejection, the yield timeout, or an out-of-gas + /// `resolve_verification`. pub fn has_pending_attestation(&self, account_id: AccountId) -> bool { self.pending_attestations.contains_key(&account_id) } diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index b78a7c4f08..784cbd5de6 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -287,10 +287,13 @@ async fn submit_participant_info__should_clean_up_when_resolve_verification_runs /// deposit being retained by the contract. async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) -> Result<()> { let balance_after = account.view_account().await?.balance; - let net_spent = balance_before.saturating_sub(balance_after); + // Raw subtraction (not `saturating_sub`): if the contract over-refunds so + // `balance_after > balance_before`, this underflows and panics rather than + // clamping to 0 and silently passing the `< 1 NEAR` check. + let net_spent = balance_before.as_yoctonear() - balance_after.as_yoctonear(); assert!( - net_spent < NearToken::from_near(1), - "deposit should be refunded (net spent {net_spent} should be < 1 NEAR, gas only)" + net_spent < NearToken::from_near(1).as_yoctonear(), + "deposit should be refunded (net spent {net_spent} yoctoNEAR should be < 1 NEAR, gas only)" ); Ok(()) } diff --git a/docs/localnet/tee-localnet.md b/docs/localnet/tee-localnet.md index 70ab1573b7..599d3c3eeb 100644 --- a/docs/localnet/tee-localnet.md +++ b/docs/localnet/tee-localnet.md @@ -276,7 +276,7 @@ near transaction view-status network-config mpc-localnet ``` ``` -(ExecutionError("Smart contract panicked: Invalid TEE Remote Attestation.: attestation verification failed: the allowed mpc image hashes list is empty" +(ExecutionError("Smart contract panicked: Invalid TEE Remote Attestation: attestation verification failed: the allowed mpc image hashes list is empty" ``` ### Vote Commands From fc07dc32bd461dfea6732c39411ebfb4a86e1a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 2 Jul 2026 15:15:03 +0200 Subject: [PATCH 30/44] test(contract): address self-review findings on async attestation tests Self-review (adversarially verified) surfaced 11 issues; applied the mechanical + correctness ones: - init_config was silently dropped on the init_running setup path, so the OOG test ran with default gas and re-tested the rejection branch. Plumb init_config through init_contract_running. - assert_deposit_refunded: bound to the gas envelope (~50 mNEAR) so a partial refund fails, instead of the loose < 1 NEAR check. - not-configured test asserts the specific VerifierNotConfigured error rather than only is_failure(). - StubResponse: pin Borsh discriminants in a unit test + keep-in-sync comments on both mirror declarations. - rename revert_dstack_store tests to the __should_ form; drop em dashes. - docs: correct the submission-path panic strings (no "Invalid TEE Remote Attestation" prefix), request_verify_foreign_tx -> verify_foreign_transaction, resolve_verification sample param, and drop the non-existent DstackAttestation::dcap_report reference. - deny.toml: ignore RUSTSEC-2026-0194/0195 (transitive quick-xml, fixed in >=0.41.0) to unblock cargo-deny. Add a verified_report() test-utils fixture (mints the real report via verify_dcap_quote) toward the deferred Verified-path coverage. The OOG test is #[ignore]d with TODO(#3730): forcing a real OOG needs the allowlist populated so execution reaches the gas-heavy RTMR3 replay; that governance setup is shared with the pending happy-path test. --- Cargo.lock | 1 + crates/contract/src/tee/tee_state.rs | 8 +- crates/contract/tests/sandbox/common.rs | 3 + .../tests/sandbox/participants_gas.rs | 3 +- crates/contract/tests/sandbox/tee_verifier.rs | 106 +++++++++++++----- crates/test-tee-verifier/src/lib.rs | 7 +- crates/test-utils/Cargo.toml | 3 +- crates/test-utils/src/attestation.rs | 16 +++ docs/design/attestation-verifier-contract.md | 41 +++---- docs/localnet/tee-localnet.md | 2 +- ...nning-an-mpc-node-in-tdx-external-guide.md | 3 +- 11 files changed, 138 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 17994c904f..df155ccf90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11386,6 +11386,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.10.9", + "tee-verifier-interface", ] [[package]] diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 38567b97fa..ca25733bc2 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -1528,9 +1528,9 @@ mod tests { } #[test] - fn revert_dstack_store__restores_the_displaced_entry_on_update() { - // Given: `alice` has an attestation, then updates it — the second insertion - // returns the displaced original wrapped in `UpdatedExistingParticipant`. + fn revert_dstack_store__should_restore_the_displaced_entry_on_update() { + // Given: `alice` has an attestation, then updates it (the second insertion + // returns the displaced original wrapped in `UpdatedExistingParticipant`). const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); let mut tee_state = TeeState::default(); let tls_public_key = bogus_ed25519_public_key(); @@ -1571,7 +1571,7 @@ mod tests { } #[test] - fn revert_dstack_store__removes_the_newly_inserted_entry() { + fn revert_dstack_store__should_remove_the_newly_inserted_entry() { // Given: a brand-new attestation for `alice` (no prior entry displaced). const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); let mut tee_state = TeeState::default(); diff --git a/crates/contract/tests/sandbox/common.rs b/crates/contract/tests/sandbox/common.rs index e900bc444d..2ce03f7e12 100644 --- a/crates/contract/tests/sandbox/common.rs +++ b/crates/contract/tests/sandbox/common.rs @@ -145,6 +145,7 @@ pub async fn init_contract_running( next_domain_id: u64, keyset: Keyset, params: ThresholdParameters, + init_config: Option, ) -> ExecutionSuccess { let result = contract .call(method_names::INIT_RUNNING) @@ -153,6 +154,7 @@ pub async fn init_contract_running( "next_domain_id": next_domain_id, "keyset": keyset, "parameters": params, + "init_config": init_config, })) .gas(GAS_FOR_INIT) .transact() @@ -311,6 +313,7 @@ impl SandboxTestSetupBuilder { next_domain_id, keyset, threshold_parameters, + self.init_config, ) .await; } else { diff --git a/crates/contract/tests/sandbox/participants_gas.rs b/crates/contract/tests/sandbox/participants_gas.rs index bd747b99f2..4a56c67b31 100644 --- a/crates/contract/tests/sandbox/participants_gas.rs +++ b/crates/contract/tests/sandbox/participants_gas.rs @@ -289,7 +289,8 @@ async fn setup_test_env_with_state(n_participants: usize, running_state: bool) - let keyset = Keyset::new(EpochId::new(1), vec![key]); let domains = vec![domain]; let next_domain_id = domains.len() as u64 + 1; - init_contract_running(&contract, domains, next_domain_id, keyset, threshold_params).await; + init_contract_running(&contract, domains, next_domain_id, keyset, threshold_params, None) + .await; } else { init_contract(&contract, threshold_params, None).await; } diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 784cbd5de6..2ed83ab816 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -28,7 +28,7 @@ use anyhow::Result; use borsh::BorshSerialize; use near_mpc_contract_interface::types::{self as dtos, Attestation}; use near_workspaces::{Account, Contract, Worker, network::Sandbox, types::NearToken}; -use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key}; +use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key, verified_report}; /// Blocks to fast-forward past the ~200-block yield-resume timeout so the /// runtime fires `on_attestation_verified`'s timeout branch. @@ -38,10 +38,14 @@ const YIELD_TIMEOUT_BLOCKS: u64 = 250; /// depending on the stub crate) so the test only needs its Borsh encoding to /// initialize the deployed stub; the stub is a separate `#[near]` contract and /// linking its crate into this test binary would collide on ABI symbols. +/// +/// KEEP THE VARIANT ORDER IN SYNC with `test_tee_verifier::StubResponse`: Borsh +/// encodes an enum as a u8 discriminant equal to the declaration index, so a +/// reorder on either side silently misroutes the response. `stub_response_discriminants` +/// below pins the indices so a divergence fails loudly. #[expect(clippy::large_enum_variant)] #[derive(BorshSerialize)] enum StubResponse { - #[expect(dead_code)] Verified(tee_verifier_interface::VerifiedReport), Rejected(String), Panic, @@ -102,10 +106,17 @@ async fn submit_participant_info__should_reject_dstack_when_verifier_not_configu ) .await?; - // Then: it is rejected (no verifier configured) and nothing is stored. + // Then: it fails synchronously with the VerifierNotConfigured error (the + // early return in submit_dstack_attestation, before any yield is registered), + // and nothing is stored. Assert the specific message so an unrelated failure + // (gas, encoding) can't pass as success. + let err = result + .into_result() + .expect_err("Dstack submit must fail when no verifier is configured") + .to_string(); assert!( - result.is_failure(), - "Dstack submit must fail when no verifier is configured: {result:#?}" + err.contains("No TEE verifier is configured"), + "expected VerifierNotConfigured, got: {err}" ); let stored = get_participant_attestation(&contract, &tls_key()).await?; assert!(stored.is_none(), "no attestation should be stored"); @@ -216,14 +227,27 @@ async fn submit_participant_info__should_clean_up_on_verifier_crash() -> Result< Ok(()) } +// TODO(#3730): un-ignore once the fixture allowlist setup lands. To make +// `resolve_verification` actually run out of gas, execution must reach the +// expensive RTMR3 replay inside `verify_post_dcap_and_store` before exhausting +// the 1 TGas budget. That requires the post-DCAP checks to get *past* the +// allowlist gate first, i.e. the contract must have the fixture's MPC image hash +// (`image_digest()`), launcher compose hash (`launcher_compose_digest()`), and +// measurements voted in, and the submitter must use the fixture keys so the +// report-data binding matches. With an empty allowlist (as here) the check +// fails fast and cheap, so `resolve_verification` completes at 1 TGas and this +// re-tests the rejection path instead. Shares that setup with the (also pending) +// Verified happy-path test. +#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; see TODO(#3730)"] #[tokio::test] async fn submit_participant_info__should_clean_up_when_resolve_verification_runs_out_of_gas() -> Result<()> { // Given: a contract configured with a `resolve_verification` gas budget far - // too small to run the post-DCAP work and resume the yield, so the callback - // receipt runs out of gas mid-execution and rolls back atomically. The stub - // answers (here, a rejection) so `resolve_verification` actually runs rather - // than hitting the no-verdict early return. + // too small to run the post-DCAP work and resume the yield. The stub returns + // `Verified` so `resolve_verification` enters `verify_post_dcap_and_store` + // (the heavy RTMR3-replay path), which then exhausts the 1 TGas budget and + // rolls the whole receipt back. A `Rejected` response would not work here: + // its branch is light enough to complete even at 1 TGas. let init_config = dtos::InitConfig { resolve_verification_tera_gas: Some(1), ..Default::default() @@ -243,15 +267,14 @@ async fn submit_participant_info__should_clean_up_when_resolve_verification_runs &worker, &contract, &mpc_signer_accounts, - StubResponse::Rejected("would-refund-if-resolve-had-gas".to_string()), + StubResponse::Verified(verified_report()), ) .await?; // When: a participant submits. The verifier answers, but `resolve_verification` - // runs out of gas before `promise_yield_resume`, so its whole receipt — the - // pending-entry removal and the refund included — rolls back and the yield is - // never resumed. The chain then advances past the ~200-block timeout so the - // runtime fires `on_attestation_verified`'s timeout branch. + // runs out of gas before `promise_yield_resume`, so its whole receipt (the + // pending-entry removal and the refund included) rolls back and the yield is + // never resumed. let submitter = &mpc_signer_accounts[0]; let balance_before = submitter.view_account().await?.balance; let _ = submit_participant_info_with_deposit( @@ -262,13 +285,24 @@ async fn submit_participant_info__should_clean_up_when_resolve_verification_runs NearToken::from_near(1), ) .await?; + + // Distinguish this path from the rejection test: because + // `resolve_verification` rolled back rather than resuming, the pending entry + // is still present here. The rejection path would have removed it already. + assert!( + has_pending_attestation(&contract, submitter.id()).await?, + "pending entry must survive an OOG resolve_verification (cleanup is left to the timeout)" + ); + + // Advancing past the ~200-block window fires `on_attestation_verified`'s + // timeout branch, which is what actually cleans up in this path. worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await?; - // Then: an out-of-gas `resolve_verification` is recovered exactly like an - // unreachable verifier — nothing stored, the pending entry cleaned up by the - // timeout branch, and the deposit refunded. This is the guarantee that a - // partial `resolve_verification` receipt cannot leave a refunded-but-still- - // pending entry: the receipt is atomic, so the account is not wedged. + // Then: an out-of-gas `resolve_verification` is recovered like an unreachable + // verifier: nothing stored, the pending entry cleaned up by the timeout + // branch, and the deposit refunded. This is the guarantee that a partial + // `resolve_verification` receipt cannot leave a refunded-but-still-pending + // entry: the receipt is atomic, so the account is not wedged. let stored = get_participant_attestation(&contract, &tls_key()).await?; assert!( stored.is_none(), @@ -282,18 +316,40 @@ async fn submit_participant_info__should_clean_up_when_resolve_verification_runs Ok(()) } -/// Asserts the 1 NEAR storage deposit was returned: the net spend since -/// `balance_before` is well under 1 NEAR (only gas), rather than the full -/// deposit being retained by the contract. +/// Asserts the full 1 NEAR storage deposit was returned: the net spend since +/// `balance_before` is only gas, well under any fraction of the deposit. async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) -> Result<()> { let balance_after = account.view_account().await?.balance; // Raw subtraction (not `saturating_sub`): if the contract over-refunds so // `balance_after > balance_before`, this underflows and panics rather than - // clamping to 0 and silently passing the `< 1 NEAR` check. + // clamping to 0 and silently passing. let net_spent = balance_before.as_yoctonear() - balance_after.as_yoctonear(); + // Bound to the gas envelope, not the deposit: max gas (~0.03 NEAR at the + // sandbox price) sits far below this ceiling, while any partial retention of + // the 1 NEAR deposit (e.g. 0.5 NEAR) would exceed it and fail. + let gas_ceiling = NearToken::from_millinear(50).as_yoctonear(); assert!( - net_spent < NearToken::from_near(1).as_yoctonear(), - "deposit should be refunded (net spent {net_spent} yoctoNEAR should be < 1 NEAR, gas only)" + net_spent < gas_ceiling, + "deposit should be fully refunded (net spent {net_spent} yoctoNEAR should be gas-only, < {gas_ceiling})" ); Ok(()) } + +/// Pins the Borsh discriminant of each [`StubResponse`] variant to its declaration +/// index. The deployed `test_tee_verifier::StubResponse` deserializes what this +/// mirror serializes, so a reorder on either side must fail loudly here rather +/// than silently misroute a response. `Verified` is index 0 by position (a +/// `VerifiedReport` fixture is not needed to guard the reorder that matters). +#[test] +fn stub_response_discriminants() { + assert_eq!( + borsh::to_vec(&StubResponse::Rejected(String::new())).unwrap()[0], + 1, + "Rejected must be Borsh discriminant 1" + ); + assert_eq!( + borsh::to_vec(&StubResponse::Panic).unwrap()[0], + 2, + "Panic must be Borsh discriminant 2" + ); +} diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs index bd5168f1ef..91e2e149ab 100644 --- a/crates/test-tee-verifier/src/lib.rs +++ b/crates/test-tee-verifier/src/lib.rs @@ -25,6 +25,11 @@ getrandom::register_custom_getrandom!(randomness_unsupported); /// What the stub's `verify_quote` should do, chosen by the test at deploy time. #[expect(clippy::large_enum_variant)] +// KEEP THE VARIANT ORDER IN SYNC with the `StubResponse` mirror in +// `crates/contract/tests/sandbox/tee_verifier.rs`: the test serializes with that +// copy and this contract deserializes with this one, so the Borsh discriminants +// (declaration index) must match. That mirror's `stub_response_discriminants` +// test pins the indices. #[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), @@ -33,7 +38,7 @@ getrandom::register_custom_getrandom!(randomness_unsupported); pub enum StubResponse { /// Return `VerificationResult::Verified` with this exact report. Tests that /// want the post-DCAP checks to pass supply the report obtained from the - /// real fixture quote (e.g. via `DstackAttestation::dcap_report`). + /// real fixture quote. Verified(tee_verifier_interface::VerifiedReport), /// Return `VerificationResult::Rejected` with this reason. Rejected(String), diff --git a/crates/test-utils/Cargo.toml b/crates/test-utils/Cargo.toml index 340e26f674..909b3dea9d 100644 --- a/crates/test-utils/Cargo.toml +++ b/crates/test-utils/Cargo.toml @@ -7,7 +7,8 @@ edition = { workspace = true } [dependencies] cargo-near-build = { workspace = true } hex = { workspace = true } -mpc-attestation = { workspace = true, features = ["test-utils"] } +mpc-attestation = { workspace = true, features = ["test-utils", "local-verify"] } +tee-verifier-interface = { workspace = true } mpc-primitives = { workspace = true } near-mpc-contract-interface = { workspace = true } near-sdk = { workspace = true, features = ["non-contract-usage"] } diff --git a/crates/test-utils/src/attestation.rs b/crates/test-utils/src/attestation.rs index 3c4af6c72e..22c129f38e 100644 --- a/crates/test-utils/src/attestation.rs +++ b/crates/test-utils/src/attestation.rs @@ -107,6 +107,22 @@ pub fn mock_dstack_attestation() -> Attestation { Attestation::Dstack(DstackAttestation::new(quote, collateral, tcb_info)) } +/// The [`VerifiedReport`] the real `tee-verifier` would return for the fixture +/// quote. Minted here by running the DCAP step at [`VALID_ATTESTATION_TIMESTAMP`] +/// (when the fixture collateral is valid), so tests can feed it to the stub +/// verifier's `Verified` response and drive the contract's post-DCAP path. +pub fn verified_report() -> tee_verifier_interface::VerifiedReport { + let dstack = DstackAttestation::new( + quote(), + mpc_attestation::collateral::collateral_from_str(include_str!("../assets/collateral.json")) + .expect("collateral.json is valid collateral"), + serde_json::from_str(TEST_TCB_INFO_STRING).unwrap(), + ); + dstack + .verify_dcap_quote(VALID_ATTESTATION_TIMESTAMP) + .expect("fixture quote verifies at VALID_ATTESTATION_TIMESTAMP") +} + pub fn mock_dto_dstack_attestation() -> near_mpc_contract_interface::types::Attestation { let quote = HexVec::from(Vec::from(quote())); let collateral_json_string = include_str!("../assets/collateral.json"); diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 9f14734746..99f81572bb 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -59,7 +59,7 @@ flowchart LR ### Submission flow -`mpc-contract`'s [`submit_participant_info`][submit-participant-info] becomes asynchronous for Dstack attestations and uses the same yield-resume pattern already in place for `sign`, `request_app_private_key`, and `request_verify_foreign_tx`. The method registers a yielded promise via [`env::promise_yield_create`][promise-yield-create] (going through the contract's existing [`enqueue_yield_request`][enqueue-yield-request] helper), stashes the resulting `data_id` in `pending_attestations`, and fires a cross-contract call to `tee-verifier::verify_quote` whose own `.then` callback (`resolve_verification`) bridges the verifier's response into a `promise_yield_resume`. Because `verify_quote` returns a `VerificationResult` value rather than failing the receipt (see [§Why a rejection is a value](#why-a-rejection-is-a-value-not-a-failed-receipt)), `resolve_verification`'s `#[callback_result]` distinguishes three outcomes: `Verified` runs the post-DCAP checks (RTMR3 replay, app-compose validation, measurement allowlist matching, report-data binding) and resumes; `Rejected` refunds and resumes **immediately** with the reason; and only `Err(PromiseError::Failed)` — the verifier unreachable or crashed — returns early, leaving cleanup to the ~200-block timeout. The yield-callback `on_attestation_verified` stays trivial, the same shape as the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]: it returns the resumed outcome to the caller, and only on the timeout (`Err(PromiseError::Failed)`) does it remove the pending entry and schedule a refund. Keeping the heavy work in `resolve_verification` makes an OOG in the yield-callback implausible, and an OOG in `resolve_verification` rolls back its whole receipt so the timeout still fires the trivial callback for cleanup. The exact branch-by-branch behavior is in [§Handling failures](#handling-failures) below. +`mpc-contract`'s [`submit_participant_info`][submit-participant-info] becomes asynchronous for Dstack attestations and uses the same yield-resume pattern already in place for `sign`, `request_app_private_key`, and `verify_foreign_transaction`. The method registers a yielded promise via [`env::promise_yield_create`][promise-yield-create] (going through the contract's existing [`enqueue_yield_request`][enqueue-yield-request] helper), stashes the resulting `data_id` in `pending_attestations`, and fires a cross-contract call to `tee-verifier::verify_quote` whose own `.then` callback (`resolve_verification`) bridges the verifier's response into a `promise_yield_resume`. Because `verify_quote` returns a `VerificationResult` value rather than failing the receipt (see [§Why a rejection is a value](#why-a-rejection-is-a-value-not-a-failed-receipt)), `resolve_verification`'s `#[callback_result]` distinguishes three outcomes: `Verified` runs the post-DCAP checks (RTMR3 replay, app-compose validation, measurement allowlist matching, report-data binding) and resumes; `Rejected` refunds and resumes **immediately** with the reason; and only `Err(PromiseError::Failed)` — the verifier unreachable or crashed — returns early, leaving cleanup to the ~200-block timeout. The yield-callback `on_attestation_verified` stays trivial, the same shape as the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]: it returns the resumed outcome to the caller, and only on the timeout (`Err(PromiseError::Failed)`) does it remove the pending entry and schedule a refund. Keeping the heavy work in `resolve_verification` makes an OOG in the yield-callback implausible, and an OOG in `resolve_verification` rolls back its whole receipt so the timeout still fires the trivial callback for cleanup. The exact branch-by-branch behavior is in [§Handling failures](#handling-failures) below. The post-DCAP policy inputs are the same fields `mpc-contract` already holds today — the allowed-image-hash list, the per-account TLS / account public-key binding, and the stored-attestation map. No new policy state is introduced; the only state addition is the `pending_attestations` map described below, which is bookkeeping for the in-flight yield. @@ -82,14 +82,14 @@ sequenceDiagram alt Verified (post-DCAP runs, then resumes) Ver-->>MPC: VerificationResult::Verified(report) - MPC->>MPC: resolve_verification: finish_verify vs fresh allowlist + MPC->>MPC: resolve_verification: verify_post_dcap_and_store vs fresh allowlist MPC->>State: store on pass / refund on fail, remove PendingAttestation - MPC->>MPC: promise_yield_resume(data_id, FinalOutcome) + MPC->>MPC: promise_yield_resume(data_id, AttestationResult) MPC-->>Op: success or error, immediately else Rejected (resumes immediately) Ver-->>MPC: VerificationResult::Rejected(reason) MPC->>MPC: resolve_verification: refund, remove PendingAttestation - MPC->>MPC: promise_yield_resume(data_id, FinalOutcome::Err(reason)) + MPC->>MPC: promise_yield_resume(data_id, AttestationResult::Err(reason)) MPC-->>Op: error (carrying reason), immediately else No verdict — verifier unreachable / silent for ~200 blocks Note over MPC: resolve_verification (if it runs) only logs and returns.
Cleanup is left to the runtime yield-timeout. @@ -123,7 +123,7 @@ Walking every path the system can take: - `resolve_verification` never runs (verifier silent) → timeout fires, yield-callback cleans up. - Yield-callback itself OOGs → genuine orphan. Its body is one `LookupMap::remove` plus one Promise schedule, small enough that an out-of-gas failure is implausible with a sensible gas budget. -This isn't a novel pattern in this codebase: `sign`, `request_app_private_key`, and `request_verify_foreign_tx` already use the same split. The heavy work and the `promise_yield_resume` call live in [`pending_requests::resolve_yields_for`][pending-requests-mod] (invoked from `respond`, `respond_ckd`, and `respond_verify_foreign_tx`), and [`return_signature_and_clean_state_on_success`][sign-yield-callback] is a tiny yield-callback that just routes the outcome back to the caller and handles the no-response timeout. The attestation flow inherits the same orphan-proof argument: heavy work in a safely-recoverable receipt, cleanup in a callback small enough that gas budgeting makes its failure implausible. +This isn't a novel pattern in this codebase: `sign`, `request_app_private_key`, and `verify_foreign_transaction` already use the same split. The heavy work and the `promise_yield_resume` call live in [`pending_requests::resolve_yields_for`][pending-requests-mod] (invoked from `respond`, `respond_ckd`, and `respond_verify_foreign_tx`), and [`return_signature_and_clean_state_on_success`][sign-yield-callback] is a tiny yield-callback that just routes the outcome back to the caller and handles the no-response timeout. The attestation flow inherits the same orphan-proof argument: heavy work in a safely-recoverable receipt, cleanup in a callback small enough that gas budgeting makes its failure implausible. ### Contract state changes @@ -140,7 +140,7 @@ Each [`PendingAttestation`](#mpc-contractsubmit_participant_info) holds: - **The submitter's `Attestation::Dstack` payload** — the RTMR3 event log, app-compose, and report-data the post-DCAP checks consume. - **The submitter's TLS public key** — the callback hashes it with the submitter's account public key and compares to the quote's `report_data` field, proving the enclave produced the quote for this specific submitter. - **The attached deposit** — covers storage staking on success, refunded to the signer of the original `submit_participant_info` transaction on failure. `env::attached_deposit()` is not visible from the callback receipt, so the value is stashed at submit time and the recipient `AccountId` is the same one used to key the entry (set by `Self::assert_caller_is_signer()`). -- **`data_id: CryptoHash`** — the yield handle returned by [`env::promise_yield_create`][promise-yield-create]. The intermediate `.then` callback (`resolve_verification`) reads this back to call [`env::promise_yield_resume`][promise-yield-resume] with a `FinalOutcome` after the post-DCAP checks have run. +- **`data_id: CryptoHash`** — the yield handle returned by [`env::promise_yield_create`][promise-yield-create]. The intermediate `.then` callback (`resolve_verification`) reads this back to call [`env::promise_yield_resume`][promise-yield-resume] with an `AttestationResult` after the post-DCAP checks have run. Entries are removed by `resolve_verification` on every branch where the verifier returned an answer — `Verified` (post-DCAP success or failure) and `Rejected`; only when the verifier gave no verdict (`Err(PromiseError::Failed)` — unreachable or crashed) does it return early and leave the entry in place. `on_attestation_verified` then removes the entry on its `Err(PromiseError::Failed)` branch, which covers that unreachable case and the no-response timeout. @@ -164,8 +164,8 @@ sequenceDiagram Ver-->>MPC: VerificationResult::Verified(report) MPC->>MPC: resolve_verification MPC->>MPC: read allowlist (sees H) - MPC->>MPC: finish_verify against fresh allowlist - MPC->>MPC: promise_yield_resume(data_id, FinalOutcome) + MPC->>MPC: verify_post_dcap_and_store against fresh allowlist + MPC->>MPC: promise_yield_resume(data_id, AttestationResult) MPC->>MPC: on_attestation_verified (trivial: return value) ``` @@ -398,7 +398,7 @@ impl MpcContract { match attestation { // Synchronous: no DCAP, verified and stored in this call. Attestation::Mock(mock) => { - self.tee_state.add_mock_participant(node_id, mock, ...)?; + self.tee_state.verify_and_store_mock(node_id, mock, ...)?; Ok(()) } // Dstack: yield-resume. @@ -475,7 +475,7 @@ impl MpcContract { /// inserts into `stored_attestations` on success; on `Rejected` it skips /// straight to the refund. Either way it removes the pending entry, /// schedules a refund where the outcome is an error, and calls - /// `promise_yield_resume(data_id, FinalOutcome)` as the LAST step of the + /// `promise_yield_resume(data_id, AttestationResult)` as the LAST step of the /// receipt — so a rejected quote is resolved *immediately*, not at the /// timeout. Only `Err(PromiseError::Failed)` (no verdict — verifier /// unreachable or crashed) is logged and returned early WITHOUT resuming or @@ -495,9 +495,10 @@ impl MpcContract { #[private] pub fn resolve_verification( &mut self, - account_id: AccountId, + node_id: NodeId, #[callback_result] result: Result, ) { + let account_id = node_id.account_id.clone(); let final_outcome = match result { // No verdict: the verifier was unreachable, panicked, or ran out of // gas. Do nothing — the runtime's yield-timeout will fire @@ -514,7 +515,7 @@ impl MpcContract { // the timeout. Ok(VerificationResult::Rejected(reason)) => { log!("verifier rejected quote for {account_id}: {reason}"); - FinalOutcome::Err(format!("verifier: {reason}")) + AttestationResult::Err(format!("verifier: {reason}")) } Ok(VerificationResult::Verified(report)) => { let pending = self.pending_attestations.get(&account_id).expect( @@ -523,17 +524,17 @@ impl MpcContract { // Post-DCAP checks operate on the verified report plus state held // here. The allowlist is read fresh — governance votes mid-flight // take effect. - match finish_verify(pending, &report, self.allowlist_fresh()) { + match verify_post_dcap_and_store(pending, &report, self.allowlist_fresh()) { Ok(()) => { self.tee_state.stored_attestations.insert( pending.tls_pk.clone(), VerifiedAttestation::from((pending.clone(), report)), ); - FinalOutcome::Ok + AttestationResult::Ok } Err(reason) => { log!("post-DCAP check failed for {account_id}: {reason}"); - FinalOutcome::Err(format!("post-DCAP: {reason}")) + AttestationResult::Err(format!("post-DCAP: {reason}")) } } } @@ -543,7 +544,7 @@ impl MpcContract { .pending_attestations .remove(&account_id) .expect("PendingAttestation must exist while resolve_verification holds the yield"); - if matches!(final_outcome, FinalOutcome::Err(_)) { + if matches!(final_outcome, AttestationResult::Err(_)) { refund_deposit(&account_id, pending.attached_deposit); } // `promise_yield_resume` must be the LAST host call in this receipt: @@ -569,11 +570,11 @@ impl MpcContract { pub fn on_attestation_verified( &mut self, account_id: AccountId, - #[callback_result] result: Result, + #[callback_result] result: Result, ) -> PromiseOrValue<()> { let reason = match result { - Ok(FinalOutcome::Ok) => return PromiseOrValue::Value(()), - Ok(FinalOutcome::Err(reason)) => reason, + Ok(AttestationResult::Ok) => return PromiseOrValue::Value(()), + Ok(AttestationResult::Err(reason)) => reason, Err(_promise_err) => { if let Some(pending) = self.pending_attestations.remove(&account_id) { refund_deposit(&account_id, pending.attached_deposit); @@ -595,7 +596,7 @@ impl MpcContract { } #[derive(BorshSerialize, BorshDeserialize)] -pub enum FinalOutcome { +pub enum AttestationResult { Ok, Err(String), } diff --git a/docs/localnet/tee-localnet.md b/docs/localnet/tee-localnet.md index 599d3c3eeb..07c32cba98 100644 --- a/docs/localnet/tee-localnet.md +++ b/docs/localnet/tee-localnet.md @@ -276,7 +276,7 @@ near transaction view-status network-config mpc-localnet ``` ``` -(ExecutionError("Smart contract panicked: Invalid TEE Remote Attestation: attestation verification failed: the allowed mpc image hashes list is empty" +(ExecutionError("Smart contract panicked: the submitted attestation failed verification, reason: Custom(\"the allowed mpc image hashes list is empty\")" ``` ### Vote Commands diff --git a/docs/running-an-mpc-node-in-tdx-external-guide.md b/docs/running-an-mpc-node-in-tdx-external-guide.md index e6413e02df..60c969e0ec 100644 --- a/docs/running-an-mpc-node-in-tdx-external-guide.md +++ b/docs/running-an-mpc-node-in-tdx-external-guide.md @@ -2062,8 +2062,7 @@ The error after `err=` is the NEAR runtime error. Common ones: If the transaction reaches execution and the contract panics, the node logs only the generic retry line above; the actual message lives in the transaction receipt. Find the tx on `https://testnet.nearblocks.io/address/` and open the failed `submit_participant_info` call — the error appears under the action's status / logs. The contract wraps the attestation-side error like this: ``` -Invalid TEE Remote Attestation: attestation verification failed: - the submitted attestation failed verification, reason: Custom("...") +the submitted attestation failed verification, reason: Custom("...") ``` The `reason` is the same `VerificationError` the client-side WARN reports (see section 1) — for example `Custom("the allowed mpc image hashes list is empty")`. Errors that **only** surface on-chain (because they're checked against the contract's allowed-measurements list, the contract's deposit logic, or the contract's caller assertion): From 7aaa981d5e250fd14bee20ff5ccd7a32cbc1244c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 3 Jul 2026 11:30:10 +0200 Subject: [PATCH 31/44] test(contract): fix TODO-format lint in ignored-test reason The #[ignore] reason string contained a bare 'TODO(#3730)' token, which the check-todo-format CI gate rejects (it requires TODO(#N): with a trailing colon). Reword to 'tracked in #3730'; the canonical TODO(#3730): comment above the test carries the reference --- crates/contract/tests/sandbox/tee_verifier.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 2ed83ab816..16e57bf52f 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -238,7 +238,7 @@ async fn submit_participant_info__should_clean_up_on_verifier_crash() -> Result< // fails fast and cheap, so `resolve_verification` completes at 1 TGas and this // re-tests the rejection path instead. Shares that setup with the (also pending) // Verified happy-path test. -#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; see TODO(#3730)"] +#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3730"] #[tokio::test] async fn submit_participant_info__should_clean_up_when_resolve_verification_runs_out_of_gas() -> Result<()> { From 77cb63e57cd58aee71fb2327a94f903c2a63d9f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 3 Jul 2026 11:43:51 +0200 Subject: [PATCH 32/44] docs(test-tee-verifier): use intra-doc links instead of bare backticks Convert linkable code references in the stub's doc comments to proper [`...`] intra-doc links (TestTeeVerifier::verify_quote, the StubResponse variants, VerificationResult::{Verified,Rejected}). Leave unlinkable references as prose: cross-crate non-deps (dcap_qvl, the real tee-verifier method), the mpc-contract crate name, and refs inside plain // comments that can't host intra-doc links. --- crates/test-tee-verifier/src/lib.rs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs index 91e2e149ab..eaafeebc2f 100644 --- a/crates/test-tee-verifier/src/lib.rs +++ b/crates/test-tee-verifier/src/lib.rs @@ -1,15 +1,12 @@ //! Test-only stub of the `tee-verifier` contract. //! -//! `verify_quote` ignores its inputs and returns a response fixed at init time, -//! instead of running real `dcap_qvl::verify`. This lets `mpc-contract` sandbox -//! tests drive every branch of the async attestation flow deterministically: -//! a `Verified` report (which the test supplies so it matches the fixture's -//! post-DCAP expectations), a `Rejected` verdict, or a panic (the no-verdict / +//! [`TestTeeVerifier::verify_quote`] ignores its inputs and returns a response +//! fixed at init time, instead of running real `dcap_qvl::verify`. This lets +//! `mpc-contract` sandbox tests drive every branch of the async attestation flow +//! deterministically: a [`StubResponse::Verified`] report (which the test +//! supplies so it matches the fixture's post-DCAP expectations), a +//! [`StubResponse::Rejected`] verdict, or a panic (the no-verdict / //! verifier-unreachable path). -//! -//! It speaks the same `tee-verifier-interface` Borsh DTOs and uses the same -//! `#[result_serializer(borsh)]` as the real verifier, so `mpc-contract` cannot -//! tell the two apart. use borsh::{BorshDeserialize, BorshSerialize}; use near_sdk::{env, near}; @@ -23,7 +20,8 @@ fn randomness_unsupported(_buf: &mut [u8]) -> Result<(), getrandom::Error> { #[cfg(target_arch = "wasm32")] getrandom::register_custom_getrandom!(randomness_unsupported); -/// What the stub's `verify_quote` should do, chosen by the test at deploy time. +/// What the stub's [`TestTeeVerifier::verify_quote`] should do, chosen by the +/// test at deploy time. #[expect(clippy::large_enum_variant)] // KEEP THE VARIANT ORDER IN SYNC with the `StubResponse` mirror in // `crates/contract/tests/sandbox/tee_verifier.rs`: the test serializes with that @@ -36,14 +34,14 @@ getrandom::register_custom_getrandom!(randomness_unsupported); derive(borsh::BorshSchema) )] pub enum StubResponse { - /// Return `VerificationResult::Verified` with this exact report. Tests that + /// Return [`VerificationResult::Verified`] with this exact report. Tests that /// want the post-DCAP checks to pass supply the report obtained from the /// real fixture quote. Verified(tee_verifier_interface::VerifiedReport), - /// Return `VerificationResult::Rejected` with this reason. + /// Return [`VerificationResult::Rejected`] with this reason. Rejected(String), /// Panic, simulating an unreachable / crashing verifier (the no-verdict - /// path that `mpc-contract` resolves via the yield timeout). + /// path that mpc-contract resolves via the yield timeout). Panic, } @@ -68,8 +66,9 @@ impl TestTeeVerifier { Self { response } } - /// Stub mirror of `tee_verifier::verify_quote`: ignores `quote`/`collateral` - /// and returns the canned response. Panics on `StubResponse::Panic`. + /// Stub mirror of the real `tee-verifier` contract's verify-quote method: + /// ignores the quote and collateral and returns the canned response. Panics + /// on [`StubResponse::Panic`]. #[result_serializer(borsh)] pub fn verify_quote( &self, From 32986dff8c1f7d4089b5a4011b37b8e2e2571ab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 3 Jul 2026 11:59:59 +0200 Subject: [PATCH 33/44] test(contract): share StubResponse via a types crate, drop the mirror StubResponse was declared twice (stub contract + test mirror) kept aligned by a KEEP-IN-SYNC comment and a discriminant-pinning test, because importing the #[near] stub crate as a dep breaks cargo test --all-features (duplicate contract-ABI symbol + abi-feature unification; see PR #3664). Extract it into a new plain-lib crate test-tee-verifier-types that both the stub and the contract's test binary depend on. A non-#[near] crate emits no contract-ABI symbol, so it can be a dev-dep of mpc-contract without the collision, giving a real single source of truth: the mirror, the sync comment, and the stub_response_discriminants test are removed. Verified in nix: cargo test --no-run --all-features -p mpc-contract links cleanly (the step that regressed in #3664), and the sandbox tee_verifier suite passes driven through the shared type. --- Cargo.lock | 10 +++++ Cargo.toml | 2 + crates/contract/Cargo.toml | 1 + crates/contract/tests/sandbox/tee_verifier.rs | 38 +------------------ crates/test-tee-verifier-types/Cargo.toml | 23 +++++++++++ crates/test-tee-verifier-types/src/lib.rs | 30 +++++++++++++++ crates/test-tee-verifier/Cargo.toml | 7 +++- crates/test-tee-verifier/src/lib.rs | 28 +------------- 8 files changed, 74 insertions(+), 65 deletions(-) create mode 100644 crates/test-tee-verifier-types/Cargo.toml create mode 100644 crates/test-tee-verifier-types/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index df155ccf90..dbc02c9305 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5881,6 +5881,7 @@ dependencies = [ "sha2 0.10.9", "signature", "tee-verifier-interface", + "test-tee-verifier-types", "test-utils", "thiserror 2.0.18", "threshold-signatures", @@ -11371,6 +11372,15 @@ dependencies = [ "getrandom 0.2.17", "near-sdk", "tee-verifier-interface", + "test-tee-verifier-types", +] + +[[package]] +name = "test-tee-verifier-types" +version = "3.13.0" +dependencies = [ + "borsh", + "tee-verifier-interface", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8b5791ebaf..31ee3427b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ members = [ "crates/test-parallel-contract", "crates/test-port-allocator", "crates/test-tee-verifier", + "crates/test-tee-verifier-types", "crates/test-utils", "crates/threshold-signatures", "crates/tls", @@ -77,6 +78,7 @@ tee-authority = { path = "crates/tee-authority" } tee-verifier-conversions = { path = "crates/tee-verifier-conversions" } tee-verifier-interface = { path = "crates/tee-verifier-interface" } test-port-allocator = { path = "crates/test-port-allocator" } +test-tee-verifier-types = { path = "crates/test-tee-verifier-types" } test-utils = { path = "crates/test-utils" } threshold-signatures = { path = "crates/threshold-signatures" } diff --git a/crates/contract/Cargo.toml b/crates/contract/Cargo.toml index e9d8f991a6..5eee20af17 100644 --- a/crates/contract/Cargo.toml +++ b/crates/contract/Cargo.toml @@ -139,6 +139,7 @@ rand_core = { workspace = true } rstest = { workspace = true } sha2 = { workspace = true } signature = { workspace = true } +test-tee-verifier-types = { workspace = true } test-utils = { workspace = true } threshold-signatures = { workspace = true } tokio = { workspace = true } diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 16e57bf52f..116ebeb6cf 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -25,32 +25,15 @@ use crate::sandbox::{ }, }; use anyhow::Result; -use borsh::BorshSerialize; use near_mpc_contract_interface::types::{self as dtos, Attestation}; use near_workspaces::{Account, Contract, Worker, network::Sandbox, types::NearToken}; +use test_tee_verifier_types::StubResponse; use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key, verified_report}; /// Blocks to fast-forward past the ~200-block yield-resume timeout so the /// runtime fires `on_attestation_verified`'s timeout branch. const YIELD_TIMEOUT_BLOCKS: u64 = 250; -/// Mirror of `test_tee_verifier::StubResponse`. Re-declared here (rather than -/// depending on the stub crate) so the test only needs its Borsh encoding to -/// initialize the deployed stub; the stub is a separate `#[near]` contract and -/// linking its crate into this test binary would collide on ABI symbols. -/// -/// KEEP THE VARIANT ORDER IN SYNC with `test_tee_verifier::StubResponse`: Borsh -/// encodes an enum as a u8 discriminant equal to the declaration index, so a -/// reorder on either side silently misroutes the response. `stub_response_discriminants` -/// below pins the indices so a divergence fails loudly. -#[expect(clippy::large_enum_variant)] -#[derive(BorshSerialize)] -enum StubResponse { - Verified(tee_verifier_interface::VerifiedReport), - Rejected(String), - Panic, -} - /// Deploys the stub verifier with the given response, initializes it, and votes /// it in as `mpc-contract`'s trusted verifier (all participants vote so the /// change crosses threshold). @@ -334,22 +317,3 @@ async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) - ); Ok(()) } - -/// Pins the Borsh discriminant of each [`StubResponse`] variant to its declaration -/// index. The deployed `test_tee_verifier::StubResponse` deserializes what this -/// mirror serializes, so a reorder on either side must fail loudly here rather -/// than silently misroute a response. `Verified` is index 0 by position (a -/// `VerifiedReport` fixture is not needed to guard the reorder that matters). -#[test] -fn stub_response_discriminants() { - assert_eq!( - borsh::to_vec(&StubResponse::Rejected(String::new())).unwrap()[0], - 1, - "Rejected must be Borsh discriminant 1" - ); - assert_eq!( - borsh::to_vec(&StubResponse::Panic).unwrap()[0], - 2, - "Panic must be Borsh discriminant 2" - ); -} diff --git a/crates/test-tee-verifier-types/Cargo.toml b/crates/test-tee-verifier-types/Cargo.toml new file mode 100644 index 0000000000..baa76ead72 --- /dev/null +++ b/crates/test-tee-verifier-types/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "test-tee-verifier-types" +version = { workspace = true } +license = { workspace = true } +edition = { workspace = true } + +# Wire types shared between the `test-tee-verifier` stub contract and the +# `mpc-contract` sandbox tests that drive it. A plain lib (no `#[near]`) so both +# a contract crate and a test binary can depend on it without the duplicate-ABI +# symbol / `--all-features` collision that importing the stub crate itself would +# cause (see docs / the mpc-contract sandbox tests). + +[features] +# Mirrors the stub's `abi` feature: derives the borsh schema on the wire types so +# the stub's ABI generation can include them. +abi = ["borsh/unstable__schema", "tee-verifier-interface/borsh-schema"] + +[dependencies] +borsh = { workspace = true } +tee-verifier-interface = { workspace = true } + +[lints] +workspace = true diff --git a/crates/test-tee-verifier-types/src/lib.rs b/crates/test-tee-verifier-types/src/lib.rs new file mode 100644 index 0000000000..634d0d1398 --- /dev/null +++ b/crates/test-tee-verifier-types/src/lib.rs @@ -0,0 +1,30 @@ +//! Wire types shared between the `test-tee-verifier` stub contract and the +//! `mpc-contract` sandbox tests that drive it. +//! +//! Kept in a plain (non-`#[near]`) crate so both a contract crate and a test +//! binary can depend on the same definition: importing the stub contract itself +//! would emit a duplicate contract-ABI symbol and unify its `abi` feature under +//! `cargo test --all-features`. + +use borsh::{BorshDeserialize, BorshSerialize}; + +/// What the stub verifier's verify-quote method should return, chosen by the +/// test at deploy time. +#[expect(clippy::large_enum_variant)] +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +#[cfg_attr( + all(feature = "abi", not(target_arch = "wasm32")), + derive(borsh::BorshSchema) +)] +pub enum StubResponse { + /// Return [`tee_verifier_interface::VerificationResult::Verified`] with this + /// exact report. Tests that want the post-DCAP checks to pass supply the + /// report obtained from the real fixture quote. + Verified(tee_verifier_interface::VerifiedReport), + /// Return [`tee_verifier_interface::VerificationResult::Rejected`] with this + /// reason. + Rejected(String), + /// Panic, simulating an unreachable / crashing verifier (the no-verdict path + /// that mpc-contract resolves via the yield timeout). + Panic, +} diff --git a/crates/test-tee-verifier/Cargo.toml b/crates/test-tee-verifier/Cargo.toml index f9612d5569..9a7a7beff8 100644 --- a/crates/test-tee-verifier/Cargo.toml +++ b/crates/test-tee-verifier/Cargo.toml @@ -17,12 +17,17 @@ crate-type = ["cdylib", "lib"] [features] # Enabled by `cargo near build` / `--all-features` for ABI generation, mirroring # the real `tee-verifier`: pulls in the borsh schema for the wire DTOs. -abi = ["borsh/unstable__schema", "tee-verifier-interface/borsh-schema"] +abi = [ + "borsh/unstable__schema", + "tee-verifier-interface/borsh-schema", + "test-tee-verifier-types/abi", +] [dependencies] borsh = { workspace = true } near-sdk = { workspace = true } tee-verifier-interface = { workspace = true } +test-tee-verifier-types = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] getrandom = { workspace = true, features = ["custom"] } diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs index eaafeebc2f..d36214b0e3 100644 --- a/crates/test-tee-verifier/src/lib.rs +++ b/crates/test-tee-verifier/src/lib.rs @@ -8,11 +8,10 @@ //! [`StubResponse::Rejected`] verdict, or a panic (the no-verdict / //! verifier-unreachable path). -use borsh::{BorshDeserialize, BorshSerialize}; use near_sdk::{env, near}; use tee_verifier_interface::{Collateral, QuoteBytes, VerificationResult, VerifierError}; +use test_tee_verifier_types::StubResponse; -// Match the real verifier's getrandom handling on wasm so the crate links. #[cfg(target_arch = "wasm32")] fn randomness_unsupported(_buf: &mut [u8]) -> Result<(), getrandom::Error> { Err(getrandom::Error::UNSUPPORTED) @@ -20,31 +19,6 @@ fn randomness_unsupported(_buf: &mut [u8]) -> Result<(), getrandom::Error> { #[cfg(target_arch = "wasm32")] getrandom::register_custom_getrandom!(randomness_unsupported); -/// What the stub's [`TestTeeVerifier::verify_quote`] should do, chosen by the -/// test at deploy time. -#[expect(clippy::large_enum_variant)] -// KEEP THE VARIANT ORDER IN SYNC with the `StubResponse` mirror in -// `crates/contract/tests/sandbox/tee_verifier.rs`: the test serializes with that -// copy and this contract deserializes with this one, so the Borsh discriminants -// (declaration index) must match. That mirror's `stub_response_discriminants` -// test pins the indices. -#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] -#[cfg_attr( - all(feature = "abi", not(target_arch = "wasm32")), - derive(borsh::BorshSchema) -)] -pub enum StubResponse { - /// Return [`VerificationResult::Verified`] with this exact report. Tests that - /// want the post-DCAP checks to pass supply the report obtained from the - /// real fixture quote. - Verified(tee_verifier_interface::VerifiedReport), - /// Return [`VerificationResult::Rejected`] with this reason. - Rejected(String), - /// Panic, simulating an unreachable / crashing verifier (the no-verdict - /// path that mpc-contract resolves via the yield timeout). - Panic, -} - #[derive(Debug)] #[near(contract_state)] pub struct TestTeeVerifier { From 040b50e8617d65343f25a535afebf0de546b0d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 3 Jul 2026 16:51:36 +0200 Subject: [PATCH 34/44] test(contract): tighten async attestation tests - revert_dstack_store unit tests: reuse create_node_id, compare the whole NodeAttestation (add PartialEq/Eq to NodeAttestation, VerifiedAttestation, MockAttestation, ValidatedDstackAttestation) instead of a field + a _ match - add a verified_report() test-utils fixture (mints the real report via verify_dcap_quote) and rework the OOG test onto the Verified path; it stays #[ignore]d pending the allowlist fixture setup (TODO(#3730)) - compress the sandbox tee_verifier tests: submit_dstack + setup_with_stub + assert_submission_cleaned_up helpers remove the repeated preamble/asserts; inline the trivial dstack_attestation()/tls_key() wrappers - switch the sandbox tests to unwrap() style (repo-wide majority) instead of Result<()>/? - assert the exact VerifierNotConfigured message via the error Display - gate/name has_pending_attestation via method_names::HAS_PENDING_ATTESTATION - extract TEST_COLLATERAL_STRING / SUBMIT_DEPOSIT consts; doc + intra-doc-link cleanups --- crates/contract/src/sandbox_test_methods.rs | 5 - crates/contract/src/tee/tee_state.rs | 39 +- .../tests/inprocess/attestation_submission.rs | 4 +- crates/contract/tests/sandbox/tee_verifier.rs | 356 +++++++----------- .../tests/sandbox/utils/mpc_contract.rs | 2 +- crates/mpc-attestation/src/attestation.rs | 8 +- .../src/method_names.rs | 4 + crates/test-tee-verifier/Cargo.toml | 9 - crates/test-tee-verifier/src/lib.rs | 14 +- crates/test-utils/src/attestation.rs | 20 +- 10 files changed, 181 insertions(+), 280 deletions(-) diff --git a/crates/contract/src/sandbox_test_methods.rs b/crates/contract/src/sandbox_test_methods.rs index f20652873b..63120a02ad 100644 --- a/crates/contract/src/sandbox_test_methods.rs +++ b/crates/contract/src/sandbox_test_methods.rs @@ -49,11 +49,6 @@ impl MpcContract { .expect("queue length must fit in u32 — bounded by MAX_PENDING_REQUEST_FAN_OUT") } - /// Whether an in-flight attestation entry exists for `account_id`. - /// - /// Used by the yield-resume sandbox tests to assert the pending entry is - /// cleaned up after a rejection, the yield timeout, or an out-of-gas - /// `resolve_verification`. pub fn has_pending_attestation(&self, account_id: AccountId) -> bool { self.pending_attestations.contains_key(&account_id) } diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index ca25733bc2..3f8171816e 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -66,7 +66,7 @@ pub enum TeeValidationResult { }, } -#[derive(Debug, BorshSerialize, BorshDeserialize)] +#[derive(Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) @@ -1533,12 +1533,9 @@ mod tests { // returns the displaced original wrapped in `UpdatedExistingParticipant`). const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); let mut tee_state = TeeState::default(); + let account_id = "alice.near".parse().unwrap(); let tls_public_key = bogus_ed25519_public_key(); - let original_node = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - }; + let original_node = create_node_id(&account_id, &tls_public_key); tee_state .verify_and_store_mock( original_node.clone(), @@ -1546,28 +1543,29 @@ mod tests { TEE_UPGRADE_DURATION, ) .expect("initial insertion should succeed"); - let updated_node = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - }; + let updated_node = create_node_id(&account_id, &tls_public_key); let insertion = tee_state .verify_and_store_mock(updated_node, MockAttestation::Valid, TEE_UPGRADE_DURATION) .expect("update should succeed"); - assert_matches!( - insertion, - ParticipantInsertion::UpdatedExistingParticipant(_) - ); + + let original_entry = NodeAttestation { + node_id: original_node, + verified_attestation: VerifiedAttestation::Mock(MockAttestation::Valid), + }; + let ParticipantInsertion::UpdatedExistingParticipant(displaced) = &insertion else { + panic!("expected an update, got {insertion:?}"); + }; + assert_eq!(*displaced, original_entry); // When: the store is reverted. tee_state.revert_dstack_store(&tls_public_key, insertion); - // Then: the original (displaced) entry is back in place. + // Then: the whole original entry is back in place. let stored = tee_state .stored_attestations .get(&tls_public_key) .expect("original entry must be restored"); - assert_eq!(stored.node_id, original_node); + assert_eq!(*stored, original_entry); } #[test] @@ -1575,12 +1573,9 @@ mod tests { // Given: a brand-new attestation for `alice` (no prior entry displaced). const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); let mut tee_state = TeeState::default(); + let account_id = "alice.near".parse().unwrap(); let tls_public_key = bogus_ed25519_public_key(); - let node = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - }; + let node = create_node_id(&account_id, &tls_public_key); let insertion = tee_state .verify_and_store_mock(node, MockAttestation::Valid, TEE_UPGRADE_DURATION) .expect("insertion should succeed"); diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index be06b4b748..abd0ea1cec 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -364,9 +364,7 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { assert_eq!(stored_before, stored_after); } -/// **Test that a `Dstack` submission is rejected when no verifier is configured.** The -/// async path has nowhere to offload DCAP verification, so it must fail up front (before -/// registering a yield) rather than leave a submission that can never resolve. +/// Test that a `Dstack` submission is rejected when no verifier is configured. #[test] fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() { // Given: a running contract with no TEE verifier voted in. diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 116ebeb6cf..a968a8fc7b 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -1,16 +1,16 @@ -//! Sandbox tests for the async `submit_participant_info` flow that offloads DCAP -//! verification to a separate `tee-verifier` contract. +//! Sandbox tests for the async [`submit_participant_info`] flow that offloads +//! DCAP verification to a separate tee-verifier contract. //! -//! These deploy the `test-tee-verifier` stub (which returns a test-chosen -//! `verify_quote` answer instead of running real `dcap-qvl`) and point -//! `mpc-contract` at it via `vote_tee_verifier_change`, then exercise each -//! resolution branch of the yield-resume flow: +//! Each test deploys the `test-tee-verifier` stub, whose verify-quote returns a +//! response the test picks instead of running real `dcap-qvl`, votes it in as the +//! trusted verifier via [`vote_tee_verifier_change`], and then covers one branch +//! of the yield-resume flow: //! //! - verifier not configured → submission rejected, nothing stored. -//! - `Rejected` → submission fails, deposit refunded, no stored attestation. +//! - [`StubResponse::Rejected`] → submission fails, deposit refunded, nothing stored. //! - no-verdict (stub panics) → the ~200-block yield timeout cleans up. -//! - `resolve_verification` runs out of gas → its receipt rolls back atomically -//! and the same ~200-block timeout cleans up (no half-committed state). +//! - out-of-gas resolve → the receipt rolls back atomically and the same timeout +//! cleans up (no half-committed state). #![allow(non_snake_case)] use crate::sandbox::{ @@ -24,16 +24,20 @@ use crate::sandbox::{ }, }, }; -use anyhow::Result; -use near_mpc_contract_interface::types::{self as dtos, Attestation}; +use mpc_contract::errors::TeeError; +use near_mpc_contract_interface::types as dtos; use near_workspaces::{Account, Contract, Worker, network::Sandbox, types::NearToken}; use test_tee_verifier_types::StubResponse; use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key, verified_report}; /// Blocks to fast-forward past the ~200-block yield-resume timeout so the -/// runtime fires `on_attestation_verified`'s timeout branch. +/// runtime fires the yield-callback's timeout branch. const YIELD_TIMEOUT_BLOCKS: u64 = 250; +/// Deposit attached to a Dstack submission: covers storage on success, fully +/// refunded on failure. +const SUBMIT_DEPOSIT: NearToken = NearToken::from_near(1); + /// Deploys the stub verifier with the given response, initializes it, and votes /// it in as `mpc-contract`'s trusted verifier (all participants vote so the /// change crosses threshold). @@ -42,35 +46,84 @@ async fn deploy_and_trust_stub( contract: &Contract, participants: &[Account], response: StubResponse, -) -> Result { - let stub = worker.dev_deploy(stub_tee_verifier_contract()).await?; +) { + let stub = worker + .dev_deploy(stub_tee_verifier_contract()) + .await + .unwrap(); stub.call("new") .args_borsh(response) .transact() - .await? - .into_result()?; + .await + .unwrap() + .into_result() + .unwrap(); - // The contract only consumes `candidate_account_id`; the hash is a voter - // commitment, so any agreed value works for the test. + // Unchecked against the stub; voters just need to agree on the same hash. let expected_code_hash = [7u8; 32]; for account in participants { - vote_tee_verifier_change(account, contract, stub.id(), expected_code_hash).await?; + vote_tee_verifier_change(account, contract, stub.id(), expected_code_hash) + .await + .unwrap(); + } +} + +async fn setup_with_stub( + response: StubResponse, + init_config: Option, +) -> (Worker, Contract, Account, NearToken) { + let mut builder = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .with_sandbox_test_methods(); + if let Some(init_config) = init_config { + builder = builder.with_init_config(init_config); } - Ok(stub) + let SandboxTestSetup { + worker, + mpc_signer_accounts, + contract, + .. + } = builder.build().await; + deploy_and_trust_stub(&worker, &contract, &mpc_signer_accounts, response).await; + + let submitter = mpc_signer_accounts[0].clone(); + let balance_before = submitter.view_account().await.unwrap().balance; + (worker, contract, submitter, balance_before) } -fn dstack_attestation() -> Attestation { - mock_dto_dstack_attestation() +async fn submit_dstack(submitter: &Account, contract: &Contract) { + let _ = submit_participant_info_with_deposit( + submitter, + contract, + &mock_dto_dstack_attestation(), + &p2p_tls_key().into(), + SUBMIT_DEPOSIT, + ) + .await + .unwrap(); } -fn tls_key() -> dtos::Ed25519PublicKey { - p2p_tls_key().into() +/// Asserts a failed submission left no stored attestation, no pending entry, and +/// refunded the deposit. +async fn assert_submission_cleaned_up( + contract: &Contract, + submitter: &Account, + balance_before: NearToken, +) { + let stored = get_participant_attestation(contract, &p2p_tls_key().into()) + .await + .unwrap(); + assert!(stored.is_none(), "nothing should be stored on failure"); + assert!( + !has_pending_attestation(contract, submitter.id()).await.unwrap(), + "the pending entry must be cleaned up" + ); + assert_deposit_refunded(submitter, balance_before).await; } #[tokio::test] -async fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() -> Result<()> -{ - // Given: a running contract with no verifier voted in. +async fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() { + // Given: no verifier voted in. let SandboxTestSetup { mpc_signer_accounts, contract, @@ -80,229 +133,101 @@ async fn submit_participant_info__should_reject_dstack_when_verifier_not_configu .build() .await; - // When: a participant submits a Dstack attestation. + // When: a Dstack attestation is submitted. let result = submit_participant_info( &mpc_signer_accounts[0], &contract, - &dstack_attestation(), - &tls_key(), + &mock_dto_dstack_attestation(), + &p2p_tls_key().into(), ) - .await?; + .await + .unwrap(); - // Then: it fails synchronously with the VerifierNotConfigured error (the - // early return in submit_dstack_attestation, before any yield is registered), - // and nothing is stored. Assert the specific message so an unrelated failure - // (gas, encoding) can't pass as success. + // Then: it fails synchronously (before any yield), so the error is on the tx + // result. let err = result .into_result() .expect_err("Dstack submit must fail when no verifier is configured") .to_string(); + let expected_panic = format!( + "Smart contract panicked: {}", + TeeError::VerifierNotConfigured + ); assert!( - err.contains("No TEE verifier is configured"), - "expected VerifierNotConfigured, got: {err}" + err.contains(&expected_panic), + "expected {expected_panic:?}, got: {err}" ); - let stored = get_participant_attestation(&contract, &tls_key()).await?; + let stored = get_participant_attestation(&contract, &p2p_tls_key().into()) + .await + .unwrap(); assert!(stored.is_none(), "no attestation should be stored"); - Ok(()) } #[tokio::test] -async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_rejection() --> Result<()> { - // Given: a contract whose trusted verifier always rejects. - let SandboxTestSetup { - worker, - mpc_signer_accounts, - contract, - .. - } = SandboxTestSetup::builder() - .with_protocols(ALL_PROTOCOLS) - .with_sandbox_test_methods() - .build() - .await; - deploy_and_trust_stub( - &worker, - &contract, - &mpc_signer_accounts, - StubResponse::Rejected("test rejection".to_string()), - ) - .await?; +async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_rejection() { + // Given: a verifier that always rejects. + let (_worker, contract, submitter, balance_before) = + setup_with_stub(StubResponse::Rejected("test rejection".to_string()), None).await; - // When: a participant submits a Dstack attestation with a 1 NEAR deposit. - let submitter = &mpc_signer_accounts[0]; - let balance_before = submitter.view_account().await?.balance; - let _ = submit_participant_info_with_deposit( - submitter, - &contract, - &dstack_attestation(), - &tls_key(), - NearToken::from_near(1), - ) - .await?; + // When: a Dstack attestation is submitted. + submit_dstack(&submitter, &contract).await; - // Then: nothing is stored, the pending entry is cleaned up, and the deposit - // is refunded. The rejection resolves in the verifier's response receipt (a - // later receipt than the original call), so the outcome is observable in - // state rather than on the original transaction's result. - let stored = get_participant_attestation(&contract, &tls_key()).await?; - assert!(stored.is_none(), "a rejected quote must not be stored"); - assert!( - !has_pending_attestation(&contract, submitter.id()).await?, - "the pending entry must be cleaned up on rejection" - ); - assert_deposit_refunded(submitter, balance_before).await?; - Ok(()) + // Then: the submission is cleaned up. The rejection resolves in the verifier's + // response receipt, so the outcome is observable in state, not the tx result. + assert_submission_cleaned_up(&contract, &submitter, balance_before).await; } #[tokio::test] -async fn submit_participant_info__should_clean_up_on_verifier_crash() -> Result<()> { - // Given: a contract whose trusted verifier panics (no verdict). - let SandboxTestSetup { - worker, - mpc_signer_accounts, - contract, - .. - } = SandboxTestSetup::builder() - .with_protocols(ALL_PROTOCOLS) - .with_sandbox_test_methods() - .build() - .await; - deploy_and_trust_stub( - &worker, - &contract, - &mpc_signer_accounts, - StubResponse::Panic, - ) - .await?; - - // When: a participant submits, the verifier crashes (no resume lands), and - // the chain advances past the ~200-block yield timeout so the runtime fires - // `on_attestation_verified`'s timeout branch. - let submitter = &mpc_signer_accounts[0]; - let balance_before = submitter.view_account().await?.balance; - // Unlike the rejection test, the outer-tx result isn't asserted here: the - // failure only resolves when the yield times out, which `near-workspaces` - // does not surface on the original `transact()`, so we assert state instead. - let _ = submit_participant_info_with_deposit( - submitter, - &contract, - &dstack_attestation(), - &tls_key(), - NearToken::from_near(1), - ) - .await?; - worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await?; - - // Then: nothing is stored, and the timeout cleanup actually committed: the - // pending entry is gone and the deposit refunded. (Guards the regression - // where the cleanup was rolled back by a panic in the same receipt, leaking - // the entry and locking the account out of resubmitting.) - let stored = get_participant_attestation(&contract, &tls_key()).await?; - assert!( - stored.is_none(), - "nothing should be stored when the verifier crashes" - ); - assert!( - !has_pending_attestation(&contract, submitter.id()).await?, - "the pending entry must be cleaned up after the yield timeout" - ); - assert_deposit_refunded(submitter, balance_before).await?; - Ok(()) +async fn submit_participant_info__should_clean_up_on_verifier_crash() { + // Given: a verifier that panics, so no resume lands. + let (worker, contract, submitter, balance_before) = + setup_with_stub(StubResponse::Panic, None).await; + + // When: a submission times out (no verdict within the yield window). + submit_dstack(&submitter, &contract).await; + worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await.unwrap(); + + // Then: the timeout cleans up. Guards the regression where cleanup was rolled + // back by a panic in the same receipt, leaking the entry and wedging the account. + assert_submission_cleaned_up(&contract, &submitter, balance_before).await; } -// TODO(#3730): un-ignore once the fixture allowlist setup lands. To make -// `resolve_verification` actually run out of gas, execution must reach the -// expensive RTMR3 replay inside `verify_post_dcap_and_store` before exhausting -// the 1 TGas budget. That requires the post-DCAP checks to get *past* the -// allowlist gate first, i.e. the contract must have the fixture's MPC image hash -// (`image_digest()`), launcher compose hash (`launcher_compose_digest()`), and -// measurements voted in, and the submitter must use the fixture keys so the -// report-data binding matches. With an empty allowlist (as here) the check -// fails fast and cheap, so `resolve_verification` completes at 1 TGas and this -// re-tests the rejection path instead. Shares that setup with the (also pending) -// Verified happy-path test. +// TODO(#3730): un-ignore once the fixture allowlist setup lands. To OOG, +// `resolve_verification` must reach the heavy RTMR3 replay, which needs the +// post-DCAP allowlist checks to pass first (fixture image/launcher hashes and +// measurements voted in, submitter using the fixture keys). With an empty +// allowlist the check fails fast and `resolve_verification` completes at 1 TGas, +// so this would re-test the rejection path. #[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3730"] #[tokio::test] -async fn submit_participant_info__should_clean_up_when_resolve_verification_runs_out_of_gas() --> Result<()> { - // Given: a contract configured with a `resolve_verification` gas budget far - // too small to run the post-DCAP work and resume the yield. The stub returns - // `Verified` so `resolve_verification` enters `verify_post_dcap_and_store` - // (the heavy RTMR3-replay path), which then exhausts the 1 TGas budget and - // rolls the whole receipt back. A `Rejected` response would not work here: - // its branch is light enough to complete even at 1 TGas. +async fn submit_participant_info__should_clean_up_when_resolve_verification_runs_out_of_gas() { + // Given: a Verified stub and a resolve gas budget too small for the post-DCAP + // work, so that branch OOGs and rolls back. (Rejected is too light to OOG.) let init_config = dtos::InitConfig { resolve_verification_tera_gas: Some(1), ..Default::default() }; - let SandboxTestSetup { - worker, - mpc_signer_accounts, - contract, - .. - } = SandboxTestSetup::builder() - .with_protocols(ALL_PROTOCOLS) - .with_sandbox_test_methods() - .with_init_config(init_config) - .build() - .await; - deploy_and_trust_stub( - &worker, - &contract, - &mpc_signer_accounts, - StubResponse::Verified(verified_report()), - ) - .await?; + let (worker, contract, submitter, balance_before) = + setup_with_stub(StubResponse::Verified(verified_report()), Some(init_config)).await; - // When: a participant submits. The verifier answers, but `resolve_verification` - // runs out of gas before `promise_yield_resume`, so its whole receipt (the - // pending-entry removal and the refund included) rolls back and the yield is - // never resumed. - let submitter = &mpc_signer_accounts[0]; - let balance_before = submitter.view_account().await?.balance; - let _ = submit_participant_info_with_deposit( - submitter, - &contract, - &dstack_attestation(), - &tls_key(), - NearToken::from_near(1), - ) - .await?; + // When: a submission is made; resolve rolls back rather than resuming. + submit_dstack(&submitter, &contract).await; - // Distinguish this path from the rejection test: because - // `resolve_verification` rolled back rather than resuming, the pending entry - // is still present here. The rejection path would have removed it already. + // Then: unlike the rejection path, the entry is still pending before the + // timeout; the timeout then cleans up, proving an atomic rollback of a partial + // resolve receipt cannot wedge the account. assert!( - has_pending_attestation(&contract, submitter.id()).await?, + has_pending_attestation(&contract, submitter.id()).await.unwrap(), "pending entry must survive an OOG resolve_verification (cleanup is left to the timeout)" ); - - // Advancing past the ~200-block window fires `on_attestation_verified`'s - // timeout branch, which is what actually cleans up in this path. - worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await?; - - // Then: an out-of-gas `resolve_verification` is recovered like an unreachable - // verifier: nothing stored, the pending entry cleaned up by the timeout - // branch, and the deposit refunded. This is the guarantee that a partial - // `resolve_verification` receipt cannot leave a refunded-but-still-pending - // entry: the receipt is atomic, so the account is not wedged. - let stored = get_participant_attestation(&contract, &tls_key()).await?; - assert!( - stored.is_none(), - "nothing should be stored when resolve_verification runs out of gas" - ); - assert!( - !has_pending_attestation(&contract, submitter.id()).await?, - "the pending entry must be cleaned up by the yield timeout after an OOG resolve_verification" - ); - assert_deposit_refunded(submitter, balance_before).await?; - Ok(()) + worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await.unwrap(); + assert_submission_cleaned_up(&contract, &submitter, balance_before).await; } -/// Asserts the full 1 NEAR storage deposit was returned: the net spend since -/// `balance_before` is only gas, well under any fraction of the deposit. -async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) -> Result<()> { - let balance_after = account.view_account().await?.balance; +/// Asserts the full 1 NEAR storage deposit was returned: the net spend is only +/// gas, well under any fraction of the deposit. +async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) { + let balance_after = account.view_account().await.unwrap().balance; // Raw subtraction (not `saturating_sub`): if the contract over-refunds so // `balance_after > balance_before`, this underflows and panics rather than // clamping to 0 and silently passing. @@ -315,5 +240,4 @@ async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) - net_spent < gas_ceiling, "deposit should be fully refunded (net spent {net_spent} yoctoNEAR should be gas-only, < {gas_ceiling})" ); - Ok(()) } diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index ef6f5e484e..c36fcaf369 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -80,7 +80,7 @@ pub async fn has_pending_attestation( account_id: &AccountId, ) -> anyhow::Result { Ok(contract - .view("has_pending_attestation") + .view(method_names::HAS_PENDING_ATTESTATION) .args_json(serde_json::json!({ "account_id": account_id })) .await? .json()?) diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 1fd671e6a9..a8ea3b12f0 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -34,7 +34,7 @@ pub enum Attestation { Mock(MockAttestation), } -#[derive(Clone, Debug, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) @@ -91,7 +91,9 @@ impl AcceptedAttestation { } #[expect(clippy::large_enum_variant)] -#[derive(Debug, Default, Clone, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] +#[derive( + Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, BorshDeserialize, BorshSerialize, +)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) @@ -195,7 +197,7 @@ impl MockAttestation { } } -#[derive(Clone, Debug, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index c2e14008e5..3e63a7a266 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -97,6 +97,10 @@ pub const OS_MEASUREMENT_VOTES: &str = "os_measurement_votes"; pub const ALLOWED_OS_MEASUREMENTS: &str = "allowed_os_measurements"; pub const MIGRATION_INFO: &str = "migration_info"; +// Sandbox-test-only methods (gated behind the contract's `sandbox-test-methods` +// feature; never in the production wasm). +pub const HAS_PENDING_ATTESTATION: &str = "has_pending_attestation"; + // Deprecated methods #[deprecated(note = "https://github.com/near/mpc/issues/3079")] pub const REGISTER_FOREIGN_CHAIN_CONFIG: &str = "register_foreign_chain_config"; diff --git a/crates/test-tee-verifier/Cargo.toml b/crates/test-tee-verifier/Cargo.toml index 9a7a7beff8..1bfdf1d749 100644 --- a/crates/test-tee-verifier/Cargo.toml +++ b/crates/test-tee-verifier/Cargo.toml @@ -4,19 +4,10 @@ version = { workspace = true } license = { workspace = true } edition = { workspace = true } -# A test-only stub of the `tee-verifier` contract: `verify_quote` returns a -# response the test chose at init, instead of running real `dcap-qvl`. Lets the -# `mpc-contract` sandbox tests drive every branch of the async attestation flow -# (Verified / Rejected / post-DCAP failure / no-verdict) deterministically. -# Speaks the same `tee-verifier-interface` Borsh DTOs as the real verifier, so -# `mpc-contract` cannot tell them apart. - [lib] crate-type = ["cdylib", "lib"] [features] -# Enabled by `cargo near build` / `--all-features` for ABI generation, mirroring -# the real `tee-verifier`: pulls in the borsh schema for the wire DTOs. abi = [ "borsh/unstable__schema", "tee-verifier-interface/borsh-schema", diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs index d36214b0e3..2654507109 100644 --- a/crates/test-tee-verifier/src/lib.rs +++ b/crates/test-tee-verifier/src/lib.rs @@ -1,12 +1,7 @@ //! Test-only stub of the `tee-verifier` contract. //! -//! [`TestTeeVerifier::verify_quote`] ignores its inputs and returns a response -//! fixed at init time, instead of running real `dcap_qvl::verify`. This lets -//! `mpc-contract` sandbox tests drive every branch of the async attestation flow -//! deterministically: a [`StubResponse::Verified`] report (which the test -//! supplies so it matches the fixture's post-DCAP expectations), a -//! [`StubResponse::Rejected`] verdict, or a panic (the no-verdict / -//! verifier-unreachable path). +//! [`TestTeeVerifier::verify_quote`] returns a [`StubResponse`] fixed at init +//! time instead of running real `dcap_qvl::verify`. use near_sdk::{env, near}; use tee_verifier_interface::{Collateral, QuoteBytes, VerificationResult, VerifierError}; @@ -40,9 +35,8 @@ impl TestTeeVerifier { Self { response } } - /// Stub mirror of the real `tee-verifier` contract's verify-quote method: - /// ignores the quote and collateral and returns the canned response. Panics - /// on [`StubResponse::Panic`]. + /// Ignores its inputs and returns the configured response, panicking on + /// [`StubResponse::Panic`]. #[result_serializer(borsh)] pub fn verify_quote( &self, diff --git a/crates/test-utils/src/attestation.rs b/crates/test-utils/src/attestation.rs index 22c129f38e..105186314b 100644 --- a/crates/test-utils/src/attestation.rs +++ b/crates/test-utils/src/attestation.rs @@ -7,8 +7,10 @@ use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash, NodeIma use near_mpc_contract_interface::types::HexVec; use serde_json::Value; use sha2::{Digest, Sha256}; +use tee_verifier_interface::VerifiedReport; pub const TEST_TCB_INFO_STRING: &str = include_str!("../assets/tcb_info.json"); +pub const TEST_COLLATERAL_STRING: &str = include_str!("../assets/collateral.json"); pub const TEST_APP_COMPOSE_STRING: &str = include_str!("../assets/app_compose.json"); pub const TEST_APP_COMPOSE_WITH_SERVICES_STRING: &str = include_str!("../assets/app_compose_with_services.json"); @@ -58,8 +60,7 @@ pub fn image_digest() -> NodeImageHash { } pub fn collateral() -> Value { - let quote_collateral_json_string = include_str!("../assets/collateral.json"); - quote_collateral_json_string + TEST_COLLATERAL_STRING .parse() .expect("Quote collateral file is a valid json.") } @@ -98,8 +99,7 @@ pub fn near_account_key() -> near_sdk::PublicKey { pub fn mock_dstack_attestation() -> Attestation { let quote = quote(); - let collateral_json_string = include_str!("../assets/collateral.json"); - let collateral = mpc_attestation::collateral::collateral_from_str(collateral_json_string) + let collateral = mpc_attestation::collateral::collateral_from_str(TEST_COLLATERAL_STRING) .expect("collateral.json is valid collateral"); let tcb_info: TcbInfo = serde_json::from_str(TEST_TCB_INFO_STRING).unwrap(); @@ -108,13 +108,12 @@ pub fn mock_dstack_attestation() -> Attestation { } /// The [`VerifiedReport`] the real `tee-verifier` would return for the fixture -/// quote. Minted here by running the DCAP step at [`VALID_ATTESTATION_TIMESTAMP`] -/// (when the fixture collateral is valid), so tests can feed it to the stub -/// verifier's `Verified` response and drive the contract's post-DCAP path. -pub fn verified_report() -> tee_verifier_interface::VerifiedReport { +/// quote, produced by running the DCAP step at [`VALID_ATTESTATION_TIMESTAMP`] +/// (when the fixture collateral is valid). +pub fn verified_report() -> VerifiedReport { let dstack = DstackAttestation::new( quote(), - mpc_attestation::collateral::collateral_from_str(include_str!("../assets/collateral.json")) + mpc_attestation::collateral::collateral_from_str(TEST_COLLATERAL_STRING) .expect("collateral.json is valid collateral"), serde_json::from_str(TEST_TCB_INFO_STRING).unwrap(), ); @@ -125,8 +124,7 @@ pub fn verified_report() -> tee_verifier_interface::VerifiedReport { pub fn mock_dto_dstack_attestation() -> near_mpc_contract_interface::types::Attestation { let quote = HexVec::from(Vec::from(quote())); - let collateral_json_string = include_str!("../assets/collateral.json"); - let collateral = serde_json::from_str(collateral_json_string).unwrap(); + let collateral = serde_json::from_str(TEST_COLLATERAL_STRING).unwrap(); let tcb_info: near_mpc_contract_interface::types::TcbInfo = serde_json::from_str(TEST_TCB_INFO_STRING).unwrap(); From 16310cb16daa1712ed97d66f5a8f8d4c4d4ec365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 13 Jul 2026 11:53:23 +0200 Subject: [PATCH 35/44] test(contract): port async attestation tests to the no-yield design The tests were written against the old yield-resume resolve_verification (pending_attestations + on_attestation_verified yield-callback + ~200-block timeout). #3766 replaced that with a plain promise chain (verify_quote -> .then(resolve_verification)) whose failures refund and fire a separate fail_attestation_submission receipt, with no pending state and no timeout. Rewrite the sandbox tests to match: - tee_verifier.rs: drop YIELD_TIMEOUT_BLOCKS / fast_forward and the pending-entry assertions; observe failures via the chain's receipt outcomes (ExecutionFinalResult::failures) instead of a queryable pending state. The verifier-crash test now expects an immediate VerifierUnavailable rather than a timeout cleanup. Keep the Verified happy-path and the OOG-resolve test #[ignore]d (they need fixture-allowlist + signer-key setup to reach a successful store) and point them at #3738 rather than the wrong #3730. - Remove has_pending_attestation (it read the removed pending_attestations map) from sandbox_test_methods.rs, its sandbox helper, and the HAS_PENDING_ATTESTATION method-name constant. - docs/design/attestation-verifier-contract.md: rewrite the submission-flow, handling-failures, state, API, and testing sections to the promise-chain design. Also reflow one pre-existing rustfmt violation in participants_gas.rs. --- crates/contract/src/sandbox_test_methods.rs | 6 +- .../tests/sandbox/participants_gas.rs | 11 +- crates/contract/tests/sandbox/tee_verifier.rs | 200 +++++--- .../tests/sandbox/utils/mpc_contract.rs | 11 - .../src/method_names.rs | 4 - docs/design/attestation-verifier-contract.md | 461 ++++++++---------- 6 files changed, 344 insertions(+), 349 deletions(-) diff --git a/crates/contract/src/sandbox_test_methods.rs b/crates/contract/src/sandbox_test_methods.rs index 63120a02ad..28997cd7af 100644 --- a/crates/contract/src/sandbox_test_methods.rs +++ b/crates/contract/src/sandbox_test_methods.rs @@ -11,7 +11,7 @@ use crate::MpcContract; use crate::primitives::ckd::CKDRequest; use crate::primitives::signature::SignatureRequest; -use near_sdk::{AccountId, near}; +use near_sdk::near; // Import the generated extension trait from near use crate::MpcContractExt; @@ -48,8 +48,4 @@ impl MpcContract { u32::try_from(len) .expect("queue length must fit in u32 — bounded by MAX_PENDING_REQUEST_FAN_OUT") } - - pub fn has_pending_attestation(&self, account_id: AccountId) -> bool { - self.pending_attestations.contains_key(&account_id) - } } diff --git a/crates/contract/tests/sandbox/participants_gas.rs b/crates/contract/tests/sandbox/participants_gas.rs index 4a56c67b31..f07e1a743a 100644 --- a/crates/contract/tests/sandbox/participants_gas.rs +++ b/crates/contract/tests/sandbox/participants_gas.rs @@ -289,8 +289,15 @@ async fn setup_test_env_with_state(n_participants: usize, running_state: bool) - let keyset = Keyset::new(EpochId::new(1), vec![key]); let domains = vec![domain]; let next_domain_id = domains.len() as u64 + 1; - init_contract_running(&contract, domains, next_domain_id, keyset, threshold_params, None) - .await; + init_contract_running( + &contract, + domains, + next_domain_id, + keyset, + threshold_params, + None, + ) + .await; } else { init_contract(&contract, threshold_params, None).await; } diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index a968a8fc7b..9d9c2dc5d5 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -4,13 +4,24 @@ //! Each test deploys the `test-tee-verifier` stub, whose verify-quote returns a //! response the test picks instead of running real `dcap-qvl`, votes it in as the //! trusted verifier via [`vote_tee_verifier_change`], and then covers one branch -//! of the yield-resume flow: +//! of the promise-chain flow. //! -//! - verifier not configured → submission rejected, nothing stored. -//! - [`StubResponse::Rejected`] → submission fails, deposit refunded, nothing stored. -//! - no-verdict (stub panics) → the ~200-block yield timeout cleans up. -//! - out-of-gas resolve → the receipt rolls back atomically and the same timeout -//! cleans up (no half-committed state). +//! A Dstack submission spawns `verify_quote` on the trusted verifier with +//! [`MpcContract::resolve_verification`] chained as its callback. There is no +//! yield-resume and no timeout: [`resolve_verification`] settles every outcome +//! synchronously within the same chain. +//! +//! - verifier not configured → the submit tx fails synchronously with +//! [`TeeError::VerifierNotConfigured`], nothing stored. +//! - [`StubResponse::Rejected`] → [`resolve_verification`] refunds the deposit and +//! fires `fail_attestation_submission`, which panics in a separate receipt to +//! fail the submitter's transaction; nothing stored. +//! - stub panics (verifier unreachable) → the callback observes a failed promise, +//! resolves to [`TeeError::VerifierUnavailable`], and fails the same way. +//! +//! On failure the top-level submit call still returns its chained promise, so the +//! failure surfaces on the chain's receipt outcomes +//! ([`ExecutionFinalResult::failures`]), not on the top-level tx result. #![allow(non_snake_case)] use crate::sandbox::{ @@ -19,21 +30,19 @@ use crate::sandbox::{ consts::ALL_PROTOCOLS, contract_build::stub_tee_verifier_contract, mpc_contract::{ - get_participant_attestation, has_pending_attestation, submit_participant_info, + get_participant_attestation, submit_participant_info, submit_participant_info_with_deposit, vote_tee_verifier_change, }, }, }; use mpc_contract::errors::TeeError; use near_mpc_contract_interface::types as dtos; -use near_workspaces::{Account, Contract, Worker, network::Sandbox, types::NearToken}; +use near_workspaces::{ + Account, Contract, Worker, network::Sandbox, result::ExecutionFinalResult, types::NearToken, +}; use test_tee_verifier_types::StubResponse; use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key, verified_report}; -/// Blocks to fast-forward past the ~200-block yield-resume timeout so the -/// runtime fires the yield-callback's timeout branch. -const YIELD_TIMEOUT_BLOCKS: u64 = 250; - /// Deposit attached to a Dstack submission: covers storage on success, fully /// refunded on failure. const SUBMIT_DEPOSIT: NearToken = NearToken::from_near(1); @@ -72,9 +81,7 @@ async fn setup_with_stub( response: StubResponse, init_config: Option, ) -> (Worker, Contract, Account, NearToken) { - let mut builder = SandboxTestSetup::builder() - .with_protocols(ALL_PROTOCOLS) - .with_sandbox_test_methods(); + let mut builder = SandboxTestSetup::builder().with_protocols(ALL_PROTOCOLS); if let Some(init_config) = init_config { builder = builder.with_init_config(init_config); } @@ -91,8 +98,8 @@ async fn setup_with_stub( (worker, contract, submitter, balance_before) } -async fn submit_dstack(submitter: &Account, contract: &Contract) { - let _ = submit_participant_info_with_deposit( +async fn submit_dstack(submitter: &Account, contract: &Contract) -> ExecutionFinalResult { + submit_participant_info_with_deposit( submitter, contract, &mock_dto_dstack_attestation(), @@ -100,24 +107,36 @@ async fn submit_dstack(submitter: &Account, contract: &Contract) { SUBMIT_DEPOSIT, ) .await - .unwrap(); + .unwrap() } -/// Asserts a failed submission left no stored attestation, no pending entry, and -/// refunded the deposit. -async fn assert_submission_cleaned_up( +/// Asserts a Dstack submission failed on the chain and left no committed state: +/// the failure surfaces on a receipt (`fail_attestation_submission` panics in its +/// own receipt), carries `expected_error`, nothing is stored, and the deposit is +/// refunded. +async fn assert_submission_failed_cleanly( + result: &ExecutionFinalResult, contract: &Contract, submitter: &Account, balance_before: NearToken, + expected_error: &TeeError, ) { + let failures = result.failures(); + assert!( + !failures.is_empty(), + "expected the promise chain to fail on a receipt, got: {result:#?}" + ); + let rendered = format!("{failures:?}"); + let expected = expected_error.to_string(); + assert!( + rendered.contains(&expected), + "expected a receipt failure containing {expected:?}, got: {rendered}" + ); + let stored = get_participant_attestation(contract, &p2p_tls_key().into()) .await .unwrap(); assert!(stored.is_none(), "nothing should be stored on failure"); - assert!( - !has_pending_attestation(contract, submitter.id()).await.unwrap(), - "the pending entry must be cleaned up" - ); assert_deposit_refunded(submitter, balance_before).await; } @@ -143,8 +162,8 @@ async fn submit_participant_info__should_reject_dstack_when_verifier_not_configu .await .unwrap(); - // Then: it fails synchronously (before any yield), so the error is on the tx - // result. + // Then: it fails synchronously (before any cross-contract call), so the error + // is on the top-level tx result, not a later receipt. let err = result .into_result() .expect_err("Dstack submit must fail when no verifier is configured") @@ -170,58 +189,121 @@ async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_re setup_with_stub(StubResponse::Rejected("test rejection".to_string()), None).await; // When: a Dstack attestation is submitted. - submit_dstack(&submitter, &contract).await; + let result = submit_dstack(&submitter, &contract).await; - // Then: the submission is cleaned up. The rejection resolves in the verifier's - // response receipt, so the outcome is observable in state, not the tx result. - assert_submission_cleaned_up(&contract, &submitter, balance_before).await; + // Then: resolve_verification refunds and fails the submission in a separate + // receipt; the failure is on the chain, not the top-level tx result. The + // stub wraps the reason in `VerifierError::DcapVerification`, whose Display + // prefixes "dcap verification failed: ". + assert_submission_failed_cleanly( + &result, + &contract, + &submitter, + balance_before, + &TeeError::QuoteRejected { + reason: "dcap verification failed: test rejection".to_string(), + }, + ) + .await; } #[tokio::test] -async fn submit_participant_info__should_clean_up_on_verifier_crash() { - // Given: a verifier that panics, so no resume lands. - let (worker, contract, submitter, balance_before) = +async fn submit_participant_info__should_fail_and_store_nothing_on_verifier_crash() { + // Given: a verifier that panics, so the verify_quote promise fails. + let (_worker, contract, submitter, balance_before) = setup_with_stub(StubResponse::Panic, None).await; - // When: a submission times out (no verdict within the yield window). - submit_dstack(&submitter, &contract).await; - worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await.unwrap(); + // When: a Dstack attestation is submitted. + let result = submit_dstack(&submitter, &contract).await; - // Then: the timeout cleans up. Guards the regression where cleanup was rolled - // back by a panic in the same receipt, leaking the entry and wedging the account. - assert_submission_cleaned_up(&contract, &submitter, balance_before).await; + // Then: the callback sees a failed promise, resolves to VerifierUnavailable, + // refunds, and fails the submission in a separate receipt. No timeout: the + // outcome settles synchronously within the same chain. + assert_submission_failed_cleanly( + &result, + &contract, + &submitter, + balance_before, + &TeeError::VerifierUnavailable, + ) + .await; } -// TODO(#3730): un-ignore once the fixture allowlist setup lands. To OOG, -// `resolve_verification` must reach the heavy RTMR3 replay, which needs the -// post-DCAP allowlist checks to pass first (fixture image/launcher hashes and -// measurements voted in, submitter using the fixture keys). With an empty -// allowlist the check fails fast and `resolve_verification` completes at 1 TGas, -// so this would re-test the rejection path. -#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3730"] +// TODO(#3738): un-ignore once the fixture allowlist setup lands. A Verified +// verdict routes through `verify_post_dcap_and_store`, whose allowlist checks +// (fixture image/launcher hashes and measurements voted in, submitter using the +// fixture keys) must pass before the attestation is stored. With an empty +// allowlist the post-DCAP check fails and the submission is rejected instead of +// stored, so the happy path cannot be exercised here yet. +#[ignore = "needs fixture allowlist setup to pass the post-DCAP checks; tracked in #3738"] #[tokio::test] -async fn submit_participant_info__should_clean_up_when_resolve_verification_runs_out_of_gas() { +async fn submit_participant_info__should_store_attestation_on_verified_quote() { + // Given: a verifier that returns the report the real verifier would produce + // for the fixture quote. + let (_worker, contract, submitter, balance_before) = + setup_with_stub(StubResponse::Verified(verified_report()), None).await; + + // When: a Dstack attestation is submitted. + let result = submit_dstack(&submitter, &contract).await; + + // Then: the chain succeeds and the attestation is stored; storage is charged + // and the excess deposit refunded (net spend is storage + gas, well under the + // full deposit). + assert!( + result.failures().is_empty(), + "the verified submission chain must succeed, got: {result:#?}" + ); + let stored = get_participant_attestation(&contract, &p2p_tls_key().into()) + .await + .unwrap(); + assert!(stored.is_some(), "a verified attestation must be stored"); + let balance_after = submitter.view_account().await.unwrap().balance; + assert!( + balance_after < balance_before, + "storage must be charged from the attached deposit" + ); +} + +// TODO(#3738): un-ignore once the fixture allowlist setup lands. To OOG, +// `resolve_verification` must reach the heavy RTMR3 replay in the post-DCAP +// checks, which needs the allowlist populated and the submitter using the fixture +// keys. With an empty allowlist the post-DCAP check fails fast and +// `resolve_verification` completes well under 1 TGas, re-testing the rejection +// path instead. Under the promise-chain model an OOG rolls the whole callback +// receipt back atomically: nothing is stored, the runtime refunds the attached +// deposit to the predecessor, and `fail_attestation_submission` never fires, so +// the chain still surfaces a failed receipt. No timeout is involved. +#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3738"] +#[tokio::test] +async fn submit_participant_info__should_fail_and_store_nothing_when_resolve_verification_runs_out_of_gas() + { // Given: a Verified stub and a resolve gas budget too small for the post-DCAP - // work, so that branch OOGs and rolls back. (Rejected is too light to OOG.) + // work, so that callback OOGs and rolls back atomically. let init_config = dtos::InitConfig { resolve_verification_tera_gas: Some(1), ..Default::default() }; - let (worker, contract, submitter, balance_before) = + let (_worker, contract, submitter, balance_before) = setup_with_stub(StubResponse::Verified(verified_report()), Some(init_config)).await; - // When: a submission is made; resolve rolls back rather than resuming. - submit_dstack(&submitter, &contract).await; + // When: a Dstack attestation is submitted. + let result = submit_dstack(&submitter, &contract).await; - // Then: unlike the rejection path, the entry is still pending before the - // timeout; the timeout then cleans up, proving an atomic rollback of a partial - // resolve receipt cannot wedge the account. + // Then: the callback receipt fails wholesale, nothing is stored, and the + // runtime refunds the attached deposit. Proves an OOG in resolve cannot commit + // partial state. + assert!( + !result.failures().is_empty(), + "an OOG resolve_verification must fail the chain, got: {result:#?}" + ); + let stored = get_participant_attestation(&contract, &p2p_tls_key().into()) + .await + .unwrap(); assert!( - has_pending_attestation(&contract, submitter.id()).await.unwrap(), - "pending entry must survive an OOG resolve_verification (cleanup is left to the timeout)" + stored.is_none(), + "nothing should be stored on an OOG resolve" ); - worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await.unwrap(); - assert_submission_cleaned_up(&contract, &submitter, balance_before).await; + assert_deposit_refunded(&submitter, balance_before).await; } /// Asserts the full 1 NEAR storage deposit was returned: the net spend is only diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index c36fcaf369..c481aae041 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -75,17 +75,6 @@ pub async fn submit_participant_info_with_deposit( .await?) } -pub async fn has_pending_attestation( - contract: &Contract, - account_id: &AccountId, -) -> anyhow::Result { - Ok(contract - .view(method_names::HAS_PENDING_ATTESTATION) - .args_json(serde_json::json!({ "account_id": account_id })) - .await? - .json()?) -} - pub async fn vote_tee_verifier_change( account: &Account, contract: &Contract, diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index 3e63a7a266..c2e14008e5 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -97,10 +97,6 @@ pub const OS_MEASUREMENT_VOTES: &str = "os_measurement_votes"; pub const ALLOWED_OS_MEASUREMENTS: &str = "allowed_os_measurements"; pub const MIGRATION_INFO: &str = "migration_info"; -// Sandbox-test-only methods (gated behind the contract's `sandbox-test-methods` -// feature; never in the production wasm). -pub const HAS_PENDING_ATTESTATION: &str = "has_pending_attestation"; - // Deprecated methods #[deprecated(note = "https://github.com/near/mpc/issues/3079")] pub const REGISTER_FOREIGN_CHAIN_CONFIG: &str = "register_foreign_chain_config"; diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 99f81572bb..f8aa29f3ac 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -59,92 +59,93 @@ flowchart LR ### Submission flow -`mpc-contract`'s [`submit_participant_info`][submit-participant-info] becomes asynchronous for Dstack attestations and uses the same yield-resume pattern already in place for `sign`, `request_app_private_key`, and `verify_foreign_transaction`. The method registers a yielded promise via [`env::promise_yield_create`][promise-yield-create] (going through the contract's existing [`enqueue_yield_request`][enqueue-yield-request] helper), stashes the resulting `data_id` in `pending_attestations`, and fires a cross-contract call to `tee-verifier::verify_quote` whose own `.then` callback (`resolve_verification`) bridges the verifier's response into a `promise_yield_resume`. Because `verify_quote` returns a `VerificationResult` value rather than failing the receipt (see [§Why a rejection is a value](#why-a-rejection-is-a-value-not-a-failed-receipt)), `resolve_verification`'s `#[callback_result]` distinguishes three outcomes: `Verified` runs the post-DCAP checks (RTMR3 replay, app-compose validation, measurement allowlist matching, report-data binding) and resumes; `Rejected` refunds and resumes **immediately** with the reason; and only `Err(PromiseError::Failed)` — the verifier unreachable or crashed — returns early, leaving cleanup to the ~200-block timeout. The yield-callback `on_attestation_verified` stays trivial, the same shape as the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]: it returns the resumed outcome to the caller, and only on the timeout (`Err(PromiseError::Failed)`) does it remove the pending entry and schedule a refund. Keeping the heavy work in `resolve_verification` makes an OOG in the yield-callback implausible, and an OOG in `resolve_verification` rolls back its whole receipt so the timeout still fires the trivial callback for cleanup. The exact branch-by-branch behavior is in [§Handling failures](#handling-failures) below. +`mpc-contract`'s [`submit_participant_info`][submit-participant-info] becomes asynchronous for Dstack attestations, but without yield-resume: it settles the submission entirely inside a single cross-contract promise chain. The method returns `Result, Error>`. A `Mock` attestation is verified and stored synchronously and returns `Ok(PromiseOrValue::Value(()))`. A `Dstack` attestation is handed to `submit_dstack_attestation`, which builds a `Promise` that calls `tee-verifier::verify_quote` and chains `resolve_verification` as its `.then` callback; the method returns `Ok(PromiseOrValue::Promise(...))`. The attached deposit is forwarded onto the callback via `.with_attached_deposit(env::attached_deposit())` rather than stashed in contract state, so `resolve_verification` can charge storage or refund from it directly. -The post-DCAP policy inputs are the same fields `mpc-contract` already holds today — the allowed-image-hash list, the per-account TLS / account public-key binding, and the stored-attestation map. No new policy state is introduced; the only state addition is the `pending_attestations` map described below, which is bookkeeping for the in-flight yield. +Because `verify_quote` returns a `VerificationResult` value rather than failing the receipt (see [§Why a rejection is a value](#why-a-rejection-is-a-value-not-a-failed-receipt)), `resolve_verification`'s `#[callback_result]` distinguishes three outcomes: `Ok(Verified)` runs the post-DCAP checks (RTMR3 replay, app-compose validation, measurement allowlist matching, report-data binding) and, on success, stores the attestation and charges storage; `Ok(Rejected)` returns a `QuoteRejected` error carrying the reason; and `Err(PromiseError::Failed)` — the verifier unreachable, panicked, or out of gas — returns `VerifierUnavailable`. On any error branch `resolve_verification` refunds the whole attached deposit and fires a *separate* `fail_attestation_submission` receipt whose panic fails the submitter's transaction. There is no yield, no `data_id`, no `pending_attestations` entry, and no ~200-block timeout: a failure settles immediately within the same promise chain. The exact branch-by-branch behavior is in [§Handling failures](#handling-failures) below. -The periodic re-validation path ([`re_verify`](../../crates/contract/src/tee/tee_state.rs)) does not call `dcap_qvl::verify` — it re-checks post-DCAP allowlist invariants against already-stored attestations — and is therefore unaffected by this design. It stays synchronous. +The post-DCAP policy inputs are the same fields `mpc-contract` already holds today — the allowed-image-hash list, the per-account TLS / account public-key binding, and the stored-attestation map. No new policy state is introduced, and the chain carries no bookkeeping map: everything `resolve_verification` needs travels as a `VerificationContext` borsh argument on the callback. + +The periodic re-validation path ([`re_verify`](../../crates/mpc-attestation/src/attestation.rs)) does not call `dcap_qvl::verify` — it re-checks post-DCAP allowlist invariants against already-stored attestations — and is therefore unaffected by this design. It stays synchronous. ```mermaid sequenceDiagram participant Op as Operator participant MPC as mpc-contract - participant State as State participant Ver as tee-verifier participant DCAP as dcap-qvl Op->>MPC: submit_participant_info(Dstack, tls_pk) - MPC->>MPC: promise_yield_create → data_id - MPC->>State: insert PendingAttestation { data_id, ... } - MPC->>Ver: Promise: verify_quote (chained .then resolve_verification) + MPC->>Ver: Promise: verify_quote(quote, collateral) + Note over MPC: .then resolve_verification(VerificationContext),
attached deposit forwarded to the callback Ver->>DCAP: verify(quote, collateral, now) - alt Verified (post-DCAP runs, then resumes) + alt Verified + store ok Ver-->>MPC: VerificationResult::Verified(report) MPC->>MPC: resolve_verification: verify_post_dcap_and_store vs fresh allowlist - MPC->>State: store on pass / refund on fail, remove PendingAttestation - MPC->>MPC: promise_yield_resume(data_id, AttestationResult) - MPC-->>Op: success or error, immediately - else Rejected (resumes immediately) - Ver-->>MPC: VerificationResult::Rejected(reason) - MPC->>MPC: resolve_verification: refund, remove PendingAttestation - MPC->>MPC: promise_yield_resume(data_id, AttestationResult::Err(reason)) - MPC-->>Op: error (carrying reason), immediately - else No verdict — verifier unreachable / silent for ~200 blocks - Note over MPC: resolve_verification (if it runs) only logs and returns.
Cleanup is left to the runtime yield-timeout. - MPC->>MPC: on_attestation_verified fires with Err(PromiseError::Failed) - MPC->>State: remove PendingAttestation, refund - MPC-->>Op: error + MPC->>MPC: charge_attestation_storage (refund excess) + MPC-->>Op: PromiseOrValue::Value(()) — success + else Verified + post-DCAP fail, or Rejected, or verifier unreachable + Ver-->>MPC: Verified(report) / Rejected(reason) / (no answer) + MPC->>MPC: resolve_verification produces Err (QuoteRejected / VerifierUnavailable) + MPC->>Op: refund whole attached deposit (this receipt) + MPC->>MPC: fail_attestation_submission receipt panics + MPC-->>Op: transaction fails (carrying the reason) end ``` #### Caller-side impact -The only caller of `submit_participant_info` in production is `mpc-node`'s `periodic_attestation_submission` task, which resubmits on a 1-hour cadence and on attestation-removal events. It already polls contract state to confirm the attestation is actually stored, with exponential backoff (100 ms → 60 s, capped at 12 h). That polling-based success criterion is what makes the sync→async change transparent. Under yield-resume the returned `Promise` also resolves with the actual outcome — success, a verifier-rejection error, or a post-DCAP-failure error (each as soon as the verifier answers), or a timeout error after ~200 blocks if it never does — so any future caller that wants to await the result synchronously can, without changing the contract. +The only caller of `submit_participant_info` in production is `mpc-node`'s `periodic_attestation_submission` task, which resubmits on a 1-hour cadence and on attestation-removal events. It already polls contract state to confirm the attestation is actually stored, with exponential backoff (100 ms → 60 s, capped at 12 h). That polling-based success criterion is what makes the sync→async change transparent. The returned `Promise` now resolves through the chain with the actual outcome — success, a verifier-rejection error, a post-DCAP-failure error, or a `VerifierUnavailable` error if the verifier never answers — so any future caller that wants to await the result synchronously can, without changing the contract. There is no ~200-block timeout error to account for: every path settles as soon as the verifier's receipt finishes. #### Handling failures -The first thing `submit_participant_info` does is insert a `PendingAttestation` entry, and that entry has to come back out once verification finishes — successfully or not. If a failure leaves the entry behind, the submitter's account is wedged: every future `submit_participant_info` call panics on the "already pending" guard, and the deposit stays locked because the refund is part of the cleanup the contract never got around to. +The submission produces no in-flight state to clean up: nothing is inserted into contract storage at submit time, so there is no pending entry that a failure could leave wedged and no "already pending" guard to trip on a resubmit. What a failure must still get right is the money — the attached deposit — and the caller-facing outcome. Both are handled in the single `.then` callback, `resolve_verification`. + +`resolve_verification` is a `#[private]` `#[payable]` method. It is `#[payable]` because the deposit rides forward onto it via `.with_attached_deposit`, so `env::attached_deposit()` inside the callback returns the amount the submitter attached. It observes the verifier's answer through `#[callback_result]` and reduces it to a `Result<(), Error>`: + +- `Ok(VerificationResult::Verified(report))` → `verify_post_dcap_and_store(&context, &report)`, which returns `Ok(())` on a clean store or an `Err` if a post-DCAP check or the storage charge fails. +- `Ok(VerificationResult::Rejected(reason))` → `Err(QuoteRejected { reason })`. +- `Err(promise_err)` → `Err(VerifierUnavailable)` — the verifier was unreachable, panicked, or ran out of gas. -That makes *where* the cleanup runs the central question, because NEAR offers two natural homes for "do something when the verifier responds" and they have very different failure modes. +On `Ok(())` the callback returns `PromiseOrValue::Value(())`; the attestation is stored and storage has been charged, with any excess deposit refunded inside `charge_attestation_storage`. -A **`.then` callback** is a normal cross-contract callback chained onto the verifier's promise. The runtime runs it in a fresh receipt once the verifier's receipt finishes; if it panics or runs out of gas, that receipt rolls back atomically and the chain ends. Because the receipt is independent of whatever yield is parked in parallel, its failure has no special effect on the submitter's call — the submitter just keeps waiting on the yield. +On `Err(err)` the callback does two things, in order, and the order is the whole point: -A **yield-callback** is different. When `submit_participant_info` calls `promise_yield_create`, it asks the runtime to *park* the submitter's call so the contract can return its result later. The runtime fires the named callback exactly once per `data_id` — either when something calls `promise_yield_resume(data_id, payload)`, or after ~200 blocks of silence with `Err(PromiseError::Failed)`. That single firing's return value is what the submitter eventually receives. There is no second invocation: an OOG inside the yield-callback rolls back its whole receipt and drops whatever cleanup it was meant to do, with no automatic retry. +1. `refund_to(&account_id, env::attached_deposit())` — refund the *entire* attached deposit in this receipt. +2. Schedule a *separate* `fail_attestation_submission` receipt via `Promise::new(current_account).function_call(...).as_return()`, and return it as `PromiseOrValue::Promise`. -The asymmetry decides the design. The work the verifier's *answer* unlocks — post-DCAP checks, the `stored_attestations` insert, the refund on rejection or post-DCAP failure, the pending-entry removal, the `promise_yield_resume` call — lives in the `.then` bridge `resolve_verification`. If it aborts mid-flight, the entire receipt rolls back atomically (including the resume), so the yield stays parked and the runtime's 200-block timeout still fires the yield-callback for cleanup — same recovery as "verifier never responded." `resolve_verification` resolves immediately on either answer it can act on: `Verified` (run post-DCAP, then resume) and `Rejected` (refund and resume with the reason). Only `Err(PromiseError::Failed)` — no verdict, the verifier was unreachable or crashed — is deliberately *not* resolved here: `resolve_verification` logs and returns early, routing that case to the timeout cleanup. The yield-callback `on_attestation_verified` is intentionally tiny: on resume, return the value to the caller; on its `Err(PromiseError::Failed)` branch — verifier unreachable or silent timeout — remove the pending entry and schedule a refund. +The refund and the failure live in different receipts deliberately. `fail_attestation_submission` is a tiny `#[private]` method that logs the reason and then `env::panic_str(&reason)` — its panic is what fails the submitter's transaction and surfaces the error. If that panic instead happened inside `resolve_verification` (the `#[handle_result]`-return-an-`Err` shape), it would roll back the whole callback receipt, discarding the refund transfer and any created promises along with it. Splitting them lets the refund commit in the first receipt while the second receipt fails the caller's transaction afterward. + +`verify_post_dcap_and_store` has its own commit-order subtlety. Unlike the synchronous `Mock` path — where returning an `Err` rolls back the entire method receipt and un-does any partial store — this callback receipt *commits regardless* of the `Err` it hands back to `resolve_verification`. So the store cannot be left to implicit rollback. `verify_post_dcap_and_store` snapshots `env::storage_usage()`, calls `verify_and_store_dstack`, then `charge_attestation_storage`; if the charge fails (`InsufficientDeposit`), it explicitly calls `tee_state.revert_dstack_store(tls_pk, insertion)` before returning the error, so the caller never gets storage for free plus a full refund. Walking every path the system can take: -- `resolve_verification` resumes (verifier returned `Verified` (post-DCAP pass or fail) or `Rejected`) → it cleaned up before resuming, caller receives the outcome. -- `resolve_verification` returns early (verifier unreachable — `Err(PromiseError::Failed)`) → timeout fires, yield-callback cleans up. -- `resolve_verification` aborts (OOG / panic mid-receipt) → timeout fires, yield-callback cleans up. -- `resolve_verification` never runs (verifier silent) → timeout fires, yield-callback cleans up. -- Yield-callback itself OOGs → genuine orphan. Its body is one `LookupMap::remove` plus one Promise schedule, small enough that an out-of-gas failure is implausible with a sensible gas budget. +- Verifier returned `Verified`, post-DCAP checks pass, storage charge succeeds → attestation stored, excess refunded, `Value(())`. Caller polls and sees the entry. +- Verifier returned `Verified` but a post-DCAP check fails → `verify_and_store_dstack` errors (nothing was stored), `resolve_verification` refunds and fires the fail receipt. +- Verifier returned `Verified`, post-DCAP passes, but the storage charge fails → `verify_post_dcap_and_store` reverts the store explicitly, returns the error, `resolve_verification` refunds and fires the fail receipt. +- Verifier returned `Rejected` → `QuoteRejected`, refund + fail receipt. +- Verifier unreachable / panicked / out of gas (`Err(PromiseError::Failed)`) → `VerifierUnavailable`, refund + fail receipt. This is handled right here, immediately; it is not deferred to any timeout. +- `resolve_verification` itself runs out of gas or panics mid-receipt → the whole callback receipt rolls back atomically, no partial commits, and the submitter's transaction fails. Because nothing was inserted at submit time, there is no orphaned state to reclaim. -This isn't a novel pattern in this codebase: `sign`, `request_app_private_key`, and `verify_foreign_transaction` already use the same split. The heavy work and the `promise_yield_resume` call live in [`pending_requests::resolve_yields_for`][pending-requests-mod] (invoked from `respond`, `respond_ckd`, and `respond_verify_foreign_tx`), and [`return_signature_and_clean_state_on_success`][sign-yield-callback] is a tiny yield-callback that just routes the outcome back to the caller and handles the no-response timeout. The attestation flow inherits the same orphan-proof argument: heavy work in a safely-recoverable receipt, cleanup in a callback small enough that gas budgeting makes its failure implausible. +The verifier still returns its verdict as a *value* rather than a failed receipt, so `#[callback_result]` can tell a definitive `Rejected` apart from `Err(PromiseError::Failed)` (no answer). Under the no-yield design both still lead to an immediate fail-and-refund in `resolve_verification`; the distinction only changes the error type and message the caller sees (`QuoteRejected` with the reason vs `VerifierUnavailable`), not whether cleanup is immediate. ### Contract state changes -The callback runs in a later block than `submit_participant_info`, as an independent contract invocation. Anything the callback still needs must be stashed in contract storage, in a new field: +`resolve_verification` runs in a later block than `submit_participant_info`, as an independent contract invocation, so anything it needs from the original call must travel with it. That is done not through contract storage but through a borsh callback argument: ```rust -pending_attestations: LookupMap +pub struct VerificationContext { + pub(crate) node_id: NodeId, + pub(crate) attestation: DstackAttestation, +} ``` -This map mirrors the other pending-request maps in `mpc-contract` ([`pending_signature_requests`][pending-requests-mod], `pending_ckd_requests`, `pending_verify_foreign_tx_requests`), but stores a single `PendingAttestation` per `AccountId` rather than a `Vec`: attestation submissions are 1-per-account. - -Each [`PendingAttestation`](#mpc-contractsubmit_participant_info) holds: +`VerificationContext` carries the submitter's `NodeId` (account id, TLS public key, and account public key — the binding the post-DCAP report-data check reproves) and the full `DstackAttestation` payload (RTMR3 event log, app-compose, report-data) that the post-DCAP checks consume. It is passed to `resolve_verification` as a `#[serializer(borsh)]` argument and is never written to contract state. The attached deposit is *not* part of it — it rides forward on the promise via `.with_attached_deposit`, so `env::attached_deposit()` in the callback yields the submitter's deposit directly. -- **The submitter's `Attestation::Dstack` payload** — the RTMR3 event log, app-compose, and report-data the post-DCAP checks consume. -- **The submitter's TLS public key** — the callback hashes it with the submitter's account public key and compares to the quote's `report_data` field, proving the enclave produced the quote for this specific submitter. -- **The attached deposit** — covers storage staking on success, refunded to the signer of the original `submit_participant_info` transaction on failure. `env::attached_deposit()` is not visible from the callback receipt, so the value is stashed at submit time and the recipient `AccountId` is the same one used to key the entry (set by `Self::assert_caller_is_signer()`). -- **`data_id: CryptoHash`** — the yield handle returned by [`env::promise_yield_create`][promise-yield-create]. The intermediate `.then` callback (`resolve_verification`) reads this back to call [`env::promise_yield_resume`][promise-yield-resume] with an `AttestationResult` after the post-DCAP checks have run. +This design adds **no** new attestation-related state. There is no `pending_attestations` map, no `PendingAttestation` struct, no `AttestationResult` enum, and no stashed `data_id` or deposit. The only new fields on the contract are `tee_verifier_account_id` and `tee_verifier_votes`, both from [§Voting on the trusted verifier](#voting-on-the-trusted-verifier-in-mpc-contract). -Entries are removed by `resolve_verification` on every branch where the verifier returned an answer — `Verified` (post-DCAP success or failure) and `Rejected`; only when the verifier gave no verdict (`Err(PromiseError::Failed)` — unreachable or crashed) does it return early and leave the entry in place. `on_attestation_verified` then removes the entry on its `Err(PromiseError::Failed)` branch, which covers that unreachable case and the no-response timeout. - -Notably absent from `PendingAttestation`: the **post-DCAP policy state** — allowed MPC image hashes, allowed launcher compose hashes, and accepted measurements. `resolve_verification` re-reads all of them from contract state, so any governance vote that adds or removes an entry mid-flight applies to verifications it overlaps. Snapshotting at request time would freeze each submission against stale policy — wrong default for a security control, where removing a compromised hash should take effect immediately. +Notably, the **post-DCAP policy state** — allowed MPC image hashes, allowed launcher compose hashes, and accepted measurements — is not snapshotted either. `verify_post_dcap_and_store` reads all of it fresh from contract state when the callback runs, so any governance vote that adds or removes an entry mid-flight applies to verifications it overlaps. Snapshotting at request time would freeze each submission against stale policy — wrong default for a security control, where removing a compromised hash should take effect immediately. ```mermaid sequenceDiagram @@ -154,8 +155,6 @@ sequenceDiagram participant Ver as tee-verifier Op->>MPC: submit_participant_info(Dstack, tls_pk) - MPC->>MPC: promise_yield_create → data_id - MPC->>MPC: insert PendingAttestation { data_id, ... } MPC->>Ver: Promise: verify_quote(...) (.then resolve_verification) Gov->>MPC: vote_add_image_hash(H) @@ -163,10 +162,8 @@ sequenceDiagram Ver-->>MPC: VerificationResult::Verified(report) MPC->>MPC: resolve_verification - MPC->>MPC: read allowlist (sees H) - MPC->>MPC: verify_post_dcap_and_store against fresh allowlist - MPC->>MPC: promise_yield_resume(data_id, AttestationResult) - MPC->>MPC: on_attestation_verified (trivial: return value) + MPC->>MPC: verify_post_dcap_and_store reads allowlist fresh (sees H) + MPC->>MPC: store on pass / refund + fail receipt on error ``` ## Crate layout @@ -281,13 +278,13 @@ pub enum VerificationResult { #### Why a rejection is a value, not a failed receipt -Encoding the rejection the obvious way — `Result` via `#[handle_result]`, so a rejection fails the receipt — loses the reason. NEAR's promise result is either `Successful(bytes)` or `Failed`, and **`Failed` carries no payload**: the caller's `#[callback_result]` sees a bare `Err(PromiseError::Failed)`, *identical* to the verifier being unreachable or out of gas. But `mpc-contract` must treat "the verifier rejected this quote" (definitive — refund and finish now) differently from "the verifier did not answer" (transient — wait for the yield timeout, the node resubmits). Returning the outcome as a *value* keeps them distinct, so `#[callback_result]` observes: +Encoding the rejection the obvious way — `Result` via `#[handle_result]`, so a rejection fails the receipt — loses the reason. NEAR's promise result is either `Successful(bytes)` or `Failed`, and **`Failed` carries no payload**: the caller's `#[callback_result]` sees a bare `Err(PromiseError::Failed)`, *identical* to the verifier being unreachable or out of gas. `mpc-contract` still wants to tell "the verifier rejected this quote" apart from "the verifier did not answer", so it can report the right error to the caller. Returning the outcome as a *value* keeps them distinct, so `#[callback_result]` observes: - `Ok(VerificationResult::Verified(report))` — quote valid; run post-DCAP checks. -- `Ok(VerificationResult::Rejected(reason))` — rejected; refund and resume **immediately**, with the reason. -- `Err(PromiseError::Failed)` — unreachable / panicked / timed out; the yield timeout cleans up. +- `Ok(VerificationResult::Rejected(reason))` — rejected; `resolve_verification` returns `QuoteRejected { reason }`. +- `Err(PromiseError::Failed)` — unreachable / panicked / out of gas; `resolve_verification` returns `VerifierUnavailable`. -This is the NEP-141 / NEP-171 shape — the meaningful outcome rides in the success payload, a failed receipt is reserved for "something broke" — and it preserves `mpc-contract`'s existing invariant that a rejection and a non-answer are never the same event. (near-sdk also refuses to serialize a bare `Result` return, so `VerificationResult` is a dedicated sum type.) +This is the NEP-141 / NEP-171 shape — the meaningful outcome rides in the success payload, a failed receipt is reserved for "something broke". Under the no-yield design both error branches lead to the same immediate refund-and-fail; keeping them distinct only changes the error type and message the caller receives. (near-sdk also refuses to serialize a bare `Result` return, so `VerificationResult` is a dedicated sum type.) ### Voting on the trusted verifier in `mpc-contract` @@ -301,7 +298,7 @@ The proposal payload is the pair `(candidate_account_id, expected_code_hash)`. ` #[near(serializers = [borsh])] pub struct VerifierChangeProposal { pub candidate_account_id: AccountId, - pub expected_code_hash: CryptoHash, + pub expected_code_hash: TeeVerifierCodeHash, } impl ProposalHashEncoding for VerifierChangeProposal { @@ -322,7 +319,7 @@ impl MpcContract { pub fn vote_tee_verifier_change( &mut self, candidate_account_id: AccountId, - expected_code_hash: CryptoHash, + expected_code_hash: TeeVerifierCodeHash, ); /// Withdraw the caller's current vote on any pending verifier-change @@ -348,8 +345,9 @@ pub struct MpcContract { /// Pending votes for changing `tee_verifier_account_id`. Each voter is an /// active MPC participant; each proposal is hashed from - /// `(candidate_account_id, expected_code_hash)`. - tee_verifier_votes: Votes, + /// `(candidate_account_id, expected_code_hash)`. `TeeVerifierVotes` is a thin + /// newtype wrapping the generic `Votes`. + tee_verifier_votes: TeeVerifierVotes, } ``` @@ -373,7 +371,7 @@ sequenceDiagram Note over MPC: tee_verifier_account_id = new (routing only,
no eviction) VerOld-->>MPC: VerificationResult::Verified(report) - MPC->>MPC: resolve_verification (post-DCAP + insert, as usual) + MPC->>MPC: resolve_verification (post-DCAP + store, as usual) Note over MPC: stored entry ages out within the
expiration window via re_verify Op->>MPC: submit_participant_info(Dstack, tls_pk) (next hourly resubmit) @@ -383,251 +381,186 @@ sequenceDiagram ### `mpc-contract::submit_participant_info` -The method splits across three receipts joined by yield-resume — see [§Submission flow](#submission-flow) above for the architecture. It returns `Result<(), Error>`, like the existing yield producers (`sign` / `request_app_private_key` / `verify_foreign_transaction`): `Mock` attestations are verified synchronously and return `Ok(())`; `Dstack` attestations register a yield via [`env::promise_yield_create`][promise-yield-create] and end on `enqueue_yield_request` so that its `env::promise_return` is the method's result. The runtime resolves the yield either when `resolve_verification` calls [`env::promise_yield_resume`][promise-yield-resume] or after ~200 blocks of silence. The post-DCAP checks and the `stored_attestations` insert live in `resolve_verification`, not in the yield-callback — that's what keeps `on_attestation_verified` trivial enough to match the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]. Draft implementation: +The method resolves a Dstack submission through a two-receipt promise chain: `verify_quote` on the verifier, then `resolve_verification` as its callback — see [§Submission flow](#submission-flow) above for the architecture. It returns `Result, Error>`. A `Mock` attestation is verified and stored synchronously and returns `Ok(PromiseOrValue::Value(()))`. A `Dstack` attestation is delegated to `submit_dstack_attestation`, which builds the chain and returns `Ok(PromiseOrValue::Promise(...))`. The attached deposit is forwarded onto `resolve_verification` via `.with_attached_deposit`, so the callback can charge storage or refund without any state being stashed at submit time. There is no `pending_attestations` insert and no "one in-flight per account" guard. Draft implementation: ```rust impl MpcContract { + #[payable] + #[handle_result] pub fn submit_participant_info( &mut self, attestation: Attestation, - tls_pk: Ed25519PublicKey, - ) -> Result<(), Error> { + tls_public_key: Ed25519PublicKey, + ) -> Result, Error> { // Existing convention: caller must be the signer of this transaction, // not a relayer or proxy. let account_id = Self::assert_caller_is_signer(); + let node_id = NodeId { account_id, tls_public_key, /* account_public_key */ }; + match attestation { - // Synchronous: no DCAP, verified and stored in this call. + // Synchronous: no DCAP, verified and stored in this call. A + // returned Err here rolls back the whole receipt. Attestation::Mock(mock) => { + let initial_storage = env::storage_usage(); self.tee_state.verify_and_store_mock(node_id, mock, ...)?; - Ok(()) - } - // Dstack: yield-resume. - Attestation::Dstack(dstack) => { - // One in-flight verification per AccountId. A duplicate submit - // before the previous one finishes (verifier response or - // runtime timeout) is rejected outright — same shape as - // duplicate sign requests. - if self.pending_attestations.contains_key(&account_id) { - return Err(TeeError::VerificationAlreadyPending.into()); - } - // Refuse until a verifier is voted in: there is no account to - // call `verify_quote` on. - let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { - return Err(TeeError::VerifierNotConfigured.into()); - }; - - let attached_deposit = env::attached_deposit(); - - // Cross-contract call to the verifier, built first so the - // `enqueue_yield_request` below stays the final host call. Its - // `.then` callback (`resolve_verification`) is the bridge that - // turns the verifier's response into a `promise_yield_resume` on - // the yield this method registers next. Quote/collateral are - // serialized by reference so `dstack` can move into the pending - // entry without cloning the (large) payload. - Promise::new(verifier_account_id) - .function_call( - "verify_quote".into(), - borsh::to_vec(&(&dstack.quote, &dstack.collateral)).unwrap(), - NearToken::from_yoctonear(0), - Gas::from_tgas(VERIFIER_GAS_TGAS), - ) - .then( - Self::ext(env::current_account_id()) - .with_static_gas(Gas::from_tgas(RESOLVE_GAS_TGAS)) - .resolve_verification(node_id.clone()), - ) - .detach(); - - // Reuses the existing `enqueue_yield_request` helper that wraps - // `env::promise_yield_create`. The helper allocates `data_id`, - // registers `on_attestation_verified` as the yield-callback, and - // surfaces `data_id` via the `insert` closure so we can stash it - // together with the rest of the `PendingAttestation` fields. It - // calls `env::promise_return` last, making the yield the method's - // result — so we just return `Ok(())` (no `value_return` that - // would override it). - self.enqueue_yield_request( - "on_attestation_verified", - borsh::to_vec(&account_id).unwrap(), - Gas::from_tgas(YIELD_CALLBACK_GAS_TGAS), - |this, data_id| { - this.pending_attestations.insert( - account_id.clone(), - PendingAttestation { - dstack, - tls_pk, - attached_deposit, - data_id, - }, - ); - }, - ); - Ok(()) + self.charge_attestation_storage(&node_id.account_id, initial_storage)?; + Ok(PromiseOrValue::Value(())) } + // Dstack: async via the verifier promise chain. + Attestation::Dstack(attestation) => Ok(PromiseOrValue::Promise( + self.submit_dstack_attestation(node_id, attestation)?, + )), } } - /// `.then` bridge between the verifier's cross-contract call and the - /// yield this submission registered. Owns every outcome where the verifier - /// *answered* (`Ok(VerificationResult::{Verified,Rejected})`): on - /// `Verified` it runs the post-DCAP checks against fresh policy state and - /// inserts into `stored_attestations` on success; on `Rejected` it skips - /// straight to the refund. Either way it removes the pending entry, - /// schedules a refund where the outcome is an error, and calls - /// `promise_yield_resume(data_id, AttestationResult)` as the LAST step of the - /// receipt — so a rejected quote is resolved *immediately*, not at the - /// timeout. Only `Err(PromiseError::Failed)` (no verdict — verifier - /// unreachable or crashed) is logged and returned early WITHOUT resuming or - /// removing the pending entry; `on_attestation_verified` owns that cleanup - /// on its `Err(PromiseError::Failed)` branch, so we must not race the - /// timeout for it. State mutations in this receipt are visible to the - /// yield-callback that fires next; if any line below `promise_yield_resume` - /// panicked or OOG'd, the entire receipt would roll back atomically (no - /// partial state commits) and the runtime's ~200-block yield-timeout would - /// still fire `on_attestation_verified` with `Err(PromiseError::Failed)` - /// for cleanup. + /// Builds the verifier promise chain. Fails the submit transaction + /// synchronously with `VerifierNotConfigured` if no verifier has been + /// voted in — there is no account to call `verify_quote` on. Otherwise it + /// calls `verify_quote` on the trusted verifier and chains + /// `resolve_verification` as its `.then` callback, forwarding the attached + /// deposit onto that callback. Quote/collateral are serialized by + /// reference so `attestation` can move into the `VerificationContext`. + fn submit_dstack_attestation( + &mut self, + node_id: NodeId, + attestation: DstackAttestation, + ) -> Result { + let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { + return Err(TeeError::VerifierNotConfigured.into()); + }; + + Ok(Promise::new(verifier_account_id) + .function_call( + "verify_quote".into(), + borsh::to_vec(&(&attestation.quote, &attestation.collateral)).unwrap(), + NearToken::from_yoctonear(0), + Gas::from_tgas(self.config.verifier_tera_gas), + ) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(Gas::from_tgas(self.config.resolve_verification_tera_gas)) + .with_attached_deposit(env::attached_deposit()) + .resolve_verification(VerificationContext { node_id, attestation }), + )) + } + + /// Verify-quote callback. `#[payable]` because the submitter's deposit + /// rides forward via `.with_attached_deposit`, so `env::attached_deposit()` + /// here is the amount they attached. `#[callback_result]` distinguishes the + /// three verifier outcomes: + /// + /// - `Ok(Verified)` → run post-DCAP checks and store. + /// - `Ok(Rejected)` → `QuoteRejected { reason }`. + /// - `Err(_)` → `VerifierUnavailable` (unreachable / panicked / OOG). /// - /// Same architectural shape as [`pending_requests::resolve_yields_for`][pending-requests-mod] - /// in the sign-request flow: the response-side function owns the state - /// mutation and the `promise_yield_resume` call; the yield-callback is - /// kept trivial. + /// On success returns `Value(())` (attestation stored, storage charged, + /// excess refunded). On any error it refunds the WHOLE attached deposit in + /// this receipt, then fires a SEPARATE `fail_attestation_submission` + /// receipt whose panic fails the caller's transaction — the split is what + /// lets the refund commit, since a panic in this receipt would roll it back + /// (and drop the created promises) along with the refund. #[private] + #[payable] pub fn resolve_verification( &mut self, - node_id: NodeId, - #[callback_result] result: Result, - ) { - let account_id = node_id.account_id.clone(); - let final_outcome = match result { - // No verdict: the verifier was unreachable, panicked, or ran out of - // gas. Do nothing — the runtime's yield-timeout will fire - // `on_attestation_verified` with `Err(PromiseError::Failed)` and - // clean up the pending entry there. We must not call - // `promise_yield_resume` here, or we'd race the timeout for - // ownership of the cleanup path. - Err(promise_err) => { - log!("verifier did not answer for {account_id}: {promise_err:?}"); - return; + #[serializer(borsh)] context: VerificationContext, + #[serializer(borsh)] + #[callback_result] + result: Result, + ) -> PromiseOrValue<()> { + let account_id = context.node_id.account_id.clone(); + + let attestation_result = match result { + Ok(VerificationResult::Verified(report)) => { + self.verify_post_dcap_and_store(&context, &report) } - // The verifier ran and rejected the quote. A definitive verdict: - // refund and resume now, with the reason, rather than waiting for - // the timeout. Ok(VerificationResult::Rejected(reason)) => { log!("verifier rejected quote for {account_id}: {reason}"); - AttestationResult::Err(format!("verifier: {reason}")) + Err(TeeError::QuoteRejected { reason: reason.to_string() }.into()) } - Ok(VerificationResult::Verified(report)) => { - let pending = self.pending_attestations.get(&account_id).expect( - "PendingAttestation must exist while resolve_verification holds the yield", - ); - // Post-DCAP checks operate on the verified report plus state held - // here. The allowlist is read fresh — governance votes mid-flight - // take effect. - match verify_post_dcap_and_store(pending, &report, self.allowlist_fresh()) { - Ok(()) => { - self.tee_state.stored_attestations.insert( - pending.tls_pk.clone(), - VerifiedAttestation::from((pending.clone(), report)), - ); - AttestationResult::Ok - } - Err(reason) => { - log!("post-DCAP check failed for {account_id}: {reason}"); - AttestationResult::Err(format!("post-DCAP: {reason}")) - } - } + Err(promise_err) => { + log!("verifier did not answer for {account_id}: {promise_err:?}"); + Err(TeeError::VerifierUnavailable.into()) } }; - let pending = self - .pending_attestations - .remove(&account_id) - .expect("PendingAttestation must exist while resolve_verification holds the yield"); - if matches!(final_outcome, AttestationResult::Err(_)) { - refund_deposit(&account_id, pending.attached_deposit); + match attestation_result { + Ok(()) => PromiseOrValue::Value(()), + Err(err) => { + refund_to(&account_id, env::attached_deposit()); + let promise = Promise::new(env::current_account_id()).function_call( + "fail_attestation_submission".into(), + borsh::to_vec(&err.to_string()).unwrap(), + NearToken::from_yoctonear(0), + Gas::from_tgas(self.config.fail_attestation_submission_tera_gas), + ); + PromiseOrValue::Promise(promise.as_return()) + } } - // `promise_yield_resume` must be the LAST host call in this receipt: - // anything after it could panic and roll back the state mutations above. - env::promise_yield_resume(&pending.data_id, borsh::to_vec(&final_outcome).unwrap()); } - /// Yield-callback. Same shape as the sign-request callback - /// [`return_signature_and_clean_state_on_success`][sign-yield-callback]: the - /// `Verified` and `Rejected` outcomes (every case where the verifier - /// answered) were already finalized by `resolve_verification` (which removed - /// the pending entry and scheduled any refund before calling - /// `promise_yield_resume`), so this body just returns the outcome to the - /// caller. - /// - /// The only branch that does real work is `Err(PromiseError::Failed)`, fired - /// by the runtime ~200 blocks after submit if no `promise_yield_resume` has - /// landed: the verifier was unreachable / never responded so - /// `resolve_verification` deliberately returned early, or it ran but rolled - /// back (OOM / panic). On that branch the pending entry is still present, so - /// it removes the entry and schedules a deposit refund. - #[private] - pub fn on_attestation_verified( + /// Runs the post-DCAP checks and stores the attestation for a `Verified` + /// response. The callback receipt commits regardless of the `Err` returned, + /// so a failed storage charge cannot rely on implicit rollback: it reverts + /// the store explicitly, or the caller would get storage for free plus a + /// full refund. + fn verify_post_dcap_and_store( &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) => { - if let Some(pending) = self.pending_attestations.remove(&account_id) { - refund_deposit(&account_id, pending.attached_deposit); - log!("yield timeout for {account_id}: refunded and cleaned up"); - } - "verifier did not respond within yield-resume window".to_string() + context: &VerificationContext, + report: &VerifiedReport, + ) -> Result<(), Error> { + let account_id = &context.node_id.account_id; + let initial_storage = env::storage_usage(); + let insertion = self.tee_state.verify_and_store_dstack( + context.node_id.clone(), + &context.attestation, + report, + /* tee_upgrade_deadline_duration */ + )?; + + match self.charge_attestation_storage(account_id, initial_storage) { + Ok(()) => Ok(()), + Err(err) => { + self.tee_state + .revert_dstack_store(&context.node_id.tls_public_key, insertion); + Err(err) } - }; - // Fail the submitter's transaction from a SEPARATE receipt: a panic here - // would roll back the cleanup above. - let promise = Promise::new(env::current_account_id()).function_call( - "fail_attestation_submission".into(), - borsh::to_vec(&reason).unwrap(), - NearToken::from_near(0), - Gas::from_tgas(FAIL_GAS_TGAS), - ); - PromiseOrValue::Promise(promise.as_return()) + } } -} -#[derive(BorshSerialize, BorshDeserialize)] -pub enum AttestationResult { - Ok, - Err(String), + /// Separate receipt whose panic fails the caller's transaction after the + /// refund in `resolve_verification` has committed. + #[private] + pub fn fail_attestation_submission(#[serializer(borsh)] reason: String) { + log!("fail_attestation_submission: {reason}"); + env::panic_str(&reason); + } } ``` -`VERIFIER_GAS_TGAS`, `RESOLVE_GAS_TGAS`, and `YIELD_CALLBACK_GAS_TGAS` are placeholders until benchmarked. The verifier-side cost is dominated by ECDSA verifications and X.509-chain walking inside `dcap_qvl::verify::verify`. The bulk of the contract-side post-DCAP work — RTMR3 replay, app-compose validation, allowlist matching, report-data binding, plus the `stored_attestations.insert` — runs inside `resolve_verification`, so `RESOLVE_GAS_TGAS` gets the largest budget. `YIELD_CALLBACK_GAS_TGAS` can be conservatively small (on the order of 10 TGas with comfortable headroom): the yield-callback only does a `LookupMap::remove` and schedules a `Promise` on the timeout branch, and just returns a value on the resume branch. +`charge_attestation_storage` reads `env::attached_deposit()` itself: if the attached amount is less than the measured storage cost it returns `InsufficientDeposit`; otherwise it refunds the excess to the account via `refund_to`. `refund_to` is the generic refund helper (a detached `transfer` promise, no-op on zero). -The contract gains the following state fields: +`verifier_tera_gas`, `resolve_verification_tera_gas`, and `fail_attestation_submission_tera_gas` are unbenchmarked estimates until measured. The verifier-side cost (`verifier_tera_gas`) is dominated by ECDSA verifications and X.509-chain walking inside `dcap_qvl::verify::verify`, so it gets the largest budget. `resolve_verification_tera_gas` covers the contract-side post-DCAP work — RTMR3 replay, app-compose validation, allowlist matching, report-data binding — plus the `verify_and_store_dstack` insert and the storage charge. `fail_attestation_submission_tera_gas` can be tiny (a couple of TGas): the method only logs and panics. -```rust -pub struct MpcContract { - // ... existing fields, including tee_verifier_account_id and - // tee_verifier_votes from §Voting on the trusted verifier ... - pending_attestations: LookupMap, -} +### Contract state changes summary -pub struct PendingAttestation { - pub dstack: DstackAttestation, - pub tls_pk: Ed25519PublicKey, - pub attached_deposit: NearToken, - pub data_id: CryptoHash, -} -``` +No new attestation state fields. The chain carries a `VerificationContext { node_id, attestation }` as a borsh callback argument; nothing new is written to storage. The only new fields on the contract are `tee_verifier_account_id` and `tee_verifier_votes` from [§Voting on the trusted verifier](#voting-on-the-trusted-verifier-in-mpc-contract). ## Testing -The yield-resume split adds four resolution branches the synchronous version never had. Three do their work in `resolve_verification`, each from a distinct verifier answer: `Verified` + post-DCAP pass (store + resume `Ok`), `Verified` + post-DCAP fail (refund + resume `Err`), and `Rejected` (refund + resume `Err`, immediately — the path that recovers the synchronous-rejection behavior the split would otherwise lose). The fourth lives in `on_attestation_verified`, on its `Err(PromiseError::Failed)` branch, reached when the verifier gave no verdict — unreachable, panicked, or no resume landed within ~200 blocks (verifier silent, or a `resolve_verification` receipt that rolled back). That no-verdict case re-enters `resolve_verification`, which logs and returns early without resuming, so its cleanup happens in `on_attestation_verified`. Each branch needs test coverage, and exercising them requires the verifier to return specific answers on demand — a `Verified` or `Rejected` value for the three `resolve_verification` branches, and for the no-verdict path either an unreachable account or the test driver advancing the chain past the yield-resume window without resuming. +The no-yield chain adds a handful of resolution branches the synchronous version never had, all inside `resolve_verification` and the helper it delegates to: + +- **Verifier not configured** — `Dstack` submit while `tee_verifier_account_id` is `None` fails *synchronously* with `VerifierNotConfigured`; the submit transaction itself errors, no promise is scheduled. +- **Verified + store happy path** — `verify_quote` returns `Verified`, post-DCAP passes, storage charged, excess refunded, `Value(())`. The attestation is present in state afterward. +- **Verified + post-DCAP fail** — `verify_and_store_dstack` errors; `resolve_verification` refunds the whole deposit and fires the `fail_attestation_submission` receipt; nothing is stored. +- **Verified + insufficient deposit** — post-DCAP passes but `charge_attestation_storage` returns `InsufficientDeposit`; `verify_post_dcap_and_store` reverts the store explicitly, so state is unchanged; refund + fail receipt. +- **Rejected → fail + refund** — `verify_quote` returns `Rejected`; `resolve_verification` returns `QuoteRejected` carrying the reason; refund + fail receipt. +- **Verifier unreachable → `VerifierUnavailable`** — the callback observes `Err(PromiseError::Failed)`; refund + fail receipt. +- **OOG in `resolve_verification` rolls back atomically** — an out-of-gas or panic mid-callback rolls back the whole receipt (no partial store, no partial refund) and fails the caller's transaction; because nothing was inserted at submit time, there is no orphaned state to reclaim. The verifier-rotation design changes the test surface in three ways. First, the expiration window itself: an entry whose `expiry_timestamp_seconds` is in the past must be rejected by `re_verify` even when every post-DCAP allowlist invariant still holds, and an entry within the (shortened) window must still pass — this is the existing expiry check, now exercised against the lowered `DEFAULT_EXPIRATION_DURATION_SECONDS`. Second, rotation routing: after `vote_tee_verifier_change` crosses threshold, the next `submit_participant_info` must call `verify_quote` on the new `tee_verifier_account_id`, and existing stored entries must remain present (no purge) until they expire. Third, the in-flight case: a verification scheduled against the old verifier that resolves after the vote crosses threshold must still be stored as a normal entry — it is not treated specially and ages out via the same expiration window as any other entry. -To make that practical, we introduce a stub `tee-verifier` crate: same `tee-verifier-interface` DTOs as the real verifier, but `verify_quote` returns whatever `VerificationResult` (`Verified` or `Rejected`) the test asks for — and a stub that panics, or an undeployed account, covers the no-verdict path. Sandbox tests deploy the stub like any other verifier candidate — lock its account, then call `vote_tee_verifier_change` from the test setup to point `mpc-contract` at the stub. This runs the same code path as production; nothing in `mpc-contract` knows or cares whether it's talking to the real verifier or the stub. +To make that practical, we introduce a stub `tee-verifier` crate: same `tee-verifier-interface` DTOs as the real verifier, but `verify_quote` returns whatever `VerificationResult` (`Verified` or `Rejected`) the test asks for — and a stub that panics, or an undeployed account, covers the `VerifierUnavailable` path. Sandbox tests deploy the stub like any other verifier candidate — lock its account, then call `vote_tee_verifier_change` from the test setup to point `mpc-contract` at the stub. This runs the same code path as production; nothing in `mpc-contract` knows or cares whether it's talking to the real verifier or the stub. E2E tests in `crates/e2e-tests` deploy either the real `tee-verifier` (when the test wants real `dcap-qvl` against a fixture quote) or the stub (for everything else). The change is one extra `deploy` call in the setup helper. @@ -636,17 +569,9 @@ E2E tests in `crates/e2e-tests` deploy either the real `tee-verifier` (when the [nep-509]: https://github.com/near/NEPs/blob/master/neps/nep-0509.md [re-verify]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/mpc-attestation/src/attestation.rs#L93 [periodic-attestation-submission]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/tee/remote_attestation.rs#L140 -[attestation-resubmission-interval]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/run.rs#L43 -[attestation-attempts-metric]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/metrics.rs#L364 [verify-tee]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1543 [clean-invalid-attestations]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1646 -[clean-foreign-chain-data]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1669 [monitor-attestation-removal]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/tee/remote_attestation.rs#L209 [submit-participant-info]: https://github.com/near/mpc/blob/efe49230bb66854c55bba080e7610e42f9221506/crates/contract/src/lib.rs#L754-L782 [launcher-pattern]: https://github.com/near/mpc/blob/efe49230bb66854c55bba080e7610e42f9221506/docs/tee-lifecycle.md#upgrade -[slack-launcher-discussion]: https://nearone.slack.com/archives/C0B12RKBSAV/p1777897902903889 -[promise-yield-create]: https://docs.rs/near-sdk/5.26.1/near_sdk/env/fn.promise_yield_create.html -[promise-yield-resume]: https://docs.rs/near-sdk/5.26.1/near_sdk/env/fn.promise_yield_resume.html -[enqueue-yield-request]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L301-L323 -[pending-requests-mod]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/pending_requests.rs -[sign-yield-callback]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1999-L2023 +[slack-launcher-discussion]: https://nearone.slack.com/archives/C0B12RKBSAV/p1777897902903889 \ No newline at end of file From 34a644c532c8aea92be942ee751566e02ccf5750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 13 Jul 2026 12:54:01 +0200 Subject: [PATCH 36/44] docs: add trailing newline to attestation-verifier-contract.md editorconfig-checker requires a final newline (insert_final_newline); the doc rewrite left the file without one, failing Fast CI checks. --- docs/design/attestation-verifier-contract.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index f8aa29f3ac..3ddb42ca58 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -574,4 +574,4 @@ E2E tests in `crates/e2e-tests` deploy either the real `tee-verifier` (when the [monitor-attestation-removal]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/tee/remote_attestation.rs#L209 [submit-participant-info]: https://github.com/near/mpc/blob/efe49230bb66854c55bba080e7610e42f9221506/crates/contract/src/lib.rs#L754-L782 [launcher-pattern]: https://github.com/near/mpc/blob/efe49230bb66854c55bba080e7610e42f9221506/docs/tee-lifecycle.md#upgrade -[slack-launcher-discussion]: https://nearone.slack.com/archives/C0B12RKBSAV/p1777897902903889 \ No newline at end of file +[slack-launcher-discussion]: https://nearone.slack.com/archives/C0B12RKBSAV/p1777897902903889 From c3ab60b99e2aeaed9c28f625ce9ac67907c10cef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 13 Jul 2026 13:57:05 +0200 Subject: [PATCH 37/44] test(contract): address pre-review findings on async attestation tests - test-utils/Cargo.toml: sort tee-verifier-interface into dependency order (fixes the cargo-sort Fast CI failure) - test-tee-verifier/Cargo.toml: ignore borsh in cargo-shear (used only via the abi feature, like the sibling tee-verifier crate), avoiding a --deny-warnings failure - test-tee-verifier-types: reword the StubResponse::Panic doc comment to the no-yield flow (it described the removed yield timeout) - tee_verifier.rs: bound the ignored happy-path test's net spend on both sides so a wrongly-retained deposit fails; note why the failure assertion substring-matches; retarget the ignored tests at the fixture follow-up (#3787) --- crates/contract/tests/sandbox/tee_verifier.rs | 26 ++++++++++++++----- crates/test-tee-verifier-types/src/lib.rs | 4 +-- crates/test-tee-verifier/Cargo.toml | 3 +++ crates/test-utils/Cargo.toml | 2 +- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 9d9c2dc5d5..9ac0c400e0 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -126,6 +126,8 @@ async fn assert_submission_failed_cleanly( !failures.is_empty(), "expected the promise chain to fail on a receipt, got: {result:#?}" ); + // Substring-match: near-workspaces keeps `ExecutionOutcome.status` + // `pub(crate)`, so the error is only reachable via the Debug dump. let rendered = format!("{failures:?}"); let expected = expected_error.to_string(); assert!( @@ -229,13 +231,13 @@ async fn submit_participant_info__should_fail_and_store_nothing_on_verifier_cras .await; } -// TODO(#3738): un-ignore once the fixture allowlist setup lands. A Verified +// TODO(#3787): un-ignore once the fixture allowlist setup lands. A Verified // verdict routes through `verify_post_dcap_and_store`, whose allowlist checks // (fixture image/launcher hashes and measurements voted in, submitter using the // fixture keys) must pass before the attestation is stored. With an empty // allowlist the post-DCAP check fails and the submission is rejected instead of // stored, so the happy path cannot be exercised here yet. -#[ignore = "needs fixture allowlist setup to pass the post-DCAP checks; tracked in #3738"] +#[ignore = "needs fixture allowlist setup to pass the post-DCAP checks; tracked in #3787"] #[tokio::test] async fn submit_participant_info__should_store_attestation_on_verified_quote() { // Given: a verifier that returns the report the real verifier would produce @@ -247,8 +249,8 @@ async fn submit_participant_info__should_store_attestation_on_verified_quote() { let result = submit_dstack(&submitter, &contract).await; // Then: the chain succeeds and the attestation is stored; storage is charged - // and the excess deposit refunded (net spend is storage + gas, well under the - // full deposit). + // and the excess deposit refunded, so net spend is storage + gas, well under + // the full deposit. assert!( result.failures().is_empty(), "the verified submission chain must succeed, got: {result:#?}" @@ -257,14 +259,24 @@ async fn submit_participant_info__should_store_attestation_on_verified_quote() { .await .unwrap(); assert!(stored.is_some(), "a verified attestation must be stored"); + + // Bound net spend both sides: storage was charged (> 0), but the excess was + // refunded (< floor). The upper bound catches a wrongly-retained deposit. let balance_after = submitter.view_account().await.unwrap().balance; + let net_spent = balance_before.as_yoctonear() - balance_after.as_yoctonear(); + let refund_floor = NearToken::from_millinear(100).as_yoctonear(); assert!( - balance_after < balance_before, + net_spent > 0, "storage must be charged from the attached deposit" ); + assert!( + net_spent < refund_floor, + "excess deposit must be refunded (net spent {net_spent} yoctoNEAR should be \ + storage + gas, < {refund_floor}); a retained {SUBMIT_DEPOSIT} deposit would exceed this" + ); } -// TODO(#3738): un-ignore once the fixture allowlist setup lands. To OOG, +// TODO(#3787): un-ignore once the fixture allowlist setup lands. To OOG, // `resolve_verification` must reach the heavy RTMR3 replay in the post-DCAP // checks, which needs the allowlist populated and the submitter using the fixture // keys. With an empty allowlist the post-DCAP check fails fast and @@ -273,7 +285,7 @@ async fn submit_participant_info__should_store_attestation_on_verified_quote() { // receipt back atomically: nothing is stored, the runtime refunds the attached // deposit to the predecessor, and `fail_attestation_submission` never fires, so // the chain still surfaces a failed receipt. No timeout is involved. -#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3738"] +#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3787"] #[tokio::test] async fn submit_participant_info__should_fail_and_store_nothing_when_resolve_verification_runs_out_of_gas() { diff --git a/crates/test-tee-verifier-types/src/lib.rs b/crates/test-tee-verifier-types/src/lib.rs index 634d0d1398..06e5344df8 100644 --- a/crates/test-tee-verifier-types/src/lib.rs +++ b/crates/test-tee-verifier-types/src/lib.rs @@ -24,7 +24,7 @@ pub enum StubResponse { /// Return [`tee_verifier_interface::VerificationResult::Rejected`] with this /// reason. Rejected(String), - /// Panic, simulating an unreachable / crashing verifier (the no-verdict path - /// that mpc-contract resolves via the yield timeout). + /// Panic, simulating an unreachable or crashing verifier: the verify-quote + /// receipt fails, which mpc-contract reports as the verifier being unavailable. Panic, } diff --git a/crates/test-tee-verifier/Cargo.toml b/crates/test-tee-verifier/Cargo.toml index 1bfdf1d749..9eb2ca223c 100644 --- a/crates/test-tee-verifier/Cargo.toml +++ b/crates/test-tee-verifier/Cargo.toml @@ -4,6 +4,9 @@ version = { workspace = true } license = { workspace = true } edition = { workspace = true } +[package.metadata.cargo-shear] +ignored = ["borsh"] + [lib] crate-type = ["cdylib", "lib"] diff --git a/crates/test-utils/Cargo.toml b/crates/test-utils/Cargo.toml index 909b3dea9d..94ee48200c 100644 --- a/crates/test-utils/Cargo.toml +++ b/crates/test-utils/Cargo.toml @@ -8,13 +8,13 @@ edition = { workspace = true } cargo-near-build = { workspace = true } hex = { workspace = true } mpc-attestation = { workspace = true, features = ["test-utils", "local-verify"] } -tee-verifier-interface = { workspace = true } mpc-primitives = { workspace = true } near-mpc-contract-interface = { workspace = true } near-sdk = { workspace = true, features = ["non-contract-usage"] } serde_json = { workspace = true } serde_yaml = { workspace = true } sha2 = { workspace = true } +tee-verifier-interface = { workspace = true } [lints] workspace = true From e3395f3b75bdb7fbefed60c77f65d48c84976808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 14 Jul 2026 18:33:03 +0200 Subject: [PATCH 38/44] test(contract): de-duplicate async attestation tests Extract helpers and reuse existing ones to cut boilerplate, with no change to test coverage: - tee_state.rs: reuse create_node_id; add node_id_for for filler-TLS-key nodes; add attestation_expiring_at, store_valid_attestations, and authenticate_as; collapse per-test TEE_UPGRADE_DURATION into one const; reuse gen_participants; merge crate::primitives imports; drop a test whose assertion is subsumed by preserve_node_id_integrity and internal_storage_distinguishes_participants_by_tls_key - attestation_submission.rs: reuse get_participant_node_ids; collapse Running-state matches into assert_matches!; add with_tee_upgrade_grace_period_seconds builder method; remove dead pre-build testing_env! blocks that TestSetupBuilder::build overwrites; fix typos --- crates/contract/src/tee/tee_state.rs | 370 ++++++------------ .../tests/inprocess/attestation_submission.rs | 122 ++---- 2 files changed, 148 insertions(+), 344 deletions(-) diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 3f8171816e..61727edf82 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -601,9 +601,10 @@ pub(crate) enum AttestationCheckError { #[expect(non_snake_case)] mod tests { use super::*; - use crate::primitives::key_state::AuthenticatedParticipantId; - use crate::primitives::test_utils::{ - bogus_ed25519_near_public_key, bogus_ed25519_public_key, gen_participant, gen_participants, + use crate::primitives::{ + key_state::AuthenticatedParticipantId, + participants::{ParticipantId, ParticipantInfo}, + test_utils::{bogus_ed25519_near_public_key, bogus_ed25519_public_key, gen_participants}, }; use crate::tee::test_utils::set_block_timestamp; use assert_matches::assert_matches; @@ -614,6 +615,8 @@ mod tests { use near_sdk::testing_env; use std::time::Duration; + const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); + /// Helper to set up the testing environment with a specific signer fn set_signer(account_id: &AccountId, public_key: &near_sdk::PublicKey) { let mut builder = VMContextBuilder::new(); @@ -623,11 +626,54 @@ mod tests { testing_env!(builder.build()); } + fn create_node_id(account_id: &AccountId, tls_public_key: &Ed25519PublicKey) -> NodeId { + NodeId { + account_id: account_id.clone(), + tls_public_key: tls_public_key.clone(), + account_public_key: bogus_ed25519_public_key(), + } + } + + fn node_id_for(account_id: &AccountId) -> NodeId { + create_node_id(account_id, &bogus_ed25519_public_key()) + } + + fn store_valid_attestations( + tee_state: &mut TeeState, + participants: &[(AccountId, ParticipantId, ParticipantInfo)], + upgrade_duration: Duration, + ) { + for (account_id, _, participant_info) in participants { + let node_id = create_node_id(account_id, &participant_info.tls_public_key); + tee_state + .verify_and_store_mock(node_id, MockAttestation::Valid, upgrade_duration) + .expect("mock attestation is valid"); + } + } + + fn attestation_expiring_at(expiry_secs: u64) -> MockAttestation { + MockAttestation::WithConstraints { + mpc_docker_image_hash: None, + launcher_docker_compose_hash: None, + expiry_timestamp_seconds: Some(expiry_secs), + expected_measurements: None, + } + } + + /// Sets `account_id` as the signer and authenticates it against `participants`. + fn authenticate_as( + account_id: &AccountId, + participants: &Participants, + ) -> AuthenticatedParticipantId { + let mut ctx = VMContextBuilder::new(); + ctx.signer_account_id(account_id.clone()); + testing_env!(ctx.build()); + AuthenticatedParticipantId::new(participants).unwrap() + } + #[test] fn clean_non_participant_votes__should_not_touch_attestations() { // Given - const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10000); - let mut tee_state = TeeState::default(); // Create some test participants using test utils @@ -638,21 +684,13 @@ mod tests { let participant_nodes: Vec = participants .participants() .iter() - .map(|(account_id, _, p_info)| NodeId { - account_id: account_id.clone(), - tls_public_key: p_info.tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - }) + .map(|(account_id, _, p_info)| create_node_id(account_id, &p_info.tls_public_key)) .collect(); // Add TEE information for all participants and non-participant let local_attestation = MockAttestation::Valid; - let non_participant_uid = NodeId { - account_id: non_participant.clone(), - account_public_key: bogus_ed25519_public_key(), - tls_public_key: bogus_ed25519_public_key(), - }; + let non_participant_uid = node_id_for(&non_participant); for node_id in &participant_nodes { tee_state @@ -704,29 +742,13 @@ mod tests { let mut tee_state = TeeState::default(); - let fresh_node = NodeId { - account_id: "fresh.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; - let stale_node = NodeId { - account_id: "stale.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; + let fresh_account: AccountId = "fresh.near".parse().unwrap(); + let stale_account: AccountId = "stale.near".parse().unwrap(); + let fresh_node = node_id_for(&fresh_account); + let stale_node = node_id_for(&stale_account); - 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 = MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(STALE_EXPIRY_SECONDS), - expected_measurements: None, - }; + let fresh = attestation_expiring_at(FRESH_EXPIRY_SECONDS); + let stale = attestation_expiring_at(STALE_EXPIRY_SECONDS); tee_state .verify_and_store_mock(fresh_node.clone(), fresh, Duration::from_secs(0)) @@ -765,19 +787,11 @@ mod tests { let mut tee_state = TeeState::default(); - let expired = MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(EXPIRY_SECONDS), - expected_measurements: None, - }; + let expired = attestation_expiring_at(EXPIRY_SECONDS); for idx in 0..10 { - let node_id = NodeId { - account_id: format!("node{idx}.near").parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; + let account_id: AccountId = format!("node{idx}.near").parse().unwrap(); + let node_id = node_id_for(&account_id); tee_state .verify_and_store_mock(node_id, expired.clone(), Duration::from_secs(0)) .unwrap(); @@ -810,17 +824,9 @@ mod tests { testing_env!(VMContextBuilder::new().block_timestamp(0).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 attestation = MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(FUTURE_EXPIRY_SECONDS), - expected_measurements: None, - }; + let account_id: AccountId = "alice.near".parse().unwrap(); + let node_id = node_id_for(&account_id); + let attestation = attestation_expiring_at(FUTURE_EXPIRY_SECONDS); tee_state .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); @@ -840,17 +846,12 @@ mod tests { #[test] fn updating_existing_participant_returns_existing_participant() { // given - const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10000); let mut tee_state = TeeState::default(); let participant: AccountId = "dave.near".parse().unwrap(); let local_attestation = MockAttestation::Valid; - let participant_id = NodeId { - account_id: participant.clone(), - account_public_key: bogus_ed25519_public_key(), - tls_public_key: bogus_ed25519_public_key(), - }; + let participant_id = node_id_for(&participant); let insertion_result = tee_state.verify_and_store_mock( participant_id.clone(), @@ -880,11 +881,8 @@ mod tests { fn verify_and_store_mock__should_increase_storage_size() { // given 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 account_id: AccountId = "alice.near".parse().unwrap(); + let node_id = node_id_for(&account_id); let attestation = MockAttestation::Valid; // when @@ -905,11 +903,8 @@ mod tests { // 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 account_id: AccountId = "alice.near".parse().unwrap(); + let node_id = node_id_for(&account_id); let storage_before = env::storage_usage(); // When @@ -927,40 +922,12 @@ mod tests { ); } - #[test] - fn verify_and_store_mock__should_index_by_tls_key() { - // given - 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 attestation = MockAttestation::Valid; - - // when - tee_state - .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) - .unwrap(); - - // then - assert!( - tee_state - .stored_attestations - .contains_key(&node_id.tls_public_key), - "Entry should be strictly retrievable using the TLS public key" - ); - } - #[test] fn verify_and_store_mock__should_preserve_node_id_integrity() { // given 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 account_id: AccountId = "alice.near".parse().unwrap(); + let node_id = node_id_for(&account_id); let attestation = MockAttestation::Valid; // when @@ -985,17 +952,10 @@ mod tests { // given let mut tee_state = TeeState::default(); - let node_1 = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; - - let node_2 = NodeId { - account_id: "bob.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; + let alice: AccountId = "alice.near".parse().unwrap(); + let bob: AccountId = "bob.near".parse().unwrap(); + let node_1 = node_id_for(&alice); + let node_2 = node_id_for(&bob); // when tee_state @@ -1031,22 +991,14 @@ mod tests { fn re_verify_validates_fresh_attestation() { // given let mut tee_state = TeeState::default(); - let node_id = NodeId { - account_id: "fresh.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; + let account_id: AccountId = "fresh.near".parse().unwrap(); + let node_id = node_id_for(&account_id); const NOW_SECONDS: u64 = 1000; testing_env!(VMContextBuilder::new().block_timestamp(NOW_SECONDS).build()); - let attestation = MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(NOW_SECONDS), - expected_measurements: None, - }; + let attestation = attestation_expiring_at(NOW_SECONDS); tee_state .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) @@ -1063,23 +1015,15 @@ mod tests { fn test_re_verify_rejects_expired_attestation() { // given let mut tee_state = TeeState::default(); - let node_id = NodeId { - account_id: "about_to_be_expired.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; + let account_id: AccountId = "about_to_be_expired.near".parse().unwrap(); + let node_id = node_id_for(&account_id); const EXPIRY_TIMESTAMP_SECONDS: u64 = 1000; const ELAPSED_SECONDS: u64 = 200; testing_env!(VMContextBuilder::new().block_timestamp(0).build()); - let attestation = MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(EXPIRY_TIMESTAMP_SECONDS), - expected_measurements: None, - }; + let attestation = attestation_expiring_at(EXPIRY_TIMESTAMP_SECONDS); tee_state .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) @@ -1105,11 +1049,8 @@ mod tests { fn re_verify_succeeds_within_expiry_time() { // given let mut tee_state = TeeState::default(); - let node_id = NodeId { - account_id: "valid_check.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; + let account_id: AccountId = "valid_check.near".parse().unwrap(); + let node_id = node_id_for(&account_id); const EXPIRY_TIMESTAMP_SECONDS: u64 = 1000; @@ -1119,12 +1060,7 @@ mod tests { .build() ); - let attestation = MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(EXPIRY_TIMESTAMP_SECONDS), - expected_measurements: None, - }; + let attestation = attestation_expiring_at(EXPIRY_TIMESTAMP_SECONDS); tee_state .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) @@ -1145,11 +1081,8 @@ mod tests { fn test_re_verify_returns_invalid_for_missing_node() { // given let tee_state = TeeState::default(); - let node_id = NodeId { - account_id: "ghost.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; + let account_id: AccountId = "ghost.near".parse().unwrap(); + let node_id = node_id_for(&account_id); // when let status = tee_state.reverify_participants(&node_id, Duration::from_secs(0)); @@ -1288,15 +1221,6 @@ mod tests { /// Grace period for TEE upgrade deadline used in validate_tee() tests const TEST_GRACE_PERIOD: Duration = Duration::from_secs(10); - /// Helper to create a NodeId from participant data - fn create_node_id(account_id: &AccountId, tls_public_key: &Ed25519PublicKey) -> NodeId { - NodeId { - account_id: account_id.clone(), - tls_public_key: tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - } - } - /// Helper to extract account IDs from participants for assertion comparisons fn account_ids(participants: &Participants) -> Vec { participants @@ -1313,12 +1237,11 @@ mod tests { let tee_upgrade_duration = Duration::MAX; // Add valid attestations for all participants - for (account_id, _, participant_info) in participants.participants().iter() { - let node_id = create_node_id(account_id, &participant_info.tls_public_key); - tee_state - .verify_and_store_mock(node_id, MockAttestation::Valid, tee_upgrade_duration) - .expect("mock attestation is valid"); - } + store_valid_attestations( + &mut tee_state, + participants.participants(), + tee_upgrade_duration, + ); let validation_result = tee_state.reverify_and_cleanup_participants(&participants, TEST_GRACE_PERIOD); @@ -1334,12 +1257,7 @@ mod tests { let tee_upgrade_duration = Duration::MAX; // Add valid attestations for only first 2 participants - 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 - .verify_and_store_mock(node_id, MockAttestation::Valid, tee_upgrade_duration) - .expect("mock attestation is valid"); - } + store_valid_attestations(&mut tee_state, &participant_list[..2], tee_upgrade_duration); // Third participant has no attestation let validation_result = @@ -1364,22 +1282,12 @@ mod tests { let participant_list: Vec<_> = participants.participants().to_vec(); // Add valid attestations for first 2 participants - 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 - .verify_and_store_mock(node_id, MockAttestation::Valid, tee_upgrade_duration) - .expect("mock attestation is valid"); - } + store_valid_attestations(&mut tee_state, &participant_list[..2], tee_upgrade_duration); // 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 = MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(expiry_time_secs), - expected_measurements: None, - }; + let expiring_attestation = attestation_expiring_at(expiry_time_secs); tee_state .verify_and_store_mock(node_id, expiring_attestation, tee_upgrade_duration) .expect("mock attestation is valid"); @@ -1414,12 +1322,7 @@ 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 { - MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(expiry_time_secs), - expected_measurements: None, - } + attestation_expiring_at(expiry_time_secs) } else { MockAttestation::Valid }; @@ -1444,16 +1347,11 @@ mod tests { #[test] 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); - let mut tee_state = TeeState::default(); let tls_public_key = bogus_ed25519_public_key(); - let alice_node = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - }; + let alice: AccountId = "alice.near".parse().unwrap(); + let alice_node = create_node_id(&alice, &tls_public_key); tee_state .verify_and_store_mock( alice_node.clone(), @@ -1463,11 +1361,8 @@ mod tests { .expect("initial insertion should succeed"); // When: a different account submits an attestation for the same TLS key. - let attacker_node = NodeId { - account_id: "attacker.near".parse().unwrap(), - tls_public_key: tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - }; + let attacker: AccountId = "attacker.near".parse().unwrap(); + let attacker_node = create_node_id(&attacker, &tls_public_key); let result = tee_state.verify_and_store_mock( attacker_node, MockAttestation::Valid, @@ -1489,26 +1384,17 @@ mod tests { #[test] 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); - let mut tee_state = TeeState::default(); let tls_public_key = bogus_ed25519_public_key(); - let initial_node = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - }; + let alice: AccountId = "alice.near".parse().unwrap(); + let initial_node = create_node_id(&alice, &tls_public_key); tee_state .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. - let rotated_node = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key, - account_public_key: bogus_ed25519_public_key(), - }; + let rotated_node = create_node_id(&alice, &tls_public_key); let result = tee_state.verify_and_store_mock( rotated_node.clone(), MockAttestation::Valid, @@ -1531,7 +1417,6 @@ mod tests { fn revert_dstack_store__should_restore_the_displaced_entry_on_update() { // Given: `alice` has an attestation, then updates it (the second insertion // returns the displaced original wrapped in `UpdatedExistingParticipant`). - const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); let mut tee_state = TeeState::default(); let account_id = "alice.near".parse().unwrap(); let tls_public_key = bogus_ed25519_public_key(); @@ -1571,7 +1456,6 @@ mod tests { #[test] fn revert_dstack_store__should_remove_the_newly_inserted_entry() { // Given: a brand-new attestation for `alice` (no prior entry displaced). - const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); let mut tee_state = TeeState::default(); let account_id = "alice.near".parse().unwrap(); let tls_public_key = bogus_ed25519_public_key(); @@ -1599,12 +1483,7 @@ mod tests { let tee_upgrade_duration = Duration::MAX; // Add valid attestations for first 2 participants - 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 - .verify_and_store_mock(node_id, MockAttestation::Valid, tee_upgrade_duration) - .expect("mock attestation is valid"); - } + store_valid_attestations(&mut tee_state, &participant_list[..2], tee_upgrade_duration); // Add invalid attestation for third participant let (account_id, _, participant_info) = &participant_list[2]; @@ -1632,23 +1511,19 @@ mod tests { #[test] fn test_clean_non_participant_votes_removes_stale_votes() { // Build 5 participants - let mut all_participants = Participants::new(); - let mut account_ids = Vec::new(); - for i in 0..5 { - let (account_id, info) = gen_participant(i); - account_ids.push(account_id.clone()); - all_participants.insert(account_id, info).unwrap(); - } + let all_participants = gen_participants(5); + let account_ids: Vec = all_participants + .participants() + .iter() + .map(|(account_id, _, _)| account_id.clone()) + .collect(); let mut tee_state = TeeState::default(); // P0 and P1 vote for a malicious hash before resharing let malicious_hash = NodeImageHash::from([0xAA; 32]); for account_id in &account_ids[0..2] { - let mut ctx = VMContextBuilder::new(); - ctx.signer_account_id(account_id.clone()); - testing_env!(ctx.build()); - let auth_id = AuthenticatedParticipantId::new(&all_participants).unwrap(); + let auth_id = authenticate_as(account_id, &all_participants); tee_state.votes.vote(malicious_hash, &auth_id); } assert_eq!(tee_state.votes.proposal_by_account.len(), 2); @@ -1664,10 +1539,7 @@ mod tests { // P2 votes for the same malicious hash — should be only 1 vote, not 3 let p2_account = &account_ids[2]; - let mut ctx = VMContextBuilder::new(); - ctx.signer_account_id(p2_account.clone()); - testing_env!(ctx.build()); - let auth_id = AuthenticatedParticipantId::new(&new_participants).unwrap(); + let auth_id = authenticate_as(p2_account, &new_participants); let vote_count = tee_state.votes.vote(malicious_hash, &auth_id); assert_eq!(vote_count, 1, "Only the fresh vote from P2 should count"); } @@ -1675,21 +1547,17 @@ mod tests { /// Verifies that clean_non_participants also removes stale launcher and measurement votes. #[test] fn test_clean_non_participants_removes_stale_launcher_and_measurement_votes() { - let mut all_participants = Participants::new(); - let mut account_ids = Vec::new(); - for i in 0..3 { - let (account_id, info) = gen_participant(i); - account_ids.push(account_id.clone()); - all_participants.insert(account_id, info).unwrap(); - } + let all_participants = gen_participants(3); + let account_ids: Vec = all_participants + .participants() + .iter() + .map(|(account_id, _, _)| account_id.clone()) + .collect(); let mut tee_state = TeeState::default(); // P0 votes for a launcher hash - let mut ctx = VMContextBuilder::new(); - ctx.signer_account_id(account_ids[0].clone()); - testing_env!(ctx.build()); - let auth_id = AuthenticatedParticipantId::new(&all_participants).unwrap(); + let auth_id = authenticate_as(&account_ids[0], &all_participants); let launcher_action = LauncherVoteAction::Add(LauncherImageHash::from([0xBB; 32])); tee_state.launcher_votes.vote(launcher_action, &auth_id); diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index abd0ea1cec..c7dcde3022 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -36,7 +36,7 @@ const ATTESTATION_STORAGE_DEPOSIT: NearToken = const DEFAULT_PARTICIPANT_COUNT: usize = 3; const DEFAULT_THRESHOLD_SIZE: u64 = 2; -const DEFAUTL_CONTRACT_PROTOCOL_STATE: ContractProtocolState = ContractProtocolState::Running; +const DEFAULT_CONTRACT_PROTOCOL_STATE: ContractProtocolState = ContractProtocolState::Running; enum ContractProtocolState { Running, @@ -61,7 +61,7 @@ impl TestSetupBuilder { } } - fn with_partcipant_count(mut self, participant_count: usize) -> Self { + fn with_participant_count(mut self, participant_count: usize) -> Self { self.participant_count = Some(participant_count); self } @@ -76,6 +76,13 @@ impl TestSetupBuilder { self } + fn with_tee_upgrade_grace_period_seconds(self, seconds: u64) -> Self { + self.with_init_config(InitConfig { + tee_upgrade_deadline_duration_seconds: Some(seconds), + ..Default::default() + }) + } + fn with_contract_protocol_state( mut self, contract_protocol_state: ContractProtocolState, @@ -90,7 +97,7 @@ impl TestSetupBuilder { let threshold = self.threshold.unwrap_or(DEFAULT_THRESHOLD_SIZE); let contract_protocol_state = self .contract_protocol_state - .unwrap_or(DEFAUTL_CONTRACT_PROTOCOL_STATE); + .unwrap_or(DEFAULT_CONTRACT_PROTOCOL_STATE); // 2. Data Generation let participants = gen_participants(participant_count); @@ -303,14 +310,8 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { const PARTICIPANT_COUNT: usize = 2; const THRESHOLD: u64 = 2; - testing_env!( - VMContextBuilder::new() - .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) - .build() - ); - let mut setup = TestSetupBuilder::new() - .with_partcipant_count(PARTICIPANT_COUNT) + .with_participant_count(PARTICIPANT_COUNT) .with_threshold(THRESHOLD) .build(); @@ -390,30 +391,15 @@ fn clean_tee_status__should_not_touch_attestations() { const PARTICIPANT_COUNT: usize = 2; // After resharing removed one participant const THRESHOLD: u64 = 2; - testing_env!( - VMContextBuilder::new() - .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) - .build() - ); - // Create contract in Running state with 2 current participants let mut setup = TestSetupBuilder::new() - .with_partcipant_count(PARTICIPANT_COUNT) + .with_participant_count(PARTICIPANT_COUNT) .with_threshold(THRESHOLD) .build(); // Submit TEE info for current 2 participants (all have valid attestations) let valid_attestation = Attestation::Mock(MockAttestation::Valid); - let participant_nodes: Vec = setup - .participants_list - .iter() - .take(PARTICIPANT_COUNT) - .map(|(account_id, _, participant_info)| NodeId { - account_id: account_id.clone(), - tls_public_key: participant_info.tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - }) - .collect(); + let participant_nodes = setup.get_participant_node_ids(); for node_id in &participant_nodes { setup.submit_attestation_for_node(node_id, valid_attestation.clone()); } @@ -433,12 +419,11 @@ fn clean_tee_status__should_not_touch_attestations() { INITIAL_TEE_ACCOUNTS ); - let running_state = match setup.contract.state() { - ProtocolContractState::Running(r) => r, - _ => panic!("Should be in Running state"), - }; - let participant_count = running_state.parameters.participants.participants.len(); - assert_eq!(participant_count, PARTICIPANT_COUNT); + assert_matches!( + setup.contract.state(), + ProtocolContractState::Running(r) + if r.parameters.participants.participants.len() == PARTICIPANT_COUNT + ); // When: clean_tee_status runs. setup.contract.clean_tee_status().unwrap(); @@ -450,17 +435,10 @@ fn clean_tee_status__should_not_touch_attestations() { ); // State should remain Running with same participant count - let final_running_state = match setup.contract.state() { - ProtocolContractState::Running(r) => r, - _ => panic!("Should still be Running after cleanup"), - }; - assert_eq!( - final_running_state - .parameters - .participants - .participants - .len(), - PARTICIPANT_COUNT + assert_matches!( + setup.contract.state(), + ProtocolContractState::Running(r) + if r.parameters.participants.participants.len() == PARTICIPANT_COUNT ); } @@ -475,15 +453,8 @@ fn clean_invalid_attestations__should_remove_expired_entries() { const EXPIRY_SECONDS: u64 = 1_000; const NOW_NS: u64 = 5_000 * NANOS_IN_SECOND; - testing_env!( - VMContextBuilder::new() - .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) - .block_timestamp(0) - .build() - ); - let mut setup = TestSetupBuilder::new() - .with_partcipant_count(PARTICIPANT_COUNT) + .with_participant_count(PARTICIPANT_COUNT) .with_threshold(THRESHOLD) .build(); @@ -497,14 +468,7 @@ fn clean_invalid_attestations__should_remove_expired_entries() { // init_running seeds one mock `Valid` attestation per participant. Overwrite the // first participant's entry with an expiring one, and add a brand-new entry for an // outsider account. - let participant_node = { - let (account_id, _, info) = &setup.participants_list[0]; - NodeId { - account_id: account_id.clone(), - tls_public_key: info.tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - } - }; + let participant_node = setup.get_participant_node_ids()[0].clone(); setup.submit_attestation_for_node(&participant_node, expiring_attestation.clone()); let stale_node = NodeId { @@ -537,12 +501,6 @@ fn clean_invalid_attestations__should_remove_expired_entries() { #[test] fn clean_invalid_attestations__should_reject_when_not_running() { // Given: contract sitting in Initializing state. - testing_env!( - VMContextBuilder::new() - .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) - .block_timestamp(0) - .build() - ); let mut setup = TestSetupBuilder::new() .with_contract_protocol_state(ContractProtocolState::Initializing) @@ -584,13 +542,8 @@ fn only_latest_hash_after_grace_period() { const SECOND_ENTRY_TIME_NS: u64 = 4 * NANOS_IN_SECOND; // 1s const GRACE_PERIOD_NS: u64 = 10 * NANOS_IN_SECOND; // 10s - let init_config = near_mpc_contract_interface::types::InitConfig { - tee_upgrade_deadline_duration_seconds: Some(GRACE_PERIOD_NS / NANOS_IN_SECOND), - ..Default::default() - }; - let mut setup = TestSetupBuilder::new() - .with_init_config(init_config) + .with_tee_upgrade_grace_period_seconds(GRACE_PERIOD_NS / NANOS_IN_SECOND) .build(); let old_hash = [1; 32]; @@ -629,12 +582,8 @@ fn latest_inserted_image_hash_takes_precedence_on_equal_time_stamps() { const INITIAL_TIME: u64 = 1; const GRACE_PERIOD: u64 = 10; - let init_config = near_mpc_contract_interface::types::InitConfig { - tee_upgrade_deadline_duration_seconds: Some(GRACE_PERIOD), - ..Default::default() - }; let mut setup = TestSetupBuilder::new() - .with_init_config(init_config) + .with_tee_upgrade_grace_period_seconds(GRACE_PERIOD) .build(); let hash_1 = [1; 32]; @@ -672,13 +621,8 @@ fn hash_grace_period_depends_on_successor_entry_time_not_latest() { const THIRD_ENTRY_TIME_NS: u64 = 7 * NANOS_IN_SECOND; const GRACE_PERIOD_TIME_NS: u64 = 10 * NANOS_IN_SECOND; - let init_config = near_mpc_contract_interface::types::InitConfig { - tee_upgrade_deadline_duration_seconds: Some(GRACE_PERIOD_TIME_NS / NANOS_IN_SECOND), - ..Default::default() - }; - let mut test_setup = TestSetupBuilder::new() - .with_init_config(init_config) + .with_tee_upgrade_grace_period_seconds(GRACE_PERIOD_TIME_NS / NANOS_IN_SECOND) .build(); let first_code_hash = [1; 32]; @@ -752,12 +696,8 @@ fn latest_image_never_expires_if_its_not_superseded() { const START_TIME_SECONDS: u64 = 1; const GRACE_PERIOD_SECONDS: u64 = 10; - let init_config = near_mpc_contract_interface::types::InitConfig { - tee_upgrade_deadline_duration_seconds: Some(GRACE_PERIOD_SECONDS), - ..Default::default() - }; let mut test_setup = TestSetupBuilder::new() - .with_init_config(init_config) + .with_tee_upgrade_grace_period_seconds(GRACE_PERIOD_SECONDS) .build(); let only_image_code_hash = [123; 32]; @@ -811,12 +751,8 @@ fn nodes_can_start_with_old_valid_hashes_during_grace_period() { const GRACE_PERIOD_NANOS: u64 = GRACE_PERIOD_SECONDS * NANOS_IN_SECOND; const HASH_DEPLOYMENT_INTERVAL_NANOS: u64 = 3 * NANOS_IN_SECOND; - let init_config = near_mpc_contract_interface::types::InitConfig { - tee_upgrade_deadline_duration_seconds: Some(GRACE_PERIOD_SECONDS), - ..Default::default() - }; let mut test_setup = TestSetupBuilder::new() - .with_init_config(init_config) + .with_tee_upgrade_grace_period_seconds(GRACE_PERIOD_SECONDS) .build(); let hash_v1 = [1; 32]; // Original version From 3436beb9b0108847e815eef99f449a4e026cd3e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 14 Jul 2026 19:08:37 +0200 Subject: [PATCH 39/44] test(contract): cover post-DCAP-fail, dstack store rejection, and deposit guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill async-attestation coverage gaps that opened after the base PR settled on the no-yield design: - sandbox: a Verified verdict that fails the post-DCAP checks (empty allowlist) still refunds and fails the submission in a separate receipt, storing nothing — the resolve_verification failure mode not blocked by the fixture-allowlist work - unit: verify_and_store_dstack rejects and stores nothing when the post-DCAP checks fail - inprocess: submit_participant_info rejects a deposit below the storage cost with InsufficientDeposit - sandbox: extend the mock-success test to assert the excess deposit is refunded --- crates/contract/src/tee/tee_state.rs | 24 +++++++++++- .../tests/inprocess/attestation_submission.rs | 37 ++++++++++++++++++- crates/contract/tests/sandbox/tee.rs | 22 ++++++++--- crates/contract/tests/sandbox/tee_verifier.rs | 32 ++++++++++++++++ 4 files changed, 108 insertions(+), 7 deletions(-) diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 61727edf82..44cd94d38e 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -608,12 +608,13 @@ mod tests { }; use crate::tee::test_utils::set_block_timestamp; use assert_matches::assert_matches; - use mpc_attestation::attestation::MockAttestation; + use mpc_attestation::attestation::{Attestation, MockAttestation}; use mpc_primitives::hash::{LauncherImageHash, NodeImageHash}; use near_account_id::AccountId; use near_sdk::test_utils::VMContextBuilder; use near_sdk::testing_env; use std::time::Duration; + use test_utils::attestation::{mock_dstack_attestation, verified_report}; const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); @@ -1500,6 +1501,27 @@ mod tests { ) } + #[test] + fn verify_and_store_dstack__should_reject_and_store_nothing_when_post_dcap_checks_fail() { + // Given: an empty allowlist, so any Dstack attestation fails the post-DCAP checks. + let mut tee_state = TeeState::default(); + let Attestation::Dstack(dstack) = mock_dstack_attestation() else { + panic!("fixture is a Dstack attestation"); + }; + let node_id = node_id_for(&"alice.near".parse().unwrap()); + + // When: it is verified and stored. + let result = + tee_state.verify_and_store_dstack(node_id, &dstack, &verified_report(), Duration::MAX); + + // Then: it is rejected and nothing is stored. + assert_matches!( + result, + Err(AttestationSubmissionError::InvalidAttestation(_)) + ); + assert!(tee_state.stored_attestations.is_empty()); + } + /// Stale CodeHashesVotes entries from removed participants must not count toward /// quorum after resharing. /// diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index c7dcde3022..7e54221907 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -3,7 +3,7 @@ use mpc_contract::{ MpcContract, crypto_shared::types::PublicKeyExtended, - errors::{Error, TeeError}, + errors::{Error, InvalidParameters, TeeError}, primitives::{ key_state::{AttemptId, EpochId, KeyForDomain, Keyset}, participants::{ParticipantId, ParticipantInfo}, @@ -365,6 +365,41 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { assert_eq!(stored_before, stored_after); } +/// Rejects a submission whose attached deposit is below the storage cost, so a caller +/// cannot store an attestation without paying for it. +#[test] +fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() { + // Given: a participant whose submission context attaches only 1 yoctoNEAR. + let mut setup = TestSetupBuilder::new().build(); + let node = setup.get_participant_node_ids()[0].clone(); + testing_env!( + VMContextBuilder::new() + .signer_account_id(node.account_id.clone()) + .predecessor_account_id(node.account_id.clone()) + .attached_deposit(NearToken::from_yoctonear(1)) + .build() + ); + + // When: that participant submits a valid mock attestation. + let result = setup + .contract + .submit_participant_info( + Attestation::Mock(MockAttestation::Valid), + node.tls_public_key.clone(), + ) + .map(|_| ()); + + // Then: the storage charge rejects it, with the required cost exceeding the attached deposit. + // (The mock path stores before charging and relies on the runtime rolling the receipt back on + // this Err; that rollback is a chain-level guarantee not modeled by the in-process VM, so we + // assert only the error here.) + assert_matches!( + &result, + Err(Error::InvalidParameters(InvalidParameters::InsufficientDeposit { attached, required })) + if required > attached + ); +} + /// Test that a `Dstack` submission is rejected when no verifier is configured. #[test] fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() { diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index f38123fcb7..769cc69d14 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -267,17 +267,29 @@ async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result .with_protocols(ALL_PROTOCOLS) .build() .await; - let mock_attestation = Attestation::Mock(MockAttestation::Valid); - let tls_key = p2p_tls_key().into(); + let submitter = &mpc_signer_accounts[0]; + let balance_before = submitter.view_account().await?.balance; + let success = submit_participant_info( - &mpc_signer_accounts[0], + submitter, &contract, - &mock_attestation, - &tls_key, + &Attestation::Mock(MockAttestation::Valid), + &p2p_tls_key().into(), ) .await? .is_success(); assert!(success); + + // The submission attaches 1 NEAR but the contract charges only the measured storage cost + // and refunds the rest, so net spend is storage + gas, well under any fraction of the + // deposit; a retained deposit (e.g. 0.5 NEAR) would exceed this ceiling. + let balance_after = submitter.view_account().await?.balance; + let net_spent = balance_before.as_yoctonear() - balance_after.as_yoctonear(); + let refund_floor = NearToken::from_millinear(100).as_yoctonear(); + assert!( + net_spent < refund_floor, + "excess deposit must be refunded (net spent {net_spent} yoctoNEAR should be storage + gas, < {refund_floor})" + ); Ok(()) } diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 9ac0c400e0..4945d0172b 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -231,6 +231,38 @@ async fn submit_participant_info__should_fail_and_store_nothing_on_verifier_cras .await; } +#[tokio::test] +async fn submit_participant_info__should_refund_and_store_nothing_when_post_dcap_checks_fail() { + // Given: a verifier that returns Verified, but an empty allowed-hash set, so the + // post-DCAP checks in resolve_verification reject the (genuinely verified) quote. + let (_worker, contract, submitter, balance_before) = + setup_with_stub(StubResponse::Verified(verified_report()), None).await; + + // When: a Dstack attestation is submitted. + let result = submit_dstack(&submitter, &contract).await; + + // Then: the failure originates inside the callback (not from the verifier), yet still + // refunds and fails the submission in a separate receipt, storing nothing. Asserted inline + // rather than via assert_submission_failed_cleanly because the error is an + // InvalidAttestation (empty-allowlist rejection), not a TeeError; the empty allowed + // mpc-image-hash list is the first post-DCAP check to reject. + let failures = result.failures(); + assert!( + !failures.is_empty(), + "expected the promise chain to fail on a receipt, got: {result:#?}" + ); + let rendered = format!("{failures:?}"); + assert!( + rendered.contains("the allowed mpc image hashes list is empty"), + "expected the empty-allowlist rejection, got: {rendered}" + ); + let stored = get_participant_attestation(&contract, &p2p_tls_key().into()) + .await + .unwrap(); + assert!(stored.is_none(), "nothing should be stored on failure"); + assert_deposit_refunded(&submitter, balance_before).await; +} + // TODO(#3787): un-ignore once the fixture allowlist setup lands. A Verified // verdict routes through `verify_post_dcap_and_store`, whose allowlist checks // (fixture image/launcher hashes and measurements voted in, submitter using the From 29483b5e7c1d4f3a12fad7ef7a71f531636e80c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 15 Jul 2026 15:01:09 +0200 Subject: [PATCH 40/44] test(contract): tighten attestation test assertions - verify_and_store_mock/revert tests: compare whole NodeAttestation values via a shared mock_valid_attestation helper instead of single fields; drop .expect()/.unwrap() lookups in favor of assert_eq!(map.get(k), Some(&entry)) - bind error payloads instead of discarding them (Invalid(msg), InvalidState, VerificationError variants); replace let-else+panic! and Ok(_)=>panic! with assert_matches! binding blocks / expect_err - rename attestation_expiring_at -> mock_attestation_with_expiry - derive PartialEq/Eq on ParticipantInsertion and Clone on NodeAttestation so insertions compare as whole values - deposit/refund tests: measure the storage stake from the contract's byte growth times env::storage_byte_cost() and assert net_spent == storage_stake + total_gas_fee(result) exactly, replacing hardcoded refund_floor/gas_ceiling and STORAGE_COST_PER_BYTE constants; add total_gas_fee helper --- crates/contract/src/tee/tee_state.rs | 111 ++++++++++-------- .../tests/inprocess/attestation_submission.rs | 16 ++- crates/contract/tests/sandbox/tee.rs | 57 ++++----- crates/contract/tests/sandbox/tee_verifier.rs | 70 +++++------ .../tests/sandbox/utils/mpc_contract.rs | 11 ++ 5 files changed, 143 insertions(+), 122 deletions(-) diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 44cd94d38e..e4f9089e4b 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -47,7 +47,7 @@ pub enum AttestationSubmissionError { TlsKeyOwnedByOtherAccount, } -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] #[expect(clippy::large_enum_variant)] pub(crate) enum ParticipantInsertion { NewlyInsertedParticipant, @@ -66,7 +66,7 @@ pub enum TeeValidationResult { }, } -#[derive(Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) @@ -608,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::{Attestation, MockAttestation, VerificationError}; use mpc_primitives::hash::{LauncherImageHash, NodeImageHash}; use near_account_id::AccountId; use near_sdk::test_utils::VMContextBuilder; @@ -652,7 +652,7 @@ mod tests { } } - fn attestation_expiring_at(expiry_secs: u64) -> MockAttestation { + fn mock_attestation_with_expiry(expiry_secs: u64) -> MockAttestation { MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, @@ -661,7 +661,14 @@ mod tests { } } - /// Sets `account_id` as the signer and authenticates it against `participants`. + fn mock_valid_attestation(node_id: NodeId) -> NodeAttestation { + NodeAttestation { + node_id, + verified_attestation: VerifiedAttestation::Mock(MockAttestation::Valid), + } + } + + /// Sets [`AccountId`] as the signer and authenticates it against [`Participants`]. fn authenticate_as( account_id: &AccountId, participants: &Participants, @@ -748,8 +755,8 @@ mod tests { let fresh_node = node_id_for(&fresh_account); let stale_node = node_id_for(&stale_account); - let fresh = attestation_expiring_at(FRESH_EXPIRY_SECONDS); - let stale = attestation_expiring_at(STALE_EXPIRY_SECONDS); + let fresh = mock_attestation_with_expiry(FRESH_EXPIRY_SECONDS); + let stale = mock_attestation_with_expiry(STALE_EXPIRY_SECONDS); tee_state .verify_and_store_mock(fresh_node.clone(), fresh, Duration::from_secs(0)) @@ -788,7 +795,7 @@ mod tests { let mut tee_state = TeeState::default(); - let expired = attestation_expiring_at(EXPIRY_SECONDS); + let expired = mock_attestation_with_expiry(EXPIRY_SECONDS); for idx in 0..10 { let account_id: AccountId = format!("node{idx}.near").parse().unwrap(); @@ -827,7 +834,7 @@ mod tests { let mut tee_state = TeeState::default(); let account_id: AccountId = "alice.near".parse().unwrap(); let node_id = node_id_for(&account_id); - let attestation = attestation_expiring_at(FUTURE_EXPIRY_SECONDS); + let attestation = mock_attestation_with_expiry(FUTURE_EXPIRY_SECONDS); tee_state .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); @@ -872,9 +879,11 @@ mod tests { ); // then - assert_matches!( + assert_eq!( re_insertion_result, - Ok(ParticipantInsertion::UpdatedExistingParticipant(_)) + Ok(ParticipantInsertion::UpdatedExistingParticipant( + mock_valid_attestation(participant_id) + )) ); } @@ -937,14 +946,9 @@ mod tests { .unwrap(); // then - let stored_entry = tee_state - .stored_attestations - .get(&node_id.tls_public_key) - .unwrap(); - assert_eq!( - stored_entry.node_id, node_id, - "The stored NodeId struct must exactly match the inserted one" + tee_state.stored_attestations.get(&node_id.tls_public_key), + Some(&mock_valid_attestation(node_id)) ); } @@ -999,7 +1003,7 @@ mod tests { testing_env!(VMContextBuilder::new().block_timestamp(NOW_SECONDS).build()); - let attestation = attestation_expiring_at(NOW_SECONDS); + let attestation = mock_attestation_with_expiry(NOW_SECONDS); tee_state .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) @@ -1024,7 +1028,7 @@ mod tests { testing_env!(VMContextBuilder::new().block_timestamp(0).build()); - let attestation = attestation_expiring_at(EXPIRY_TIMESTAMP_SECONDS); + let attestation = mock_attestation_with_expiry(EXPIRY_TIMESTAMP_SECONDS); tee_state .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) @@ -1043,7 +1047,7 @@ mod tests { let status = tee_state.reverify_participants(&node_id, Duration::from_secs(0)); // then - assert_matches!(status, TeeQuoteStatus::Invalid(_)); + assert_matches!(status, TeeQuoteStatus::Invalid(msg) if msg.contains("has expired")); } #[test] @@ -1061,7 +1065,7 @@ mod tests { .build() ); - let attestation = attestation_expiring_at(EXPIRY_TIMESTAMP_SECONDS); + let attestation = mock_attestation_with_expiry(EXPIRY_TIMESTAMP_SECONDS); tee_state .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) @@ -1288,7 +1292,7 @@ mod tests { // 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_expiring_at(expiry_time_secs); + let expiring_attestation = mock_attestation_with_expiry(expiry_time_secs); tee_state .verify_and_store_mock(node_id, expiring_attestation, tee_upgrade_duration) .expect("mock attestation is valid"); @@ -1323,7 +1327,7 @@ 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_expiring_at(expiry_time_secs) + mock_attestation_with_expiry(expiry_time_secs) } else { MockAttestation::Valid }; @@ -1375,11 +1379,10 @@ mod tests { result, Err(AttestationSubmissionError::TlsKeyOwnedByOtherAccount) ); - let stored = tee_state - .stored_attestations - .get(&tls_public_key) - .expect("entry must still be present"); - assert_eq!(stored.node_id, alice_node); + assert_eq!( + tee_state.stored_attestations.get(&tls_public_key), + Some(&mock_valid_attestation(alice_node)) + ); } #[test] @@ -1407,11 +1410,10 @@ mod tests { result, Ok(ParticipantInsertion::UpdatedExistingParticipant(_)) ); - let stored = tee_state - .stored_attestations - .get(&rotated_node.tls_public_key) - .expect("entry must be present"); - assert_eq!(stored.node_id, rotated_node); + assert_eq!( + tee_state.stored_attestations.get(&tls_public_key), + Some(&mock_valid_attestation(rotated_node)) + ); } #[test] @@ -1431,27 +1433,32 @@ mod tests { .expect("initial insertion should succeed"); let updated_node = create_node_id(&account_id, &tls_public_key); let insertion = tee_state - .verify_and_store_mock(updated_node, MockAttestation::Valid, TEE_UPGRADE_DURATION) + .verify_and_store_mock( + updated_node.clone(), + MockAttestation::Valid, + TEE_UPGRADE_DURATION, + ) .expect("update should succeed"); - let original_entry = NodeAttestation { - node_id: original_node, - verified_attestation: VerifiedAttestation::Mock(MockAttestation::Valid), - }; - let ParticipantInsertion::UpdatedExistingParticipant(displaced) = &insertion else { - panic!("expected an update, got {insertion:?}"); - }; - assert_eq!(*displaced, original_entry); + let original_entry = mock_valid_attestation(original_node); + let updated_entry = mock_valid_attestation(updated_node); + assert_eq!( + insertion, + ParticipantInsertion::UpdatedExistingParticipant(original_entry.clone()) + ); + assert_eq!( + tee_state.stored_attestations.get(&tls_public_key), + Some(&updated_entry) + ); // When: the store is reverted. tee_state.revert_dstack_store(&tls_public_key, insertion); // Then: the whole original entry is back in place. - let stored = tee_state - .stored_attestations - .get(&tls_public_key) - .expect("original entry must be restored"); - assert_eq!(*stored, original_entry); + assert_eq!( + tee_state.stored_attestations.get(&tls_public_key), + Some(&original_entry) + ); } #[test] @@ -1497,7 +1504,9 @@ mod tests { assert_matches!( add_participant_result, - Err(AttestationSubmissionError::InvalidAttestation(_)) + Err(AttestationSubmissionError::InvalidAttestation( + VerificationError::InvalidMockAttestation + )) ) } @@ -1517,7 +1526,9 @@ mod tests { // Then: it is rejected and nothing is stored. assert_matches!( result, - Err(AttestationSubmissionError::InvalidAttestation(_)) + Err(AttestationSubmissionError::InvalidAttestation( + VerificationError::Custom(msg) + )) if msg.contains("allowed mpc image hashes list is empty") ); assert!(tee_state.stored_attestations.is_empty()); } diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index 7e54221907..f457198ab1 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -3,7 +3,7 @@ use mpc_contract::{ MpcContract, crypto_shared::types::PublicKeyExtended, - errors::{Error, InvalidParameters, TeeError}, + errors::{Error, InvalidParameters, InvalidState, TeeError}, primitives::{ key_state::{AttemptId, EpochId, KeyForDomain, Keyset}, participants::{ParticipantId, ParticipantInfo}, @@ -372,11 +372,12 @@ fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() { // Given: a participant whose submission context attaches only 1 yoctoNEAR. let mut setup = TestSetupBuilder::new().build(); let node = setup.get_participant_node_ids()[0].clone(); + let attached_deposit = NearToken::from_yoctonear(1); testing_env!( VMContextBuilder::new() .signer_account_id(node.account_id.clone()) .predecessor_account_id(node.account_id.clone()) - .attached_deposit(NearToken::from_yoctonear(1)) + .attached_deposit(attached_deposit) .build() ); @@ -396,7 +397,7 @@ fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() { assert_matches!( &result, Err(Error::InvalidParameters(InvalidParameters::InsufficientDeposit { attached, required })) - if required > attached + if *attached == attached_deposit.as_yoctonear() && required > attached ); } @@ -407,10 +408,10 @@ fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() let mut setup = TestSetupBuilder::new().build(); let node = setup.get_participant_node_ids()[0].clone(); - // When: that participant submits a Dstack attestation. + // When let result = setup.try_submit_attestation_for_node(&node, mock_dto_dstack_attestation()); - // Then: it is rejected with `VerifierNotConfigured`. + // Then assert_matches!( &result, Err(Error::TeeError(TeeError::VerifierNotConfigured)) @@ -545,7 +546,10 @@ fn clean_invalid_attestations__should_reject_when_not_running() { let result = setup.contract.clean_invalid_attestations(100); // Then: the call errors without mutating state. - assert_matches!(result, Err(_)); + assert_matches!( + result, + Err(Error::InvalidState(InvalidState::ProtocolStateNotRunning)) + ); } macro_rules! assert_allowed_docker_image_hashes { diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index 769cc69d14..fe7f105d6d 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -8,7 +8,7 @@ use crate::sandbox::{ mpc_contract::{ assert_running_return_participants, assert_running_return_threshold, get_participant_attestation, get_state, get_tee_accounts, submit_participant_info, - vote_add_launcher_hash, vote_for_hash, + total_gas_fee, vote_add_launcher_hash, vote_for_hash, }, resharing_utils::conclude_resharing, sign_utils::DomainResponseTest, @@ -260,6 +260,7 @@ pub async fn get_participants(contract: &Contract) -> Result { #[tokio::test] async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result<()> { let SandboxTestSetup { + worker, contract, mpc_signer_accounts, .. @@ -269,26 +270,29 @@ async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result .await; let submitter = &mpc_signer_accounts[0]; let balance_before = submitter.view_account().await?.balance; + let storage_before = worker.view_account(contract.id()).await?.storage_usage; - let success = submit_participant_info( + let result = submit_participant_info( submitter, &contract, &Attestation::Mock(MockAttestation::Valid), &p2p_tls_key().into(), ) - .await? - .is_success(); - assert!(success); + .await?; + assert!(result.is_success()); - // The submission attaches 1 NEAR but the contract charges only the measured storage cost - // and refunds the rest, so net spend is storage + gas, well under any fraction of the - // deposit; a retained deposit (e.g. 0.5 NEAR) would exceed this ceiling. + // The caller's net spend must be exactly the measured storage stake plus the fee + // actually burnt: the storage entry is charged from the attached deposit and every + // other yoctoNEAR of the deposit is refunded. + let bytes_grown = + u128::from(worker.view_account(contract.id()).await?.storage_usage - storage_before); + assert!(bytes_grown > 0); + let storage_stake = near_sdk::env::storage_byte_cost().saturating_mul(bytes_grown); let balance_after = submitter.view_account().await?.balance; - let net_spent = balance_before.as_yoctonear() - balance_after.as_yoctonear(); - let refund_floor = NearToken::from_millinear(100).as_yoctonear(); - assert!( - net_spent < refund_floor, - "excess deposit must be refunded (net spent {net_spent} yoctoNEAR should be storage + gas, < {refund_floor})" + let net_spent = balance_before.saturating_sub(balance_after); + assert_eq!( + net_spent, + storage_stake.saturating_add(total_gas_fee(&result)) ); Ok(()) } @@ -318,13 +322,10 @@ async fn test_clean_tee_status_denies_external_account_access() -> Result<()> { assert!(!result.is_success()); // Verify the error message indicates unauthorized access - match result.into_result() { - Err(failure) => { - let error_msg = format!("{:?}", failure); - assert!(error_msg.contains("Method clean_tee_status is private")); - } - Ok(_) => panic!("Call should have failed"), - } + let failure = result + .into_result() + .expect_err("clean_tee_status must reject a non-private caller"); + assert!(format!("{failure:?}").contains("Method clean_tee_status is private")); Ok(()) } @@ -1053,8 +1054,6 @@ async fn submit_participant_info__should_reject_new_attestation_with_zero_deposi #[tokio::test] async fn submit_participant_info__should_store_new_attestation_and_charge_with_sufficient_deposit() -> 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); let SandboxTestSetup { worker, contract, .. @@ -1091,17 +1090,13 @@ async fn submit_participant_info__should_store_new_attestation_and_charge_with_s ); 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})" - ); + assert!(bytes_grown > 0); - let storage_stake = NearToken::from_yoctonear(u128::from(bytes_grown) * STORAGE_COST_PER_BYTE); + // The caller's net spend must be exactly the measured storage stake plus the fee + // actually burnt; the rest of the attached deposit is refunded. + let storage_stake = near_sdk::env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); 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}" - ); + assert_eq!(spent, storage_stake.saturating_add(total_gas_fee(&result))); Ok(()) } diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 4945d0172b..7e751bbe15 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -31,7 +31,7 @@ use crate::sandbox::{ contract_build::stub_tee_verifier_contract, mpc_contract::{ get_participant_attestation, submit_participant_info, - submit_participant_info_with_deposit, vote_tee_verifier_change, + submit_participant_info_with_deposit, total_gas_fee, vote_tee_verifier_change, }, }, }; @@ -139,7 +139,18 @@ async fn assert_submission_failed_cleanly( .await .unwrap(); assert!(stored.is_none(), "nothing should be stored on failure"); - assert_deposit_refunded(submitter, balance_before).await; + assert_deposit_refunded(submitter, balance_before, result).await; +} + +/// Asserts the deposit was fully refunded: with nothing stored, the caller spends only gas. +async fn assert_deposit_refunded( + account: &Account, + balance_before: NearToken, + result: &ExecutionFinalResult, +) { + let balance_after = account.view_account().await.unwrap().balance; + let net_spent = balance_before.saturating_sub(balance_after); + assert_eq!(net_spent, total_gas_fee(result)); } #[tokio::test] @@ -260,7 +271,7 @@ async fn submit_participant_info__should_refund_and_store_nothing_when_post_dcap .await .unwrap(); assert!(stored.is_none(), "nothing should be stored on failure"); - assert_deposit_refunded(&submitter, balance_before).await; + assert_deposit_refunded(&submitter, balance_before, &result).await; } // TODO(#3787): un-ignore once the fixture allowlist setup lands. A Verified @@ -274,8 +285,13 @@ async fn submit_participant_info__should_refund_and_store_nothing_when_post_dcap async fn submit_participant_info__should_store_attestation_on_verified_quote() { // Given: a verifier that returns the report the real verifier would produce // for the fixture quote. - let (_worker, contract, submitter, balance_before) = + let (worker, contract, submitter, balance_before) = setup_with_stub(StubResponse::Verified(verified_report()), None).await; + let storage_before = worker + .view_account(contract.id()) + .await + .unwrap() + .storage_usage; // When: a Dstack attestation is submitted. let result = submit_dstack(&submitter, &contract).await; @@ -292,19 +308,21 @@ async fn submit_participant_info__should_store_attestation_on_verified_quote() { .unwrap(); assert!(stored.is_some(), "a verified attestation must be stored"); - // Bound net spend both sides: storage was charged (> 0), but the excess was - // refunded (< floor). The upper bound catches a wrongly-retained deposit. + // The caller's net spend must be exactly the measured storage stake plus the fee + // actually burnt; the rest of the SUBMIT_DEPOSIT is refunded. + let storage_after = worker + .view_account(contract.id()) + .await + .unwrap() + .storage_usage; + let bytes_grown = u128::from(storage_after - storage_before); + assert!(bytes_grown > 0); + let storage_stake = near_sdk::env::storage_byte_cost().saturating_mul(bytes_grown); let balance_after = submitter.view_account().await.unwrap().balance; - let net_spent = balance_before.as_yoctonear() - balance_after.as_yoctonear(); - let refund_floor = NearToken::from_millinear(100).as_yoctonear(); - assert!( - net_spent > 0, - "storage must be charged from the attached deposit" - ); - assert!( - net_spent < refund_floor, - "excess deposit must be refunded (net spent {net_spent} yoctoNEAR should be \ - storage + gas, < {refund_floor}); a retained {SUBMIT_DEPOSIT} deposit would exceed this" + let net_spent = balance_before.saturating_sub(balance_after); + assert_eq!( + net_spent, + storage_stake.saturating_add(total_gas_fee(&result)) ); } @@ -347,23 +365,5 @@ async fn submit_participant_info__should_fail_and_store_nothing_when_resolve_ver stored.is_none(), "nothing should be stored on an OOG resolve" ); - assert_deposit_refunded(&submitter, balance_before).await; -} - -/// Asserts the full 1 NEAR storage deposit was returned: the net spend is only -/// gas, well under any fraction of the deposit. -async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) { - let balance_after = account.view_account().await.unwrap().balance; - // Raw subtraction (not `saturating_sub`): if the contract over-refunds so - // `balance_after > balance_before`, this underflows and panics rather than - // clamping to 0 and silently passing. - let net_spent = balance_before.as_yoctonear() - balance_after.as_yoctonear(); - // Bound to the gas envelope, not the deposit: max gas (~0.03 NEAR at the - // sandbox price) sits far below this ceiling, while any partial retention of - // the 1 NEAR deposit (e.g. 0.5 NEAR) would exceed it and fail. - let gas_ceiling = NearToken::from_millinear(50).as_yoctonear(); - assert!( - net_spent < gas_ceiling, - "deposit should be fully refunded (net spent {net_spent} yoctoNEAR should be gas-only, < {gas_ceiling})" - ); + assert_deposit_refunded(&submitter, balance_before, &result).await; } diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index c481aae041..a529256a0e 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -12,6 +12,17 @@ use near_workspaces::{ Account, AccountId, Contract, result::ExecutionFinalResult, types::NearToken, }; +/// The gas fee the caller actually pays for a call, summed over its transaction and +/// receipts. This is gas only: it excludes both the refunded unused prepaid gas and any +/// storage-staking deposit (storage is locked on the contract, not burnt). +pub fn total_gas_fee(result: &ExecutionFinalResult) -> NearToken { + result + .outcomes() + .iter() + .map(|outcome| outcome.tokens_burnt) + .fold(NearToken::from_yoctonear(0), NearToken::saturating_add) +} + pub async fn get_state(contract: &Contract) -> ProtocolContractState { contract .view(method_names::STATE) From c7d132037c0761ab5b8cc615bf22222e1e45f7fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 15 Jul 2026 16:35:02 +0200 Subject: [PATCH 41/44] docs(contract): tighten async attestation test comments Condense the tee_verifier sandbox module doc, per-test `// Then:` notes, and the two TODO(#3787) blocks; drop the inaccurate "runtime refunds the deposit to the predecessor" note on the OOG TODO. Trim the duplicated non-`#[near]` rationale on the test-tee-verifier-types crate (Cargo.toml + lib.rs) and shorten the test-tee-verifier stub doc. --- crates/contract/tests/sandbox/tee_verifier.rs | 73 +++++-------------- crates/test-tee-verifier-types/Cargo.toml | 8 +- crates/test-tee-verifier-types/src/lib.rs | 6 +- crates/test-tee-verifier/src/lib.rs | 7 +- 4 files changed, 28 insertions(+), 66 deletions(-) diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 7e751bbe15..0bb55aa85b 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -1,27 +1,12 @@ -//! Sandbox tests for the async [`submit_participant_info`] flow that offloads -//! DCAP verification to a separate tee-verifier contract. +//! Sandbox tests for the async [`submit_participant_info`] flow that offloads DCAP +//! verification to a separate tee-verifier contract. //! -//! Each test deploys the `test-tee-verifier` stub, whose verify-quote returns a -//! response the test picks instead of running real `dcap-qvl`, votes it in as the -//! trusted verifier via [`vote_tee_verifier_change`], and then covers one branch -//! of the promise-chain flow. -//! -//! A Dstack submission spawns `verify_quote` on the trusted verifier with -//! [`MpcContract::resolve_verification`] chained as its callback. There is no -//! yield-resume and no timeout: [`resolve_verification`] settles every outcome -//! synchronously within the same chain. -//! -//! - verifier not configured → the submit tx fails synchronously with -//! [`TeeError::VerifierNotConfigured`], nothing stored. -//! - [`StubResponse::Rejected`] → [`resolve_verification`] refunds the deposit and -//! fires `fail_attestation_submission`, which panics in a separate receipt to -//! fail the submitter's transaction; nothing stored. -//! - stub panics (verifier unreachable) → the callback observes a failed promise, -//! resolves to [`TeeError::VerifierUnavailable`], and fails the same way. -//! -//! On failure the top-level submit call still returns its chained promise, so the -//! failure surfaces on the chain's receipt outcomes -//! ([`ExecutionFinalResult::failures`]), not on the top-level tx result. +//! Each test deploys the `test-tee-verifier` stub (returning a picked response +//! instead of running real `dcap-qvl`), votes it in as the trusted verifier, and +//! covers one branch of the promise chain: a Dstack submission spawns `verify_quote` +//! with [`MpcContract::resolve_verification`] chained as its callback, which settles +//! every outcome synchronously. When a submission fails, the top-level `submit` tx still +//! succeeds (it returned the chained promise); the error appears on one of the receipts. #![allow(non_snake_case)] use crate::sandbox::{ @@ -110,10 +95,9 @@ async fn submit_dstack(submitter: &Account, contract: &Contract) -> ExecutionFin .unwrap() } -/// Asserts a Dstack submission failed on the chain and left no committed state: -/// the failure surfaces on a receipt (`fail_attestation_submission` panics in its -/// own receipt), carries `expected_error`, nothing is stored, and the deposit is -/// refunded. +/// Asserts a Dstack submission failed cleanly: a receipt failed carrying +/// `expected_error` (`fail_attestation_submission` panics in its own receipt), no +/// attestation was stored, and the deposit was refunded. async fn assert_submission_failed_cleanly( result: &ExecutionFinalResult, contract: &Contract, @@ -204,10 +188,7 @@ async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_re // When: a Dstack attestation is submitted. let result = submit_dstack(&submitter, &contract).await; - // Then: resolve_verification refunds and fails the submission in a separate - // receipt; the failure is on the chain, not the top-level tx result. The - // stub wraps the reason in `VerifierError::DcapVerification`, whose Display - // prefixes "dcap verification failed: ". + // Then: the submission fails cleanly, reporting the verifier's rejection reason. assert_submission_failed_cleanly( &result, &contract, @@ -230,8 +211,7 @@ async fn submit_participant_info__should_fail_and_store_nothing_on_verifier_cras let result = submit_dstack(&submitter, &contract).await; // Then: the callback sees a failed promise, resolves to VerifierUnavailable, - // refunds, and fails the submission in a separate receipt. No timeout: the - // outcome settles synchronously within the same chain. + // refunds, and fails the submission in a separate receipt. assert_submission_failed_cleanly( &result, &contract, @@ -252,11 +232,8 @@ async fn submit_participant_info__should_refund_and_store_nothing_when_post_dcap // When: a Dstack attestation is submitted. let result = submit_dstack(&submitter, &contract).await; - // Then: the failure originates inside the callback (not from the verifier), yet still - // refunds and fails the submission in a separate receipt, storing nothing. Asserted inline - // rather than via assert_submission_failed_cleanly because the error is an - // InvalidAttestation (empty-allowlist rejection), not a TeeError; the empty allowed - // mpc-image-hash list is the first post-DCAP check to reject. + // Then: the callback's post-DCAP check rejects the (verified) quote. Asserted inline + // rather than via assert_submission_failed_cleanly since the error is not a TeeError. let failures = result.failures(); assert!( !failures.is_empty(), @@ -274,12 +251,8 @@ async fn submit_participant_info__should_refund_and_store_nothing_when_post_dcap assert_deposit_refunded(&submitter, balance_before, &result).await; } -// TODO(#3787): un-ignore once the fixture allowlist setup lands. A Verified -// verdict routes through `verify_post_dcap_and_store`, whose allowlist checks -// (fixture image/launcher hashes and measurements voted in, submitter using the -// fixture keys) must pass before the attestation is stored. With an empty -// allowlist the post-DCAP check fails and the submission is rejected instead of -// stored, so the happy path cannot be exercised here yet. +// TODO(#3787): un-ignore once the fixture allowlist setup lands; without it the +// post-DCAP check rejects the quote, so the store happy path can't run here yet. #[ignore = "needs fixture allowlist setup to pass the post-DCAP checks; tracked in #3787"] #[tokio::test] async fn submit_participant_info__should_store_attestation_on_verified_quote() { @@ -326,15 +299,9 @@ async fn submit_participant_info__should_store_attestation_on_verified_quote() { ); } -// TODO(#3787): un-ignore once the fixture allowlist setup lands. To OOG, -// `resolve_verification` must reach the heavy RTMR3 replay in the post-DCAP -// checks, which needs the allowlist populated and the submitter using the fixture -// keys. With an empty allowlist the post-DCAP check fails fast and -// `resolve_verification` completes well under 1 TGas, re-testing the rejection -// path instead. Under the promise-chain model an OOG rolls the whole callback -// receipt back atomically: nothing is stored, the runtime refunds the attached -// deposit to the predecessor, and `fail_attestation_submission` never fires, so -// the chain still surfaces a failed receipt. No timeout is involved. +// TODO(#3787): un-ignore once the fixture allowlist setup lands; without it the +// post-DCAP check fails fast and resolve_verification never reaches the heavy work +// needed to run it out of gas, so this re-tests the rejection path instead. #[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3787"] #[tokio::test] async fn submit_participant_info__should_fail_and_store_nothing_when_resolve_verification_runs_out_of_gas() diff --git a/crates/test-tee-verifier-types/Cargo.toml b/crates/test-tee-verifier-types/Cargo.toml index baa76ead72..28d34b9cdc 100644 --- a/crates/test-tee-verifier-types/Cargo.toml +++ b/crates/test-tee-verifier-types/Cargo.toml @@ -4,11 +4,9 @@ version = { workspace = true } license = { workspace = true } edition = { workspace = true } -# Wire types shared between the `test-tee-verifier` stub contract and the -# `mpc-contract` sandbox tests that drive it. A plain lib (no `#[near]`) so both -# a contract crate and a test binary can depend on it without the duplicate-ABI -# symbol / `--all-features` collision that importing the stub crate itself would -# cause (see docs / the mpc-contract sandbox tests). +# Wire types shared between the `test-tee-verifier` stub contract and the sandbox +# tests that drive it. Kept as a plain (non-`#[near]`) lib so a test crate can depend +# on it without pulling a contract's duplicate ABI symbol under `--all-features`. [features] # Mirrors the stub's `abi` feature: derives the borsh schema on the wire types so diff --git a/crates/test-tee-verifier-types/src/lib.rs b/crates/test-tee-verifier-types/src/lib.rs index 06e5344df8..c86372fc23 100644 --- a/crates/test-tee-verifier-types/src/lib.rs +++ b/crates/test-tee-verifier-types/src/lib.rs @@ -1,10 +1,8 @@ //! Wire types shared between the `test-tee-verifier` stub contract and the //! `mpc-contract` sandbox tests that drive it. //! -//! Kept in a plain (non-`#[near]`) crate so both a contract crate and a test -//! binary can depend on the same definition: importing the stub contract itself -//! would emit a duplicate contract-ABI symbol and unify its `abi` feature under -//! `cargo test --all-features`. +//! Kept as a plain (non-`#[near]`) crate so a test crate can depend on it without +//! pulling the stub contract's duplicate ABI symbol under `cargo test --all-features`. use borsh::{BorshDeserialize, BorshSerialize}; diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs index 2654507109..502c6db571 100644 --- a/crates/test-tee-verifier/src/lib.rs +++ b/crates/test-tee-verifier/src/lib.rs @@ -1,7 +1,6 @@ -//! Test-only stub of the `tee-verifier` contract. -//! -//! [`TestTeeVerifier::verify_quote`] returns a [`StubResponse`] fixed at init -//! time instead of running real `dcap_qvl::verify`. +//! Test-only stub of the `tee-verifier` contract: [`TestTeeVerifier::verify_quote`] +//! returns a [`StubResponse`] chosen at init time instead of running DCAP quote +//! verification, letting tests drive any verifier outcome deterministically. use near_sdk::{env, near}; use tee_verifier_interface::{Collateral, QuoteBytes, VerificationResult, VerifierError}; From e66a0de293bd63ef8b5700929dda8757684153cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 15 Jul 2026 16:42:18 +0200 Subject: [PATCH 42/44] test(contract): drop doc changes, split to a stacked PR Revert the TEE attestation doc edits (docs/design/attestation-verifier-contract.md, docs/localnet/tee-localnet.md, docs/running-an-mpc-node-in-tdx-external-guide.md) to their base-branch content so this PR is test-only. The doc updates move to a stacked PR tracked by #3825. --- docs/design/attestation-verifier-contract.md | 459 ++++++++++-------- docs/localnet/tee-localnet.md | 2 +- ...nning-an-mpc-node-in-tdx-external-guide.md | 3 +- 3 files changed, 261 insertions(+), 203 deletions(-) diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 3ddb42ca58..0346ce6c69 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -59,93 +59,92 @@ flowchart LR ### Submission flow -`mpc-contract`'s [`submit_participant_info`][submit-participant-info] becomes asynchronous for Dstack attestations, but without yield-resume: it settles the submission entirely inside a single cross-contract promise chain. The method returns `Result, Error>`. A `Mock` attestation is verified and stored synchronously and returns `Ok(PromiseOrValue::Value(()))`. A `Dstack` attestation is handed to `submit_dstack_attestation`, which builds a `Promise` that calls `tee-verifier::verify_quote` and chains `resolve_verification` as its `.then` callback; the method returns `Ok(PromiseOrValue::Promise(...))`. The attached deposit is forwarded onto the callback via `.with_attached_deposit(env::attached_deposit())` rather than stashed in contract state, so `resolve_verification` can charge storage or refund from it directly. +`mpc-contract`'s [`submit_participant_info`][submit-participant-info] becomes asynchronous for Dstack attestations and uses the same yield-resume pattern already in place for `sign`, `request_app_private_key`, and `request_verify_foreign_tx`. The method registers a yielded promise via [`env::promise_yield_create`][promise-yield-create] (going through the contract's existing [`enqueue_yield_request`][enqueue-yield-request] helper), stashes the resulting `data_id` in `pending_attestations`, and fires a cross-contract call to `tee-verifier::verify_quote` whose own `.then` callback (`resolve_verification`) bridges the verifier's response into a `promise_yield_resume`. Because `verify_quote` returns a `VerificationResult` value rather than failing the receipt (see [§Why a rejection is a value](#why-a-rejection-is-a-value-not-a-failed-receipt)), `resolve_verification`'s `#[callback_result]` distinguishes three outcomes: `Verified` runs the post-DCAP checks (RTMR3 replay, app-compose validation, measurement allowlist matching, report-data binding) and resumes; `Rejected` refunds and resumes **immediately** with the reason; and only `Err(PromiseError::Failed)` — the verifier unreachable or crashed — returns early, leaving cleanup to the ~200-block timeout. The yield-callback `on_attestation_verified` stays trivial, the same shape as the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]: it returns the resumed outcome to the caller, and only on the timeout (`Err(PromiseError::Failed)`) does it remove the pending entry and schedule a refund. Keeping the heavy work in `resolve_verification` makes an OOG in the yield-callback implausible, and an OOG in `resolve_verification` rolls back its whole receipt so the timeout still fires the trivial callback for cleanup. The exact branch-by-branch behavior is in [§Handling failures](#handling-failures) below. -Because `verify_quote` returns a `VerificationResult` value rather than failing the receipt (see [§Why a rejection is a value](#why-a-rejection-is-a-value-not-a-failed-receipt)), `resolve_verification`'s `#[callback_result]` distinguishes three outcomes: `Ok(Verified)` runs the post-DCAP checks (RTMR3 replay, app-compose validation, measurement allowlist matching, report-data binding) and, on success, stores the attestation and charges storage; `Ok(Rejected)` returns a `QuoteRejected` error carrying the reason; and `Err(PromiseError::Failed)` — the verifier unreachable, panicked, or out of gas — returns `VerifierUnavailable`. On any error branch `resolve_verification` refunds the whole attached deposit and fires a *separate* `fail_attestation_submission` receipt whose panic fails the submitter's transaction. There is no yield, no `data_id`, no `pending_attestations` entry, and no ~200-block timeout: a failure settles immediately within the same promise chain. The exact branch-by-branch behavior is in [§Handling failures](#handling-failures) below. +The post-DCAP policy inputs are the same fields `mpc-contract` already holds today — the allowed-image-hash list, the per-account TLS / account public-key binding, and the stored-attestation map. No new policy state is introduced; the only state addition is the `pending_attestations` map described below, which is bookkeeping for the in-flight yield. -The post-DCAP policy inputs are the same fields `mpc-contract` already holds today — the allowed-image-hash list, the per-account TLS / account public-key binding, and the stored-attestation map. No new policy state is introduced, and the chain carries no bookkeeping map: everything `resolve_verification` needs travels as a `VerificationContext` borsh argument on the callback. - -The periodic re-validation path ([`re_verify`](../../crates/mpc-attestation/src/attestation.rs)) does not call `dcap_qvl::verify` — it re-checks post-DCAP allowlist invariants against already-stored attestations — and is therefore unaffected by this design. It stays synchronous. +The periodic re-validation path ([`re_verify`](../../crates/contract/src/tee/tee_state.rs)) does not call `dcap_qvl::verify` — it re-checks post-DCAP allowlist invariants against already-stored attestations — and is therefore unaffected by this design. It stays synchronous. ```mermaid sequenceDiagram participant Op as Operator participant MPC as mpc-contract + participant State as State participant Ver as tee-verifier participant DCAP as dcap-qvl Op->>MPC: submit_participant_info(Dstack, tls_pk) - MPC->>Ver: Promise: verify_quote(quote, collateral) - Note over MPC: .then resolve_verification(VerificationContext),
attached deposit forwarded to the callback + MPC->>MPC: promise_yield_create → data_id + MPC->>State: insert PendingAttestation { data_id, ... } + MPC->>Ver: Promise: verify_quote (chained .then resolve_verification) Ver->>DCAP: verify(quote, collateral, now) - alt Verified + store ok + alt Verified (post-DCAP runs, then resumes) Ver-->>MPC: VerificationResult::Verified(report) - MPC->>MPC: resolve_verification: verify_post_dcap_and_store vs fresh allowlist - MPC->>MPC: charge_attestation_storage (refund excess) - MPC-->>Op: PromiseOrValue::Value(()) — success - else Verified + post-DCAP fail, or Rejected, or verifier unreachable - Ver-->>MPC: Verified(report) / Rejected(reason) / (no answer) - MPC->>MPC: resolve_verification produces Err (QuoteRejected / VerifierUnavailable) - MPC->>Op: refund whole attached deposit (this receipt) - MPC->>MPC: fail_attestation_submission receipt panics - MPC-->>Op: transaction fails (carrying the reason) + MPC->>MPC: resolve_verification: finish_verify vs fresh allowlist + MPC->>State: store on pass / refund on fail, remove PendingAttestation + MPC->>MPC: promise_yield_resume(data_id, FinalOutcome) + MPC-->>Op: success or error, immediately + else Rejected (resumes immediately) + Ver-->>MPC: VerificationResult::Rejected(reason) + MPC->>MPC: resolve_verification: refund, remove PendingAttestation + MPC->>MPC: promise_yield_resume(data_id, FinalOutcome::Err(reason)) + MPC-->>Op: error (carrying reason), immediately + else No verdict — verifier unreachable / silent for ~200 blocks + Note over MPC: resolve_verification (if it runs) only logs and returns.
Cleanup is left to the runtime yield-timeout. + MPC->>MPC: on_attestation_verified fires with Err(PromiseError::Failed) + MPC->>State: remove PendingAttestation, refund + MPC-->>Op: error end ``` #### Caller-side impact -The only caller of `submit_participant_info` in production is `mpc-node`'s `periodic_attestation_submission` task, which resubmits on a 1-hour cadence and on attestation-removal events. It already polls contract state to confirm the attestation is actually stored, with exponential backoff (100 ms → 60 s, capped at 12 h). That polling-based success criterion is what makes the sync→async change transparent. The returned `Promise` now resolves through the chain with the actual outcome — success, a verifier-rejection error, a post-DCAP-failure error, or a `VerifierUnavailable` error if the verifier never answers — so any future caller that wants to await the result synchronously can, without changing the contract. There is no ~200-block timeout error to account for: every path settles as soon as the verifier's receipt finishes. +The only caller of `submit_participant_info` in production is `mpc-node`'s `periodic_attestation_submission` task, which resubmits on a 1-hour cadence and on attestation-removal events. It already polls contract state to confirm the attestation is actually stored, with exponential backoff (100 ms → 60 s, capped at 12 h). That polling-based success criterion is what makes the sync→async change transparent. Under yield-resume the returned `Promise` also resolves with the actual outcome — success, a verifier-rejection error, or a post-DCAP-failure error (each as soon as the verifier answers), or a timeout error after ~200 blocks if it never does — so any future caller that wants to await the result synchronously can, without changing the contract. #### Handling failures -The submission produces no in-flight state to clean up: nothing is inserted into contract storage at submit time, so there is no pending entry that a failure could leave wedged and no "already pending" guard to trip on a resubmit. What a failure must still get right is the money — the attached deposit — and the caller-facing outcome. Both are handled in the single `.then` callback, `resolve_verification`. - -`resolve_verification` is a `#[private]` `#[payable]` method. It is `#[payable]` because the deposit rides forward onto it via `.with_attached_deposit`, so `env::attached_deposit()` inside the callback returns the amount the submitter attached. It observes the verifier's answer through `#[callback_result]` and reduces it to a `Result<(), Error>`: - -- `Ok(VerificationResult::Verified(report))` → `verify_post_dcap_and_store(&context, &report)`, which returns `Ok(())` on a clean store or an `Err` if a post-DCAP check or the storage charge fails. -- `Ok(VerificationResult::Rejected(reason))` → `Err(QuoteRejected { reason })`. -- `Err(promise_err)` → `Err(VerifierUnavailable)` — the verifier was unreachable, panicked, or ran out of gas. +The first thing `submit_participant_info` does is insert a `PendingAttestation` entry, and that entry has to come back out once verification finishes — successfully or not. If a failure leaves the entry behind, the submitter's account is wedged: every future `submit_participant_info` call panics on the "already pending" guard, and the deposit stays locked because the refund is part of the cleanup the contract never got around to. -On `Ok(())` the callback returns `PromiseOrValue::Value(())`; the attestation is stored and storage has been charged, with any excess deposit refunded inside `charge_attestation_storage`. +That makes *where* the cleanup runs the central question, because NEAR offers two natural homes for "do something when the verifier responds" and they have very different failure modes. -On `Err(err)` the callback does two things, in order, and the order is the whole point: +A **`.then` callback** is a normal cross-contract callback chained onto the verifier's promise. The runtime runs it in a fresh receipt once the verifier's receipt finishes; if it panics or runs out of gas, that receipt rolls back atomically and the chain ends. Because the receipt is independent of whatever yield is parked in parallel, its failure has no special effect on the submitter's call — the submitter just keeps waiting on the yield. -1. `refund_to(&account_id, env::attached_deposit())` — refund the *entire* attached deposit in this receipt. -2. Schedule a *separate* `fail_attestation_submission` receipt via `Promise::new(current_account).function_call(...).as_return()`, and return it as `PromiseOrValue::Promise`. +A **yield-callback** is different. When `submit_participant_info` calls `promise_yield_create`, it asks the runtime to *park* the submitter's call so the contract can return its result later. The runtime fires the named callback exactly once per `data_id` — either when something calls `promise_yield_resume(data_id, payload)`, or after ~200 blocks of silence with `Err(PromiseError::Failed)`. That single firing's return value is what the submitter eventually receives. There is no second invocation: an OOG inside the yield-callback rolls back its whole receipt and drops whatever cleanup it was meant to do, with no automatic retry. -The refund and the failure live in different receipts deliberately. `fail_attestation_submission` is a tiny `#[private]` method that logs the reason and then `env::panic_str(&reason)` — its panic is what fails the submitter's transaction and surfaces the error. If that panic instead happened inside `resolve_verification` (the `#[handle_result]`-return-an-`Err` shape), it would roll back the whole callback receipt, discarding the refund transfer and any created promises along with it. Splitting them lets the refund commit in the first receipt while the second receipt fails the caller's transaction afterward. - -`verify_post_dcap_and_store` has its own commit-order subtlety. Unlike the synchronous `Mock` path — where returning an `Err` rolls back the entire method receipt and un-does any partial store — this callback receipt *commits regardless* of the `Err` it hands back to `resolve_verification`. So the store cannot be left to implicit rollback. `verify_post_dcap_and_store` snapshots `env::storage_usage()`, calls `verify_and_store_dstack`, then `charge_attestation_storage`; if the charge fails (`InsufficientDeposit`), it explicitly calls `tee_state.revert_dstack_store(tls_pk, insertion)` before returning the error, so the caller never gets storage for free plus a full refund. +The asymmetry decides the design. The work the verifier's *answer* unlocks — post-DCAP checks, the `stored_attestations` insert, the refund on rejection or post-DCAP failure, the pending-entry removal, the `promise_yield_resume` call — lives in the `.then` bridge `resolve_verification`. If it aborts mid-flight, the entire receipt rolls back atomically (including the resume), so the yield stays parked and the runtime's 200-block timeout still fires the yield-callback for cleanup — same recovery as "verifier never responded." `resolve_verification` resolves immediately on either answer it can act on: `Verified` (run post-DCAP, then resume) and `Rejected` (refund and resume with the reason). Only `Err(PromiseError::Failed)` — no verdict, the verifier was unreachable or crashed — is deliberately *not* resolved here: `resolve_verification` logs and returns early, routing that case to the timeout cleanup. The yield-callback `on_attestation_verified` is intentionally tiny: on resume, return the value to the caller; on its `Err(PromiseError::Failed)` branch — verifier unreachable or silent timeout — remove the pending entry and schedule a refund. Walking every path the system can take: -- Verifier returned `Verified`, post-DCAP checks pass, storage charge succeeds → attestation stored, excess refunded, `Value(())`. Caller polls and sees the entry. -- Verifier returned `Verified` but a post-DCAP check fails → `verify_and_store_dstack` errors (nothing was stored), `resolve_verification` refunds and fires the fail receipt. -- Verifier returned `Verified`, post-DCAP passes, but the storage charge fails → `verify_post_dcap_and_store` reverts the store explicitly, returns the error, `resolve_verification` refunds and fires the fail receipt. -- Verifier returned `Rejected` → `QuoteRejected`, refund + fail receipt. -- Verifier unreachable / panicked / out of gas (`Err(PromiseError::Failed)`) → `VerifierUnavailable`, refund + fail receipt. This is handled right here, immediately; it is not deferred to any timeout. -- `resolve_verification` itself runs out of gas or panics mid-receipt → the whole callback receipt rolls back atomically, no partial commits, and the submitter's transaction fails. Because nothing was inserted at submit time, there is no orphaned state to reclaim. +- `resolve_verification` resumes (verifier returned `Verified` (post-DCAP pass or fail) or `Rejected`) → it cleaned up before resuming, caller receives the outcome. +- `resolve_verification` returns early (verifier unreachable — `Err(PromiseError::Failed)`) → timeout fires, yield-callback cleans up. +- `resolve_verification` aborts (OOG / panic mid-receipt) → timeout fires, yield-callback cleans up. +- `resolve_verification` never runs (verifier silent) → timeout fires, yield-callback cleans up. +- Yield-callback itself OOGs → genuine orphan. Its body is one `LookupMap::remove` plus one Promise schedule, small enough that an out-of-gas failure is implausible with a sensible gas budget. -The verifier still returns its verdict as a *value* rather than a failed receipt, so `#[callback_result]` can tell a definitive `Rejected` apart from `Err(PromiseError::Failed)` (no answer). Under the no-yield design both still lead to an immediate fail-and-refund in `resolve_verification`; the distinction only changes the error type and message the caller sees (`QuoteRejected` with the reason vs `VerifierUnavailable`), not whether cleanup is immediate. +This isn't a novel pattern in this codebase: `sign`, `request_app_private_key`, and `request_verify_foreign_tx` already use the same split. The heavy work and the `promise_yield_resume` call live in [`pending_requests::resolve_yields_for`][pending-requests-mod] (invoked from `respond`, `respond_ckd`, and `respond_verify_foreign_tx`), and [`return_signature_and_clean_state_on_success`][sign-yield-callback] is a tiny yield-callback that just routes the outcome back to the caller and handles the no-response timeout. The attestation flow inherits the same orphan-proof argument: heavy work in a safely-recoverable receipt, cleanup in a callback small enough that gas budgeting makes its failure implausible. ### Contract state changes -`resolve_verification` runs in a later block than `submit_participant_info`, as an independent contract invocation, so anything it needs from the original call must travel with it. That is done not through contract storage but through a borsh callback argument: +The callback runs in a later block than `submit_participant_info`, as an independent contract invocation. Anything the callback still needs must be stashed in contract storage, in a new field: ```rust -pub struct VerificationContext { - pub(crate) node_id: NodeId, - pub(crate) attestation: DstackAttestation, -} +pending_attestations: LookupMap ``` -`VerificationContext` carries the submitter's `NodeId` (account id, TLS public key, and account public key — the binding the post-DCAP report-data check reproves) and the full `DstackAttestation` payload (RTMR3 event log, app-compose, report-data) that the post-DCAP checks consume. It is passed to `resolve_verification` as a `#[serializer(borsh)]` argument and is never written to contract state. The attached deposit is *not* part of it — it rides forward on the promise via `.with_attached_deposit`, so `env::attached_deposit()` in the callback yields the submitter's deposit directly. +This map mirrors the other pending-request maps in `mpc-contract` ([`pending_signature_requests`][pending-requests-mod], `pending_ckd_requests`, `pending_verify_foreign_tx_requests`), but stores a single `PendingAttestation` per `AccountId` rather than a `Vec`: attestation submissions are 1-per-account. -This design adds **no** new attestation-related state. There is no `pending_attestations` map, no `PendingAttestation` struct, no `AttestationResult` enum, and no stashed `data_id` or deposit. The only new fields on the contract are `tee_verifier_account_id` and `tee_verifier_votes`, both from [§Voting on the trusted verifier](#voting-on-the-trusted-verifier-in-mpc-contract). +Each [`PendingAttestation`](#mpc-contractsubmit_participant_info) holds: -Notably, the **post-DCAP policy state** — allowed MPC image hashes, allowed launcher compose hashes, and accepted measurements — is not snapshotted either. `verify_post_dcap_and_store` reads all of it fresh from contract state when the callback runs, so any governance vote that adds or removes an entry mid-flight applies to verifications it overlaps. Snapshotting at request time would freeze each submission against stale policy — wrong default for a security control, where removing a compromised hash should take effect immediately. +- **The submitter's `Attestation::Dstack` payload** — the RTMR3 event log, app-compose, and report-data the post-DCAP checks consume. +- **The submitter's TLS public key** — the callback hashes it with the submitter's account public key and compares to the quote's `report_data` field, proving the enclave produced the quote for this specific submitter. +- **The attached deposit** — covers storage staking on success, refunded to the signer of the original `submit_participant_info` transaction on failure. `env::attached_deposit()` is not visible from the callback receipt, so the value is stashed at submit time and the recipient `AccountId` is the same one used to key the entry (set by `Self::assert_caller_is_signer()`). +- **`data_id: CryptoHash`** — the yield handle returned by [`env::promise_yield_create`][promise-yield-create]. The intermediate `.then` callback (`resolve_verification`) reads this back to call [`env::promise_yield_resume`][promise-yield-resume] with a `FinalOutcome` after the post-DCAP checks have run. + +Entries are removed by `resolve_verification` on every branch where the verifier returned an answer — `Verified` (post-DCAP success or failure) and `Rejected`; only when the verifier gave no verdict (`Err(PromiseError::Failed)` — unreachable or crashed) does it return early and leave the entry in place. `on_attestation_verified` then removes the entry on its `Err(PromiseError::Failed)` branch, which covers that unreachable case and the no-response timeout. + +Notably absent from `PendingAttestation`: the **post-DCAP policy state** — allowed MPC image hashes, allowed launcher compose hashes, and accepted measurements. `resolve_verification` re-reads all of them from contract state, so any governance vote that adds or removes an entry mid-flight applies to verifications it overlaps. Snapshotting at request time would freeze each submission against stale policy — wrong default for a security control, where removing a compromised hash should take effect immediately. ```mermaid sequenceDiagram @@ -155,6 +154,8 @@ sequenceDiagram participant Ver as tee-verifier Op->>MPC: submit_participant_info(Dstack, tls_pk) + MPC->>MPC: promise_yield_create → data_id + MPC->>MPC: insert PendingAttestation { data_id, ... } MPC->>Ver: Promise: verify_quote(...) (.then resolve_verification) Gov->>MPC: vote_add_image_hash(H) @@ -162,8 +163,10 @@ sequenceDiagram Ver-->>MPC: VerificationResult::Verified(report) MPC->>MPC: resolve_verification - MPC->>MPC: verify_post_dcap_and_store reads allowlist fresh (sees H) - MPC->>MPC: store on pass / refund + fail receipt on error + MPC->>MPC: read allowlist (sees H) + MPC->>MPC: finish_verify against fresh allowlist + MPC->>MPC: promise_yield_resume(data_id, FinalOutcome) + MPC->>MPC: on_attestation_verified (trivial: return value) ``` ## Crate layout @@ -278,13 +281,13 @@ pub enum VerificationResult { #### Why a rejection is a value, not a failed receipt -Encoding the rejection the obvious way — `Result` via `#[handle_result]`, so a rejection fails the receipt — loses the reason. NEAR's promise result is either `Successful(bytes)` or `Failed`, and **`Failed` carries no payload**: the caller's `#[callback_result]` sees a bare `Err(PromiseError::Failed)`, *identical* to the verifier being unreachable or out of gas. `mpc-contract` still wants to tell "the verifier rejected this quote" apart from "the verifier did not answer", so it can report the right error to the caller. Returning the outcome as a *value* keeps them distinct, so `#[callback_result]` observes: +Encoding the rejection the obvious way — `Result` via `#[handle_result]`, so a rejection fails the receipt — loses the reason. NEAR's promise result is either `Successful(bytes)` or `Failed`, and **`Failed` carries no payload**: the caller's `#[callback_result]` sees a bare `Err(PromiseError::Failed)`, *identical* to the verifier being unreachable or out of gas. But `mpc-contract` must treat "the verifier rejected this quote" (definitive — refund and finish now) differently from "the verifier did not answer" (transient — wait for the yield timeout, the node resubmits). Returning the outcome as a *value* keeps them distinct, so `#[callback_result]` observes: - `Ok(VerificationResult::Verified(report))` — quote valid; run post-DCAP checks. -- `Ok(VerificationResult::Rejected(reason))` — rejected; `resolve_verification` returns `QuoteRejected { reason }`. -- `Err(PromiseError::Failed)` — unreachable / panicked / out of gas; `resolve_verification` returns `VerifierUnavailable`. +- `Ok(VerificationResult::Rejected(reason))` — rejected; refund and resume **immediately**, with the reason. +- `Err(PromiseError::Failed)` — unreachable / panicked / timed out; the yield timeout cleans up. -This is the NEP-141 / NEP-171 shape — the meaningful outcome rides in the success payload, a failed receipt is reserved for "something broke". Under the no-yield design both error branches lead to the same immediate refund-and-fail; keeping them distinct only changes the error type and message the caller receives. (near-sdk also refuses to serialize a bare `Result` return, so `VerificationResult` is a dedicated sum type.) +This is the NEP-141 / NEP-171 shape — the meaningful outcome rides in the success payload, a failed receipt is reserved for "something broke" — and it preserves `mpc-contract`'s existing invariant that a rejection and a non-answer are never the same event. (near-sdk also refuses to serialize a bare `Result` return, so `VerificationResult` is a dedicated sum type.) ### Voting on the trusted verifier in `mpc-contract` @@ -298,7 +301,7 @@ The proposal payload is the pair `(candidate_account_id, expected_code_hash)`. ` #[near(serializers = [borsh])] pub struct VerifierChangeProposal { pub candidate_account_id: AccountId, - pub expected_code_hash: TeeVerifierCodeHash, + pub expected_code_hash: CryptoHash, } impl ProposalHashEncoding for VerifierChangeProposal { @@ -319,7 +322,7 @@ impl MpcContract { pub fn vote_tee_verifier_change( &mut self, candidate_account_id: AccountId, - expected_code_hash: TeeVerifierCodeHash, + expected_code_hash: CryptoHash, ); /// Withdraw the caller's current vote on any pending verifier-change @@ -334,20 +337,17 @@ The contract gains two new state fields: pub struct MpcContract { // ... existing fields ... - /// The locked account `mpc-contract` currently trusts as the verifier, or - /// `None` until participants vote one in (a `Dstack` `submit_participant_info` - /// is then rejected with `VerifierNotConfigured`). `submit_participant_info` - /// calls `verify_quote` on this account. Mutated only by the threshold-crossing - /// vote above; the mutation re-routes future submissions and does not touch - /// already-stored attestations. (Making this non-`Option` once a verifier is - /// voted in is the follow-up #3639.) - tee_verifier_account_id: Option, + /// The locked account `mpc-contract` currently trusts as the verifier. + /// `submit_participant_info` calls `verify_quote` on this account. + /// Mutated only by the threshold-crossing vote above; the mutation + /// re-routes future submissions and does not touch already-stored + /// attestations. + tee_verifier_account_id: AccountId, /// Pending votes for changing `tee_verifier_account_id`. Each voter is an /// active MPC participant; each proposal is hashed from - /// `(candidate_account_id, expected_code_hash)`. `TeeVerifierVotes` is a thin - /// newtype wrapping the generic `Votes`. - tee_verifier_votes: TeeVerifierVotes, + /// `(candidate_account_id, expected_code_hash)`. + tee_verifier_votes: Votes, } ``` @@ -371,7 +371,7 @@ sequenceDiagram Note over MPC: tee_verifier_account_id = new (routing only,
no eviction) VerOld-->>MPC: VerificationResult::Verified(report) - MPC->>MPC: resolve_verification (post-DCAP + store, as usual) + MPC->>MPC: resolve_verification (post-DCAP + insert, as usual) Note over MPC: stored entry ages out within the
expiration window via re_verify Op->>MPC: submit_participant_info(Dstack, tls_pk) (next hourly resubmit) @@ -381,186 +381,235 @@ sequenceDiagram ### `mpc-contract::submit_participant_info` -The method resolves a Dstack submission through a two-receipt promise chain: `verify_quote` on the verifier, then `resolve_verification` as its callback — see [§Submission flow](#submission-flow) above for the architecture. It returns `Result, Error>`. A `Mock` attestation is verified and stored synchronously and returns `Ok(PromiseOrValue::Value(()))`. A `Dstack` attestation is delegated to `submit_dstack_attestation`, which builds the chain and returns `Ok(PromiseOrValue::Promise(...))`. The attached deposit is forwarded onto `resolve_verification` via `.with_attached_deposit`, so the callback can charge storage or refund without any state being stashed at submit time. There is no `pending_attestations` insert and no "one in-flight per account" guard. Draft implementation: +The method splits across three receipts joined by yield-resume — see [§Submission flow](#submission-flow) above for the architecture. The return type is [`PromiseOrValue<()>`](https://docs.rs/near-sdk/5.26.1/near_sdk/enum.PromiseOrValue.html), `near-sdk`'s "sometimes synchronous, sometimes a Promise chain" type: `Mock` attestations return `Value(())` immediately, and `Dstack` attestations return the yielded `Promise` from [`env::promise_yield_create`][promise-yield-create], which the runtime resolves either when `resolve_verification` calls [`env::promise_yield_resume`][promise-yield-resume] or after ~200 blocks of silence. The post-DCAP checks and the `stored_attestations` insert live in `resolve_verification`, not in the yield-callback — that's what keeps `on_attestation_verified` trivial enough to match the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]. Draft implementation: ```rust impl MpcContract { - #[payable] - #[handle_result] pub fn submit_participant_info( &mut self, attestation: Attestation, - tls_public_key: Ed25519PublicKey, - ) -> Result, Error> { + tls_pk: Ed25519PublicKey, + ) -> PromiseOrValue<()> { // Existing convention: caller must be the signer of this transaction, // not a relayer or proxy. let account_id = Self::assert_caller_is_signer(); - let node_id = NodeId { account_id, tls_public_key, /* account_public_key */ }; - match attestation { - // Synchronous: no DCAP, verified and stored in this call. A - // returned Err here rolls back the whole receipt. + // Unchanged from today. Attestation::Mock(mock) => { - let initial_storage = env::storage_usage(); - self.tee_state.verify_and_store_mock(node_id, mock, ...)?; - self.charge_attestation_storage(&node_id.account_id, initial_storage)?; - Ok(PromiseOrValue::Value(())) + self.verify_mock_synchronously(mock, tls_pk); + PromiseOrValue::Value(()) } - // Dstack: async via the verifier promise chain. - Attestation::Dstack(attestation) => Ok(PromiseOrValue::Promise( - self.submit_dstack_attestation(node_id, attestation)?, - )), - } - } - - /// Builds the verifier promise chain. Fails the submit transaction - /// synchronously with `VerifierNotConfigured` if no verifier has been - /// voted in — there is no account to call `verify_quote` on. Otherwise it - /// calls `verify_quote` on the trusted verifier and chains - /// `resolve_verification` as its `.then` callback, forwarding the attached - /// deposit onto that callback. Quote/collateral are serialized by - /// reference so `attestation` can move into the `VerificationContext`. - fn submit_dstack_attestation( - &mut self, - node_id: NodeId, - attestation: DstackAttestation, - ) -> Result { - let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { - return Err(TeeError::VerifierNotConfigured.into()); - }; + // Dstack: yield-resume. + Attestation::Dstack(dstack) => { + // One in-flight verification per AccountId. A duplicate submit + // before the previous one finishes (verifier response or + // runtime timeout) is rejected outright — same shape as + // duplicate sign requests. + if self.pending_attestations.contains_key(&account_id) { + env::panic_str("verification already pending"); + } + + let (quote, collateral) = extract_dcap_inputs(&dstack); + let attached_deposit = env::attached_deposit(); + + // Reuses the existing `enqueue_yield_request` helper that + // wraps `env::promise_yield_create`. The helper allocates + // `data_id`, registers `on_attestation_verified` as the + // yield-callback, and surfaces `data_id` via the `insert` + // closure so we can stash it together with the rest of the + // `PendingAttestation` fields. + self.enqueue_yield_request( + "on_attestation_verified", + borsh::to_vec(&account_id).unwrap(), + Gas::from_tgas(YIELD_CALLBACK_GAS_TGAS), + |this, data_id| { + this.pending_attestations.insert( + account_id.clone(), + PendingAttestation { + dstack, + tls_pk, + attached_deposit, + data_id, + }, + ); + }, + ); - Ok(Promise::new(verifier_account_id) - .function_call( - "verify_quote".into(), - borsh::to_vec(&(&attestation.quote, &attestation.collateral)).unwrap(), - NearToken::from_yoctonear(0), - Gas::from_tgas(self.config.verifier_tera_gas), - ) - .then( - Self::ext(env::current_account_id()) - .with_static_gas(Gas::from_tgas(self.config.resolve_verification_tera_gas)) - .with_attached_deposit(env::attached_deposit()) - .resolve_verification(VerificationContext { node_id, attestation }), - )) + // Cross-contract call to the verifier. Its `.then` callback + // (`resolve_verification`) is the bridge that turns the + // verifier's response into a `promise_yield_resume` on the + // yield this method registered above. + Promise::new(self.tee_verifier_account_id.clone()) + .function_call( + "verify_quote".into(), + borsh::to_vec(&(quote, collateral)).unwrap(), + NearToken::from_yoctonear(0), + Gas::from_tgas(VERIFIER_GAS_TGAS), + ) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(Gas::from_tgas(RESOLVE_GAS_TGAS)) + .resolve_verification(account_id), + ); + + // The yield handle was returned by `enqueue_yield_request` + // via `env::promise_return`, so the caller's `Promise` + // resolves with whatever the yield-callback returns. + PromiseOrValue::Value(()) + } + } } - /// Verify-quote callback. `#[payable]` because the submitter's deposit - /// rides forward via `.with_attached_deposit`, so `env::attached_deposit()` - /// here is the amount they attached. `#[callback_result]` distinguishes the - /// three verifier outcomes: - /// - /// - `Ok(Verified)` → run post-DCAP checks and store. - /// - `Ok(Rejected)` → `QuoteRejected { reason }`. - /// - `Err(_)` → `VerifierUnavailable` (unreachable / panicked / OOG). + /// `.then` bridge between the verifier's cross-contract call and the + /// yield this submission registered. Owns every outcome where the verifier + /// *answered* (`Ok(VerificationResult::{Verified,Rejected})`): on + /// `Verified` it runs the post-DCAP checks against fresh policy state and + /// inserts into `stored_attestations` on success; on `Rejected` it skips + /// straight to the refund. Either way it removes the pending entry, + /// schedules a refund where the outcome is an error, and calls + /// `promise_yield_resume(data_id, FinalOutcome)` as the LAST step of the + /// receipt — so a rejected quote is resolved *immediately*, not at the + /// timeout. Only `Err(PromiseError::Failed)` (no verdict — verifier + /// unreachable or crashed) is logged and returned early WITHOUT resuming or + /// removing the pending entry; `on_attestation_verified` owns that cleanup + /// on its `Err(PromiseError::Failed)` branch, so we must not race the + /// timeout for it. State mutations in this receipt are visible to the + /// yield-callback that fires next; if any line below `promise_yield_resume` + /// panicked or OOG'd, the entire receipt would roll back atomically (no + /// partial state commits) and the runtime's ~200-block yield-timeout would + /// still fire `on_attestation_verified` with `Err(PromiseError::Failed)` + /// for cleanup. /// - /// On success returns `Value(())` (attestation stored, storage charged, - /// excess refunded). On any error it refunds the WHOLE attached deposit in - /// this receipt, then fires a SEPARATE `fail_attestation_submission` - /// receipt whose panic fails the caller's transaction — the split is what - /// lets the refund commit, since a panic in this receipt would roll it back - /// (and drop the created promises) along with the refund. + /// Same architectural shape as [`pending_requests::resolve_yields_for`][pending-requests-mod] + /// in the sign-request flow: the response-side function owns the state + /// mutation and the `promise_yield_resume` call; the yield-callback is + /// kept trivial. #[private] - #[payable] pub fn resolve_verification( &mut self, - #[serializer(borsh)] context: VerificationContext, - #[serializer(borsh)] - #[callback_result] - result: Result, - ) -> PromiseOrValue<()> { - let account_id = context.node_id.account_id.clone(); - - let attestation_result = match result { - Ok(VerificationResult::Verified(report)) => { - self.verify_post_dcap_and_store(&context, &report) + account_id: AccountId, + #[callback_result] result: Result, + ) { + let final_outcome = match result { + // No verdict: the verifier was unreachable, panicked, or ran out of + // gas. Do nothing — the runtime's yield-timeout will fire + // `on_attestation_verified` with `Err(PromiseError::Failed)` and + // clean up the pending entry there. We must not call + // `promise_yield_resume` here, or we'd race the timeout for + // ownership of the cleanup path. + Err(promise_err) => { + log!("verifier did not answer for {account_id}: {promise_err:?}"); + return; } + // The verifier ran and rejected the quote. A definitive verdict: + // refund and resume now, with the reason, rather than waiting for + // the timeout. Ok(VerificationResult::Rejected(reason)) => { log!("verifier rejected quote for {account_id}: {reason}"); - Err(TeeError::QuoteRejected { reason: reason.to_string() }.into()) + FinalOutcome::Err(format!("verifier: {reason}")) } - Err(promise_err) => { - log!("verifier did not answer for {account_id}: {promise_err:?}"); - Err(TeeError::VerifierUnavailable.into()) + Ok(VerificationResult::Verified(report)) => { + let pending = self.pending_attestations.get(&account_id).expect( + "PendingAttestation must exist while resolve_verification holds the yield", + ); + // Post-DCAP checks operate on the verified report plus state held + // here. The allowlist is read fresh — governance votes mid-flight + // take effect. + match finish_verify(pending, &report, self.allowlist_fresh()) { + Ok(()) => { + self.tee_state.stored_attestations.insert( + pending.tls_pk.clone(), + VerifiedAttestation::from((pending.clone(), report)), + ); + FinalOutcome::Ok + } + Err(reason) => { + log!("post-DCAP check failed for {account_id}: {reason}"); + FinalOutcome::Err(format!("post-DCAP: {reason}")) + } + } } }; - match attestation_result { - Ok(()) => PromiseOrValue::Value(()), - Err(err) => { - refund_to(&account_id, env::attached_deposit()); - let promise = Promise::new(env::current_account_id()).function_call( - "fail_attestation_submission".into(), - borsh::to_vec(&err.to_string()).unwrap(), - NearToken::from_yoctonear(0), - Gas::from_tgas(self.config.fail_attestation_submission_tera_gas), - ); - PromiseOrValue::Promise(promise.as_return()) - } + let pending = self + .pending_attestations + .remove(&account_id) + .expect("PendingAttestation must exist while resolve_verification holds the yield"); + if matches!(final_outcome, FinalOutcome::Err(_)) { + refund_deposit(&account_id, pending.attached_deposit); } + // `promise_yield_resume` must be the LAST host call in this receipt: + // anything after it could panic and roll back the state mutations above. + env::promise_yield_resume(&pending.data_id, borsh::to_vec(&final_outcome).unwrap()); } - /// Runs the post-DCAP checks and stores the attestation for a `Verified` - /// response. The callback receipt commits regardless of the `Err` returned, - /// so a failed storage charge cannot rely on implicit rollback: it reverts - /// the store explicitly, or the caller would get storage for free plus a - /// full refund. - fn verify_post_dcap_and_store( + /// Yield-callback. Same shape as the sign-request callback + /// [`return_signature_and_clean_state_on_success`][sign-yield-callback]: the + /// `Verified` and `Rejected` outcomes (every case where the verifier + /// answered) were already finalized by `resolve_verification` (which removed + /// the pending entry and scheduled any refund before calling + /// `promise_yield_resume`), so this body just returns the outcome to the + /// caller. + /// + /// The only branch that does real work is `Err(PromiseError::Failed)`, fired + /// by the runtime ~200 blocks after submit if no `promise_yield_resume` has + /// landed: the verifier was unreachable / never responded so + /// `resolve_verification` deliberately returned early, or it ran but rolled + /// back (OOM / panic). On that branch the pending entry is still present, so + /// it removes the entry and schedules a deposit refund. + #[private] + pub fn on_attestation_verified( &mut self, - context: &VerificationContext, - report: &VerifiedReport, - ) -> Result<(), Error> { - let account_id = &context.node_id.account_id; - let initial_storage = env::storage_usage(); - let insertion = self.tee_state.verify_and_store_dstack( - context.node_id.clone(), - &context.attestation, - report, - /* tee_upgrade_deadline_duration */ - )?; - - match self.charge_attestation_storage(account_id, initial_storage) { - Ok(()) => Ok(()), - Err(err) => { - self.tee_state - .revert_dstack_store(&context.node_id.tls_public_key, insertion); - Err(err) + account_id: AccountId, + #[callback_result] result: Result, + ) -> Result<(), String> { + match result { + Ok(FinalOutcome::Ok) => Ok(()), + Ok(FinalOutcome::Err(reason)) => Err(reason), + Err(_promise_err) => { + if let Some(pending) = self.pending_attestations.remove(&account_id) { + refund_deposit(&account_id, pending.attached_deposit); + log!("yield timeout for {account_id}: refunded and cleaned up"); + } + Err("verifier did not respond within yield-resume window".to_string()) } } } +} - /// Separate receipt whose panic fails the caller's transaction after the - /// refund in `resolve_verification` has committed. - #[private] - pub fn fail_attestation_submission(#[serializer(borsh)] reason: String) { - log!("fail_attestation_submission: {reason}"); - env::panic_str(&reason); - } +#[derive(BorshSerialize, BorshDeserialize)] +pub enum FinalOutcome { + Ok, + Err(String), } ``` -`charge_attestation_storage` reads `env::attached_deposit()` itself: if the attached amount is less than the measured storage cost it returns `InsufficientDeposit`; otherwise it refunds the excess to the account via `refund_to`. `refund_to` is the generic refund helper (a detached `transfer` promise, no-op on zero). +`VERIFIER_GAS_TGAS`, `RESOLVE_GAS_TGAS`, and `YIELD_CALLBACK_GAS_TGAS` are placeholders until benchmarked. The verifier-side cost is dominated by ECDSA verifications and X.509-chain walking inside `dcap_qvl::verify::verify`. The bulk of the contract-side post-DCAP work — RTMR3 replay, app-compose validation, allowlist matching, report-data binding, plus the `stored_attestations.insert` — runs inside `resolve_verification`, so `RESOLVE_GAS_TGAS` gets the largest budget. `YIELD_CALLBACK_GAS_TGAS` can be conservatively small (on the order of 10 TGas with comfortable headroom): the yield-callback only does a `LookupMap::remove` and schedules a `Promise` on the timeout branch, and just returns a value on the resume branch. -`verifier_tera_gas`, `resolve_verification_tera_gas`, and `fail_attestation_submission_tera_gas` are unbenchmarked estimates until measured. The verifier-side cost (`verifier_tera_gas`) is dominated by ECDSA verifications and X.509-chain walking inside `dcap_qvl::verify::verify`, so it gets the largest budget. `resolve_verification_tera_gas` covers the contract-side post-DCAP work — RTMR3 replay, app-compose validation, allowlist matching, report-data binding — plus the `verify_and_store_dstack` insert and the storage charge. `fail_attestation_submission_tera_gas` can be tiny (a couple of TGas): the method only logs and panics. +The contract gains the following state fields: -### Contract state changes summary +```rust +pub struct MpcContract { + // ... existing fields, including tee_verifier_account_id and + // tee_verifier_votes from §Voting on the trusted verifier ... + pending_attestations: LookupMap, +} -No new attestation state fields. The chain carries a `VerificationContext { node_id, attestation }` as a borsh callback argument; nothing new is written to storage. The only new fields on the contract are `tee_verifier_account_id` and `tee_verifier_votes` from [§Voting on the trusted verifier](#voting-on-the-trusted-verifier-in-mpc-contract). +pub struct PendingAttestation { + pub dstack: DstackAttestation, + pub tls_pk: Ed25519PublicKey, + pub attached_deposit: NearToken, + pub data_id: CryptoHash, +} +``` ## Testing -The no-yield chain adds a handful of resolution branches the synchronous version never had, all inside `resolve_verification` and the helper it delegates to: - -- **Verifier not configured** — `Dstack` submit while `tee_verifier_account_id` is `None` fails *synchronously* with `VerifierNotConfigured`; the submit transaction itself errors, no promise is scheduled. -- **Verified + store happy path** — `verify_quote` returns `Verified`, post-DCAP passes, storage charged, excess refunded, `Value(())`. The attestation is present in state afterward. -- **Verified + post-DCAP fail** — `verify_and_store_dstack` errors; `resolve_verification` refunds the whole deposit and fires the `fail_attestation_submission` receipt; nothing is stored. -- **Verified + insufficient deposit** — post-DCAP passes but `charge_attestation_storage` returns `InsufficientDeposit`; `verify_post_dcap_and_store` reverts the store explicitly, so state is unchanged; refund + fail receipt. -- **Rejected → fail + refund** — `verify_quote` returns `Rejected`; `resolve_verification` returns `QuoteRejected` carrying the reason; refund + fail receipt. -- **Verifier unreachable → `VerifierUnavailable`** — the callback observes `Err(PromiseError::Failed)`; refund + fail receipt. -- **OOG in `resolve_verification` rolls back atomically** — an out-of-gas or panic mid-callback rolls back the whole receipt (no partial store, no partial refund) and fails the caller's transaction; because nothing was inserted at submit time, there is no orphaned state to reclaim. +The yield-resume split adds four resolution branches the synchronous version never had. Three do their work in `resolve_verification`, each from a distinct verifier answer: `Verified` + post-DCAP pass (store + resume `Ok`), `Verified` + post-DCAP fail (refund + resume `Err`), and `Rejected` (refund + resume `Err`, immediately — the path that recovers the synchronous-rejection behavior the split would otherwise lose). The fourth lives in `on_attestation_verified`, on its `Err(PromiseError::Failed)` branch, reached when the verifier gave no verdict — unreachable, panicked, or no resume landed within ~200 blocks (verifier silent, or a `resolve_verification` receipt that rolled back). That no-verdict case re-enters `resolve_verification`, which logs and returns early without resuming, so its cleanup happens in `on_attestation_verified`. Each branch needs test coverage, and exercising them requires the verifier to return specific answers on demand — a `Verified` or `Rejected` value for the three `resolve_verification` branches, and for the no-verdict path either an unreachable account or the test driver advancing the chain past the yield-resume window without resuming. The verifier-rotation design changes the test surface in three ways. First, the expiration window itself: an entry whose `expiry_timestamp_seconds` is in the past must be rejected by `re_verify` even when every post-DCAP allowlist invariant still holds, and an entry within the (shortened) window must still pass — this is the existing expiry check, now exercised against the lowered `DEFAULT_EXPIRATION_DURATION_SECONDS`. Second, rotation routing: after `vote_tee_verifier_change` crosses threshold, the next `submit_participant_info` must call `verify_quote` on the new `tee_verifier_account_id`, and existing stored entries must remain present (no purge) until they expire. Third, the in-flight case: a verification scheduled against the old verifier that resolves after the vote crosses threshold must still be stored as a normal entry — it is not treated specially and ages out via the same expiration window as any other entry. -To make that practical, we introduce a stub `tee-verifier` crate: same `tee-verifier-interface` DTOs as the real verifier, but `verify_quote` returns whatever `VerificationResult` (`Verified` or `Rejected`) the test asks for — and a stub that panics, or an undeployed account, covers the `VerifierUnavailable` path. Sandbox tests deploy the stub like any other verifier candidate — lock its account, then call `vote_tee_verifier_change` from the test setup to point `mpc-contract` at the stub. This runs the same code path as production; nothing in `mpc-contract` knows or cares whether it's talking to the real verifier or the stub. +To make that practical, we introduce a stub `tee-verifier` crate: same `tee-verifier-interface` DTOs as the real verifier, but `verify_quote` returns whatever `VerificationResult` (`Verified` or `Rejected`) the test asks for — and a stub that panics, or an undeployed account, covers the no-verdict path. Sandbox tests deploy the stub like any other verifier candidate — lock its account, then call `vote_tee_verifier_change` from the test setup to point `mpc-contract` at the stub. This runs the same code path as production; nothing in `mpc-contract` knows or cares whether it's talking to the real verifier or the stub. E2E tests in `crates/e2e-tests` deploy either the real `tee-verifier` (when the test wants real `dcap-qvl` against a fixture quote) or the stub (for everything else). The change is one extra `deploy` call in the setup helper. @@ -569,9 +618,17 @@ E2E tests in `crates/e2e-tests` deploy either the real `tee-verifier` (when the [nep-509]: https://github.com/near/NEPs/blob/master/neps/nep-0509.md [re-verify]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/mpc-attestation/src/attestation.rs#L93 [periodic-attestation-submission]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/tee/remote_attestation.rs#L140 +[attestation-resubmission-interval]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/run.rs#L43 +[attestation-attempts-metric]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/metrics.rs#L364 [verify-tee]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1543 [clean-invalid-attestations]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1646 +[clean-foreign-chain-data]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1669 [monitor-attestation-removal]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/tee/remote_attestation.rs#L209 [submit-participant-info]: https://github.com/near/mpc/blob/efe49230bb66854c55bba080e7610e42f9221506/crates/contract/src/lib.rs#L754-L782 [launcher-pattern]: https://github.com/near/mpc/blob/efe49230bb66854c55bba080e7610e42f9221506/docs/tee-lifecycle.md#upgrade [slack-launcher-discussion]: https://nearone.slack.com/archives/C0B12RKBSAV/p1777897902903889 +[promise-yield-create]: https://docs.rs/near-sdk/5.26.1/near_sdk/env/fn.promise_yield_create.html +[promise-yield-resume]: https://docs.rs/near-sdk/5.26.1/near_sdk/env/fn.promise_yield_resume.html +[enqueue-yield-request]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L301-L323 +[pending-requests-mod]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/pending_requests.rs +[sign-yield-callback]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1999-L2023 diff --git a/docs/localnet/tee-localnet.md b/docs/localnet/tee-localnet.md index 07c32cba98..054a39042f 100644 --- a/docs/localnet/tee-localnet.md +++ b/docs/localnet/tee-localnet.md @@ -276,7 +276,7 @@ near transaction view-status network-config mpc-localnet ``` ``` -(ExecutionError("Smart contract panicked: the submitted attestation failed verification, reason: Custom(\"the allowed mpc image hashes list is empty\")" +(ExecutionError("Smart contract panicked: Invalid TEE Remote Attestation.: TeeQuoteStatus is invalid: the allowed mpc image hashes list is empty" ``` ### Vote Commands diff --git a/docs/running-an-mpc-node-in-tdx-external-guide.md b/docs/running-an-mpc-node-in-tdx-external-guide.md index 60c969e0ec..3c455fc1f5 100644 --- a/docs/running-an-mpc-node-in-tdx-external-guide.md +++ b/docs/running-an-mpc-node-in-tdx-external-guide.md @@ -2062,7 +2062,8 @@ The error after `err=` is the NEAR runtime error. Common ones: If the transaction reaches execution and the contract panics, the node logs only the generic retry line above; the actual message lives in the transaction receipt. Find the tx on `https://testnet.nearblocks.io/address/` and open the failed `submit_participant_info` call — the error appears under the action's status / logs. The contract wraps the attestation-side error like this: ``` -the submitted attestation failed verification, reason: Custom("...") +Invalid TEE Remote Attestation: TeeQuoteStatus is invalid: + the submitted attestation failed verification, reason: Custom("...") ``` The `reason` is the same `VerificationError` the client-side WARN reports (see section 1) — for example `Custom("the allowed mpc image hashes list is empty")`. Errors that **only** surface on-chain (because they're checked against the contract's allowed-measurements list, the contract's deposit logic, or the contract's caller assertion): From ac5dead49e91caa6d61c96234e6a1191442fc992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 15 Jul 2026 16:42:44 +0200 Subject: [PATCH 43/44] docs: align TEE attestation docs with the no-yield design Rewrite docs/design/attestation-verifier-contract.md to describe the no-yield promise-chain submit_participant_info flow (submit_dstack_attestation + resolve_verification, deposit forwarded on the callback, fail_attestation_submission on failure) instead of the superseded yield-resume / pending_attestations / timeout design, and fix the re_verify source reference. Correct stale on-chain error strings in docs/localnet/tee-localnet.md and docs/running-an-mpc-node-in-tdx-external-guide.md. Closes #3825 --- docs/design/attestation-verifier-contract.md | 459 ++++++++---------- docs/localnet/tee-localnet.md | 2 +- ...nning-an-mpc-node-in-tdx-external-guide.md | 3 +- 3 files changed, 203 insertions(+), 261 deletions(-) diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 0346ce6c69..3ddb42ca58 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -59,92 +59,93 @@ flowchart LR ### Submission flow -`mpc-contract`'s [`submit_participant_info`][submit-participant-info] becomes asynchronous for Dstack attestations and uses the same yield-resume pattern already in place for `sign`, `request_app_private_key`, and `request_verify_foreign_tx`. The method registers a yielded promise via [`env::promise_yield_create`][promise-yield-create] (going through the contract's existing [`enqueue_yield_request`][enqueue-yield-request] helper), stashes the resulting `data_id` in `pending_attestations`, and fires a cross-contract call to `tee-verifier::verify_quote` whose own `.then` callback (`resolve_verification`) bridges the verifier's response into a `promise_yield_resume`. Because `verify_quote` returns a `VerificationResult` value rather than failing the receipt (see [§Why a rejection is a value](#why-a-rejection-is-a-value-not-a-failed-receipt)), `resolve_verification`'s `#[callback_result]` distinguishes three outcomes: `Verified` runs the post-DCAP checks (RTMR3 replay, app-compose validation, measurement allowlist matching, report-data binding) and resumes; `Rejected` refunds and resumes **immediately** with the reason; and only `Err(PromiseError::Failed)` — the verifier unreachable or crashed — returns early, leaving cleanup to the ~200-block timeout. The yield-callback `on_attestation_verified` stays trivial, the same shape as the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]: it returns the resumed outcome to the caller, and only on the timeout (`Err(PromiseError::Failed)`) does it remove the pending entry and schedule a refund. Keeping the heavy work in `resolve_verification` makes an OOG in the yield-callback implausible, and an OOG in `resolve_verification` rolls back its whole receipt so the timeout still fires the trivial callback for cleanup. The exact branch-by-branch behavior is in [§Handling failures](#handling-failures) below. +`mpc-contract`'s [`submit_participant_info`][submit-participant-info] becomes asynchronous for Dstack attestations, but without yield-resume: it settles the submission entirely inside a single cross-contract promise chain. The method returns `Result, Error>`. A `Mock` attestation is verified and stored synchronously and returns `Ok(PromiseOrValue::Value(()))`. A `Dstack` attestation is handed to `submit_dstack_attestation`, which builds a `Promise` that calls `tee-verifier::verify_quote` and chains `resolve_verification` as its `.then` callback; the method returns `Ok(PromiseOrValue::Promise(...))`. The attached deposit is forwarded onto the callback via `.with_attached_deposit(env::attached_deposit())` rather than stashed in contract state, so `resolve_verification` can charge storage or refund from it directly. -The post-DCAP policy inputs are the same fields `mpc-contract` already holds today — the allowed-image-hash list, the per-account TLS / account public-key binding, and the stored-attestation map. No new policy state is introduced; the only state addition is the `pending_attestations` map described below, which is bookkeeping for the in-flight yield. +Because `verify_quote` returns a `VerificationResult` value rather than failing the receipt (see [§Why a rejection is a value](#why-a-rejection-is-a-value-not-a-failed-receipt)), `resolve_verification`'s `#[callback_result]` distinguishes three outcomes: `Ok(Verified)` runs the post-DCAP checks (RTMR3 replay, app-compose validation, measurement allowlist matching, report-data binding) and, on success, stores the attestation and charges storage; `Ok(Rejected)` returns a `QuoteRejected` error carrying the reason; and `Err(PromiseError::Failed)` — the verifier unreachable, panicked, or out of gas — returns `VerifierUnavailable`. On any error branch `resolve_verification` refunds the whole attached deposit and fires a *separate* `fail_attestation_submission` receipt whose panic fails the submitter's transaction. There is no yield, no `data_id`, no `pending_attestations` entry, and no ~200-block timeout: a failure settles immediately within the same promise chain. The exact branch-by-branch behavior is in [§Handling failures](#handling-failures) below. -The periodic re-validation path ([`re_verify`](../../crates/contract/src/tee/tee_state.rs)) does not call `dcap_qvl::verify` — it re-checks post-DCAP allowlist invariants against already-stored attestations — and is therefore unaffected by this design. It stays synchronous. +The post-DCAP policy inputs are the same fields `mpc-contract` already holds today — the allowed-image-hash list, the per-account TLS / account public-key binding, and the stored-attestation map. No new policy state is introduced, and the chain carries no bookkeeping map: everything `resolve_verification` needs travels as a `VerificationContext` borsh argument on the callback. + +The periodic re-validation path ([`re_verify`](../../crates/mpc-attestation/src/attestation.rs)) does not call `dcap_qvl::verify` — it re-checks post-DCAP allowlist invariants against already-stored attestations — and is therefore unaffected by this design. It stays synchronous. ```mermaid sequenceDiagram participant Op as Operator participant MPC as mpc-contract - participant State as State participant Ver as tee-verifier participant DCAP as dcap-qvl Op->>MPC: submit_participant_info(Dstack, tls_pk) - MPC->>MPC: promise_yield_create → data_id - MPC->>State: insert PendingAttestation { data_id, ... } - MPC->>Ver: Promise: verify_quote (chained .then resolve_verification) + MPC->>Ver: Promise: verify_quote(quote, collateral) + Note over MPC: .then resolve_verification(VerificationContext),
attached deposit forwarded to the callback Ver->>DCAP: verify(quote, collateral, now) - alt Verified (post-DCAP runs, then resumes) + alt Verified + store ok Ver-->>MPC: VerificationResult::Verified(report) - MPC->>MPC: resolve_verification: finish_verify vs fresh allowlist - MPC->>State: store on pass / refund on fail, remove PendingAttestation - MPC->>MPC: promise_yield_resume(data_id, FinalOutcome) - MPC-->>Op: success or error, immediately - else Rejected (resumes immediately) - Ver-->>MPC: VerificationResult::Rejected(reason) - MPC->>MPC: resolve_verification: refund, remove PendingAttestation - MPC->>MPC: promise_yield_resume(data_id, FinalOutcome::Err(reason)) - MPC-->>Op: error (carrying reason), immediately - else No verdict — verifier unreachable / silent for ~200 blocks - Note over MPC: resolve_verification (if it runs) only logs and returns.
Cleanup is left to the runtime yield-timeout. - MPC->>MPC: on_attestation_verified fires with Err(PromiseError::Failed) - MPC->>State: remove PendingAttestation, refund - MPC-->>Op: error + MPC->>MPC: resolve_verification: verify_post_dcap_and_store vs fresh allowlist + MPC->>MPC: charge_attestation_storage (refund excess) + MPC-->>Op: PromiseOrValue::Value(()) — success + else Verified + post-DCAP fail, or Rejected, or verifier unreachable + Ver-->>MPC: Verified(report) / Rejected(reason) / (no answer) + MPC->>MPC: resolve_verification produces Err (QuoteRejected / VerifierUnavailable) + MPC->>Op: refund whole attached deposit (this receipt) + MPC->>MPC: fail_attestation_submission receipt panics + MPC-->>Op: transaction fails (carrying the reason) end ``` #### Caller-side impact -The only caller of `submit_participant_info` in production is `mpc-node`'s `periodic_attestation_submission` task, which resubmits on a 1-hour cadence and on attestation-removal events. It already polls contract state to confirm the attestation is actually stored, with exponential backoff (100 ms → 60 s, capped at 12 h). That polling-based success criterion is what makes the sync→async change transparent. Under yield-resume the returned `Promise` also resolves with the actual outcome — success, a verifier-rejection error, or a post-DCAP-failure error (each as soon as the verifier answers), or a timeout error after ~200 blocks if it never does — so any future caller that wants to await the result synchronously can, without changing the contract. +The only caller of `submit_participant_info` in production is `mpc-node`'s `periodic_attestation_submission` task, which resubmits on a 1-hour cadence and on attestation-removal events. It already polls contract state to confirm the attestation is actually stored, with exponential backoff (100 ms → 60 s, capped at 12 h). That polling-based success criterion is what makes the sync→async change transparent. The returned `Promise` now resolves through the chain with the actual outcome — success, a verifier-rejection error, a post-DCAP-failure error, or a `VerifierUnavailable` error if the verifier never answers — so any future caller that wants to await the result synchronously can, without changing the contract. There is no ~200-block timeout error to account for: every path settles as soon as the verifier's receipt finishes. #### Handling failures -The first thing `submit_participant_info` does is insert a `PendingAttestation` entry, and that entry has to come back out once verification finishes — successfully or not. If a failure leaves the entry behind, the submitter's account is wedged: every future `submit_participant_info` call panics on the "already pending" guard, and the deposit stays locked because the refund is part of the cleanup the contract never got around to. +The submission produces no in-flight state to clean up: nothing is inserted into contract storage at submit time, so there is no pending entry that a failure could leave wedged and no "already pending" guard to trip on a resubmit. What a failure must still get right is the money — the attached deposit — and the caller-facing outcome. Both are handled in the single `.then` callback, `resolve_verification`. + +`resolve_verification` is a `#[private]` `#[payable]` method. It is `#[payable]` because the deposit rides forward onto it via `.with_attached_deposit`, so `env::attached_deposit()` inside the callback returns the amount the submitter attached. It observes the verifier's answer through `#[callback_result]` and reduces it to a `Result<(), Error>`: + +- `Ok(VerificationResult::Verified(report))` → `verify_post_dcap_and_store(&context, &report)`, which returns `Ok(())` on a clean store or an `Err` if a post-DCAP check or the storage charge fails. +- `Ok(VerificationResult::Rejected(reason))` → `Err(QuoteRejected { reason })`. +- `Err(promise_err)` → `Err(VerifierUnavailable)` — the verifier was unreachable, panicked, or ran out of gas. -That makes *where* the cleanup runs the central question, because NEAR offers two natural homes for "do something when the verifier responds" and they have very different failure modes. +On `Ok(())` the callback returns `PromiseOrValue::Value(())`; the attestation is stored and storage has been charged, with any excess deposit refunded inside `charge_attestation_storage`. -A **`.then` callback** is a normal cross-contract callback chained onto the verifier's promise. The runtime runs it in a fresh receipt once the verifier's receipt finishes; if it panics or runs out of gas, that receipt rolls back atomically and the chain ends. Because the receipt is independent of whatever yield is parked in parallel, its failure has no special effect on the submitter's call — the submitter just keeps waiting on the yield. +On `Err(err)` the callback does two things, in order, and the order is the whole point: -A **yield-callback** is different. When `submit_participant_info` calls `promise_yield_create`, it asks the runtime to *park* the submitter's call so the contract can return its result later. The runtime fires the named callback exactly once per `data_id` — either when something calls `promise_yield_resume(data_id, payload)`, or after ~200 blocks of silence with `Err(PromiseError::Failed)`. That single firing's return value is what the submitter eventually receives. There is no second invocation: an OOG inside the yield-callback rolls back its whole receipt and drops whatever cleanup it was meant to do, with no automatic retry. +1. `refund_to(&account_id, env::attached_deposit())` — refund the *entire* attached deposit in this receipt. +2. Schedule a *separate* `fail_attestation_submission` receipt via `Promise::new(current_account).function_call(...).as_return()`, and return it as `PromiseOrValue::Promise`. -The asymmetry decides the design. The work the verifier's *answer* unlocks — post-DCAP checks, the `stored_attestations` insert, the refund on rejection or post-DCAP failure, the pending-entry removal, the `promise_yield_resume` call — lives in the `.then` bridge `resolve_verification`. If it aborts mid-flight, the entire receipt rolls back atomically (including the resume), so the yield stays parked and the runtime's 200-block timeout still fires the yield-callback for cleanup — same recovery as "verifier never responded." `resolve_verification` resolves immediately on either answer it can act on: `Verified` (run post-DCAP, then resume) and `Rejected` (refund and resume with the reason). Only `Err(PromiseError::Failed)` — no verdict, the verifier was unreachable or crashed — is deliberately *not* resolved here: `resolve_verification` logs and returns early, routing that case to the timeout cleanup. The yield-callback `on_attestation_verified` is intentionally tiny: on resume, return the value to the caller; on its `Err(PromiseError::Failed)` branch — verifier unreachable or silent timeout — remove the pending entry and schedule a refund. +The refund and the failure live in different receipts deliberately. `fail_attestation_submission` is a tiny `#[private]` method that logs the reason and then `env::panic_str(&reason)` — its panic is what fails the submitter's transaction and surfaces the error. If that panic instead happened inside `resolve_verification` (the `#[handle_result]`-return-an-`Err` shape), it would roll back the whole callback receipt, discarding the refund transfer and any created promises along with it. Splitting them lets the refund commit in the first receipt while the second receipt fails the caller's transaction afterward. + +`verify_post_dcap_and_store` has its own commit-order subtlety. Unlike the synchronous `Mock` path — where returning an `Err` rolls back the entire method receipt and un-does any partial store — this callback receipt *commits regardless* of the `Err` it hands back to `resolve_verification`. So the store cannot be left to implicit rollback. `verify_post_dcap_and_store` snapshots `env::storage_usage()`, calls `verify_and_store_dstack`, then `charge_attestation_storage`; if the charge fails (`InsufficientDeposit`), it explicitly calls `tee_state.revert_dstack_store(tls_pk, insertion)` before returning the error, so the caller never gets storage for free plus a full refund. Walking every path the system can take: -- `resolve_verification` resumes (verifier returned `Verified` (post-DCAP pass or fail) or `Rejected`) → it cleaned up before resuming, caller receives the outcome. -- `resolve_verification` returns early (verifier unreachable — `Err(PromiseError::Failed)`) → timeout fires, yield-callback cleans up. -- `resolve_verification` aborts (OOG / panic mid-receipt) → timeout fires, yield-callback cleans up. -- `resolve_verification` never runs (verifier silent) → timeout fires, yield-callback cleans up. -- Yield-callback itself OOGs → genuine orphan. Its body is one `LookupMap::remove` plus one Promise schedule, small enough that an out-of-gas failure is implausible with a sensible gas budget. +- Verifier returned `Verified`, post-DCAP checks pass, storage charge succeeds → attestation stored, excess refunded, `Value(())`. Caller polls and sees the entry. +- Verifier returned `Verified` but a post-DCAP check fails → `verify_and_store_dstack` errors (nothing was stored), `resolve_verification` refunds and fires the fail receipt. +- Verifier returned `Verified`, post-DCAP passes, but the storage charge fails → `verify_post_dcap_and_store` reverts the store explicitly, returns the error, `resolve_verification` refunds and fires the fail receipt. +- Verifier returned `Rejected` → `QuoteRejected`, refund + fail receipt. +- Verifier unreachable / panicked / out of gas (`Err(PromiseError::Failed)`) → `VerifierUnavailable`, refund + fail receipt. This is handled right here, immediately; it is not deferred to any timeout. +- `resolve_verification` itself runs out of gas or panics mid-receipt → the whole callback receipt rolls back atomically, no partial commits, and the submitter's transaction fails. Because nothing was inserted at submit time, there is no orphaned state to reclaim. -This isn't a novel pattern in this codebase: `sign`, `request_app_private_key`, and `request_verify_foreign_tx` already use the same split. The heavy work and the `promise_yield_resume` call live in [`pending_requests::resolve_yields_for`][pending-requests-mod] (invoked from `respond`, `respond_ckd`, and `respond_verify_foreign_tx`), and [`return_signature_and_clean_state_on_success`][sign-yield-callback] is a tiny yield-callback that just routes the outcome back to the caller and handles the no-response timeout. The attestation flow inherits the same orphan-proof argument: heavy work in a safely-recoverable receipt, cleanup in a callback small enough that gas budgeting makes its failure implausible. +The verifier still returns its verdict as a *value* rather than a failed receipt, so `#[callback_result]` can tell a definitive `Rejected` apart from `Err(PromiseError::Failed)` (no answer). Under the no-yield design both still lead to an immediate fail-and-refund in `resolve_verification`; the distinction only changes the error type and message the caller sees (`QuoteRejected` with the reason vs `VerifierUnavailable`), not whether cleanup is immediate. ### Contract state changes -The callback runs in a later block than `submit_participant_info`, as an independent contract invocation. Anything the callback still needs must be stashed in contract storage, in a new field: +`resolve_verification` runs in a later block than `submit_participant_info`, as an independent contract invocation, so anything it needs from the original call must travel with it. That is done not through contract storage but through a borsh callback argument: ```rust -pending_attestations: LookupMap +pub struct VerificationContext { + pub(crate) node_id: NodeId, + pub(crate) attestation: DstackAttestation, +} ``` -This map mirrors the other pending-request maps in `mpc-contract` ([`pending_signature_requests`][pending-requests-mod], `pending_ckd_requests`, `pending_verify_foreign_tx_requests`), but stores a single `PendingAttestation` per `AccountId` rather than a `Vec`: attestation submissions are 1-per-account. +`VerificationContext` carries the submitter's `NodeId` (account id, TLS public key, and account public key — the binding the post-DCAP report-data check reproves) and the full `DstackAttestation` payload (RTMR3 event log, app-compose, report-data) that the post-DCAP checks consume. It is passed to `resolve_verification` as a `#[serializer(borsh)]` argument and is never written to contract state. The attached deposit is *not* part of it — it rides forward on the promise via `.with_attached_deposit`, so `env::attached_deposit()` in the callback yields the submitter's deposit directly. -Each [`PendingAttestation`](#mpc-contractsubmit_participant_info) holds: +This design adds **no** new attestation-related state. There is no `pending_attestations` map, no `PendingAttestation` struct, no `AttestationResult` enum, and no stashed `data_id` or deposit. The only new fields on the contract are `tee_verifier_account_id` and `tee_verifier_votes`, both from [§Voting on the trusted verifier](#voting-on-the-trusted-verifier-in-mpc-contract). -- **The submitter's `Attestation::Dstack` payload** — the RTMR3 event log, app-compose, and report-data the post-DCAP checks consume. -- **The submitter's TLS public key** — the callback hashes it with the submitter's account public key and compares to the quote's `report_data` field, proving the enclave produced the quote for this specific submitter. -- **The attached deposit** — covers storage staking on success, refunded to the signer of the original `submit_participant_info` transaction on failure. `env::attached_deposit()` is not visible from the callback receipt, so the value is stashed at submit time and the recipient `AccountId` is the same one used to key the entry (set by `Self::assert_caller_is_signer()`). -- **`data_id: CryptoHash`** — the yield handle returned by [`env::promise_yield_create`][promise-yield-create]. The intermediate `.then` callback (`resolve_verification`) reads this back to call [`env::promise_yield_resume`][promise-yield-resume] with a `FinalOutcome` after the post-DCAP checks have run. - -Entries are removed by `resolve_verification` on every branch where the verifier returned an answer — `Verified` (post-DCAP success or failure) and `Rejected`; only when the verifier gave no verdict (`Err(PromiseError::Failed)` — unreachable or crashed) does it return early and leave the entry in place. `on_attestation_verified` then removes the entry on its `Err(PromiseError::Failed)` branch, which covers that unreachable case and the no-response timeout. - -Notably absent from `PendingAttestation`: the **post-DCAP policy state** — allowed MPC image hashes, allowed launcher compose hashes, and accepted measurements. `resolve_verification` re-reads all of them from contract state, so any governance vote that adds or removes an entry mid-flight applies to verifications it overlaps. Snapshotting at request time would freeze each submission against stale policy — wrong default for a security control, where removing a compromised hash should take effect immediately. +Notably, the **post-DCAP policy state** — allowed MPC image hashes, allowed launcher compose hashes, and accepted measurements — is not snapshotted either. `verify_post_dcap_and_store` reads all of it fresh from contract state when the callback runs, so any governance vote that adds or removes an entry mid-flight applies to verifications it overlaps. Snapshotting at request time would freeze each submission against stale policy — wrong default for a security control, where removing a compromised hash should take effect immediately. ```mermaid sequenceDiagram @@ -154,8 +155,6 @@ sequenceDiagram participant Ver as tee-verifier Op->>MPC: submit_participant_info(Dstack, tls_pk) - MPC->>MPC: promise_yield_create → data_id - MPC->>MPC: insert PendingAttestation { data_id, ... } MPC->>Ver: Promise: verify_quote(...) (.then resolve_verification) Gov->>MPC: vote_add_image_hash(H) @@ -163,10 +162,8 @@ sequenceDiagram Ver-->>MPC: VerificationResult::Verified(report) MPC->>MPC: resolve_verification - MPC->>MPC: read allowlist (sees H) - MPC->>MPC: finish_verify against fresh allowlist - MPC->>MPC: promise_yield_resume(data_id, FinalOutcome) - MPC->>MPC: on_attestation_verified (trivial: return value) + MPC->>MPC: verify_post_dcap_and_store reads allowlist fresh (sees H) + MPC->>MPC: store on pass / refund + fail receipt on error ``` ## Crate layout @@ -281,13 +278,13 @@ pub enum VerificationResult { #### Why a rejection is a value, not a failed receipt -Encoding the rejection the obvious way — `Result` via `#[handle_result]`, so a rejection fails the receipt — loses the reason. NEAR's promise result is either `Successful(bytes)` or `Failed`, and **`Failed` carries no payload**: the caller's `#[callback_result]` sees a bare `Err(PromiseError::Failed)`, *identical* to the verifier being unreachable or out of gas. But `mpc-contract` must treat "the verifier rejected this quote" (definitive — refund and finish now) differently from "the verifier did not answer" (transient — wait for the yield timeout, the node resubmits). Returning the outcome as a *value* keeps them distinct, so `#[callback_result]` observes: +Encoding the rejection the obvious way — `Result` via `#[handle_result]`, so a rejection fails the receipt — loses the reason. NEAR's promise result is either `Successful(bytes)` or `Failed`, and **`Failed` carries no payload**: the caller's `#[callback_result]` sees a bare `Err(PromiseError::Failed)`, *identical* to the verifier being unreachable or out of gas. `mpc-contract` still wants to tell "the verifier rejected this quote" apart from "the verifier did not answer", so it can report the right error to the caller. Returning the outcome as a *value* keeps them distinct, so `#[callback_result]` observes: - `Ok(VerificationResult::Verified(report))` — quote valid; run post-DCAP checks. -- `Ok(VerificationResult::Rejected(reason))` — rejected; refund and resume **immediately**, with the reason. -- `Err(PromiseError::Failed)` — unreachable / panicked / timed out; the yield timeout cleans up. +- `Ok(VerificationResult::Rejected(reason))` — rejected; `resolve_verification` returns `QuoteRejected { reason }`. +- `Err(PromiseError::Failed)` — unreachable / panicked / out of gas; `resolve_verification` returns `VerifierUnavailable`. -This is the NEP-141 / NEP-171 shape — the meaningful outcome rides in the success payload, a failed receipt is reserved for "something broke" — and it preserves `mpc-contract`'s existing invariant that a rejection and a non-answer are never the same event. (near-sdk also refuses to serialize a bare `Result` return, so `VerificationResult` is a dedicated sum type.) +This is the NEP-141 / NEP-171 shape — the meaningful outcome rides in the success payload, a failed receipt is reserved for "something broke". Under the no-yield design both error branches lead to the same immediate refund-and-fail; keeping them distinct only changes the error type and message the caller receives. (near-sdk also refuses to serialize a bare `Result` return, so `VerificationResult` is a dedicated sum type.) ### Voting on the trusted verifier in `mpc-contract` @@ -301,7 +298,7 @@ The proposal payload is the pair `(candidate_account_id, expected_code_hash)`. ` #[near(serializers = [borsh])] pub struct VerifierChangeProposal { pub candidate_account_id: AccountId, - pub expected_code_hash: CryptoHash, + pub expected_code_hash: TeeVerifierCodeHash, } impl ProposalHashEncoding for VerifierChangeProposal { @@ -322,7 +319,7 @@ impl MpcContract { pub fn vote_tee_verifier_change( &mut self, candidate_account_id: AccountId, - expected_code_hash: CryptoHash, + expected_code_hash: TeeVerifierCodeHash, ); /// Withdraw the caller's current vote on any pending verifier-change @@ -337,17 +334,20 @@ The contract gains two new state fields: pub struct MpcContract { // ... existing fields ... - /// The locked account `mpc-contract` currently trusts as the verifier. - /// `submit_participant_info` calls `verify_quote` on this account. - /// Mutated only by the threshold-crossing vote above; the mutation - /// re-routes future submissions and does not touch already-stored - /// attestations. - tee_verifier_account_id: AccountId, + /// The locked account `mpc-contract` currently trusts as the verifier, or + /// `None` until participants vote one in (a `Dstack` `submit_participant_info` + /// is then rejected with `VerifierNotConfigured`). `submit_participant_info` + /// calls `verify_quote` on this account. Mutated only by the threshold-crossing + /// vote above; the mutation re-routes future submissions and does not touch + /// already-stored attestations. (Making this non-`Option` once a verifier is + /// voted in is the follow-up #3639.) + tee_verifier_account_id: Option, /// Pending votes for changing `tee_verifier_account_id`. Each voter is an /// active MPC participant; each proposal is hashed from - /// `(candidate_account_id, expected_code_hash)`. - tee_verifier_votes: Votes, + /// `(candidate_account_id, expected_code_hash)`. `TeeVerifierVotes` is a thin + /// newtype wrapping the generic `Votes`. + tee_verifier_votes: TeeVerifierVotes, } ``` @@ -371,7 +371,7 @@ sequenceDiagram Note over MPC: tee_verifier_account_id = new (routing only,
no eviction) VerOld-->>MPC: VerificationResult::Verified(report) - MPC->>MPC: resolve_verification (post-DCAP + insert, as usual) + MPC->>MPC: resolve_verification (post-DCAP + store, as usual) Note over MPC: stored entry ages out within the
expiration window via re_verify Op->>MPC: submit_participant_info(Dstack, tls_pk) (next hourly resubmit) @@ -381,235 +381,186 @@ sequenceDiagram ### `mpc-contract::submit_participant_info` -The method splits across three receipts joined by yield-resume — see [§Submission flow](#submission-flow) above for the architecture. The return type is [`PromiseOrValue<()>`](https://docs.rs/near-sdk/5.26.1/near_sdk/enum.PromiseOrValue.html), `near-sdk`'s "sometimes synchronous, sometimes a Promise chain" type: `Mock` attestations return `Value(())` immediately, and `Dstack` attestations return the yielded `Promise` from [`env::promise_yield_create`][promise-yield-create], which the runtime resolves either when `resolve_verification` calls [`env::promise_yield_resume`][promise-yield-resume] or after ~200 blocks of silence. The post-DCAP checks and the `stored_attestations` insert live in `resolve_verification`, not in the yield-callback — that's what keeps `on_attestation_verified` trivial enough to match the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]. Draft implementation: +The method resolves a Dstack submission through a two-receipt promise chain: `verify_quote` on the verifier, then `resolve_verification` as its callback — see [§Submission flow](#submission-flow) above for the architecture. It returns `Result, Error>`. A `Mock` attestation is verified and stored synchronously and returns `Ok(PromiseOrValue::Value(()))`. A `Dstack` attestation is delegated to `submit_dstack_attestation`, which builds the chain and returns `Ok(PromiseOrValue::Promise(...))`. The attached deposit is forwarded onto `resolve_verification` via `.with_attached_deposit`, so the callback can charge storage or refund without any state being stashed at submit time. There is no `pending_attestations` insert and no "one in-flight per account" guard. Draft implementation: ```rust impl MpcContract { + #[payable] + #[handle_result] pub fn submit_participant_info( &mut self, attestation: Attestation, - tls_pk: Ed25519PublicKey, - ) -> PromiseOrValue<()> { + tls_public_key: Ed25519PublicKey, + ) -> Result, Error> { // Existing convention: caller must be the signer of this transaction, // not a relayer or proxy. let account_id = Self::assert_caller_is_signer(); + let node_id = NodeId { account_id, tls_public_key, /* account_public_key */ }; + match attestation { - // Unchanged from today. + // Synchronous: no DCAP, verified and stored in this call. A + // returned Err here rolls back the whole receipt. Attestation::Mock(mock) => { - self.verify_mock_synchronously(mock, tls_pk); - PromiseOrValue::Value(()) - } - // Dstack: yield-resume. - Attestation::Dstack(dstack) => { - // One in-flight verification per AccountId. A duplicate submit - // before the previous one finishes (verifier response or - // runtime timeout) is rejected outright — same shape as - // duplicate sign requests. - if self.pending_attestations.contains_key(&account_id) { - env::panic_str("verification already pending"); - } - - let (quote, collateral) = extract_dcap_inputs(&dstack); - let attached_deposit = env::attached_deposit(); - - // Reuses the existing `enqueue_yield_request` helper that - // wraps `env::promise_yield_create`. The helper allocates - // `data_id`, registers `on_attestation_verified` as the - // yield-callback, and surfaces `data_id` via the `insert` - // closure so we can stash it together with the rest of the - // `PendingAttestation` fields. - self.enqueue_yield_request( - "on_attestation_verified", - borsh::to_vec(&account_id).unwrap(), - Gas::from_tgas(YIELD_CALLBACK_GAS_TGAS), - |this, data_id| { - this.pending_attestations.insert( - account_id.clone(), - PendingAttestation { - dstack, - tls_pk, - attached_deposit, - data_id, - }, - ); - }, - ); - - // Cross-contract call to the verifier. Its `.then` callback - // (`resolve_verification`) is the bridge that turns the - // verifier's response into a `promise_yield_resume` on the - // yield this method registered above. - Promise::new(self.tee_verifier_account_id.clone()) - .function_call( - "verify_quote".into(), - borsh::to_vec(&(quote, collateral)).unwrap(), - NearToken::from_yoctonear(0), - Gas::from_tgas(VERIFIER_GAS_TGAS), - ) - .then( - Self::ext(env::current_account_id()) - .with_static_gas(Gas::from_tgas(RESOLVE_GAS_TGAS)) - .resolve_verification(account_id), - ); - - // The yield handle was returned by `enqueue_yield_request` - // via `env::promise_return`, so the caller's `Promise` - // resolves with whatever the yield-callback returns. - PromiseOrValue::Value(()) + let initial_storage = env::storage_usage(); + self.tee_state.verify_and_store_mock(node_id, mock, ...)?; + self.charge_attestation_storage(&node_id.account_id, initial_storage)?; + Ok(PromiseOrValue::Value(())) } + // Dstack: async via the verifier promise chain. + Attestation::Dstack(attestation) => Ok(PromiseOrValue::Promise( + self.submit_dstack_attestation(node_id, attestation)?, + )), } } - /// `.then` bridge between the verifier's cross-contract call and the - /// yield this submission registered. Owns every outcome where the verifier - /// *answered* (`Ok(VerificationResult::{Verified,Rejected})`): on - /// `Verified` it runs the post-DCAP checks against fresh policy state and - /// inserts into `stored_attestations` on success; on `Rejected` it skips - /// straight to the refund. Either way it removes the pending entry, - /// schedules a refund where the outcome is an error, and calls - /// `promise_yield_resume(data_id, FinalOutcome)` as the LAST step of the - /// receipt — so a rejected quote is resolved *immediately*, not at the - /// timeout. Only `Err(PromiseError::Failed)` (no verdict — verifier - /// unreachable or crashed) is logged and returned early WITHOUT resuming or - /// removing the pending entry; `on_attestation_verified` owns that cleanup - /// on its `Err(PromiseError::Failed)` branch, so we must not race the - /// timeout for it. State mutations in this receipt are visible to the - /// yield-callback that fires next; if any line below `promise_yield_resume` - /// panicked or OOG'd, the entire receipt would roll back atomically (no - /// partial state commits) and the runtime's ~200-block yield-timeout would - /// still fire `on_attestation_verified` with `Err(PromiseError::Failed)` - /// for cleanup. + /// Builds the verifier promise chain. Fails the submit transaction + /// synchronously with `VerifierNotConfigured` if no verifier has been + /// voted in — there is no account to call `verify_quote` on. Otherwise it + /// calls `verify_quote` on the trusted verifier and chains + /// `resolve_verification` as its `.then` callback, forwarding the attached + /// deposit onto that callback. Quote/collateral are serialized by + /// reference so `attestation` can move into the `VerificationContext`. + fn submit_dstack_attestation( + &mut self, + node_id: NodeId, + attestation: DstackAttestation, + ) -> Result { + let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { + return Err(TeeError::VerifierNotConfigured.into()); + }; + + Ok(Promise::new(verifier_account_id) + .function_call( + "verify_quote".into(), + borsh::to_vec(&(&attestation.quote, &attestation.collateral)).unwrap(), + NearToken::from_yoctonear(0), + Gas::from_tgas(self.config.verifier_tera_gas), + ) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(Gas::from_tgas(self.config.resolve_verification_tera_gas)) + .with_attached_deposit(env::attached_deposit()) + .resolve_verification(VerificationContext { node_id, attestation }), + )) + } + + /// Verify-quote callback. `#[payable]` because the submitter's deposit + /// rides forward via `.with_attached_deposit`, so `env::attached_deposit()` + /// here is the amount they attached. `#[callback_result]` distinguishes the + /// three verifier outcomes: + /// + /// - `Ok(Verified)` → run post-DCAP checks and store. + /// - `Ok(Rejected)` → `QuoteRejected { reason }`. + /// - `Err(_)` → `VerifierUnavailable` (unreachable / panicked / OOG). /// - /// Same architectural shape as [`pending_requests::resolve_yields_for`][pending-requests-mod] - /// in the sign-request flow: the response-side function owns the state - /// mutation and the `promise_yield_resume` call; the yield-callback is - /// kept trivial. + /// On success returns `Value(())` (attestation stored, storage charged, + /// excess refunded). On any error it refunds the WHOLE attached deposit in + /// this receipt, then fires a SEPARATE `fail_attestation_submission` + /// receipt whose panic fails the caller's transaction — the split is what + /// lets the refund commit, since a panic in this receipt would roll it back + /// (and drop the created promises) along with the refund. #[private] + #[payable] pub fn resolve_verification( &mut self, - account_id: AccountId, - #[callback_result] result: Result, - ) { - let final_outcome = match result { - // No verdict: the verifier was unreachable, panicked, or ran out of - // gas. Do nothing — the runtime's yield-timeout will fire - // `on_attestation_verified` with `Err(PromiseError::Failed)` and - // clean up the pending entry there. We must not call - // `promise_yield_resume` here, or we'd race the timeout for - // ownership of the cleanup path. - Err(promise_err) => { - log!("verifier did not answer for {account_id}: {promise_err:?}"); - return; + #[serializer(borsh)] context: VerificationContext, + #[serializer(borsh)] + #[callback_result] + result: Result, + ) -> PromiseOrValue<()> { + let account_id = context.node_id.account_id.clone(); + + let attestation_result = match result { + Ok(VerificationResult::Verified(report)) => { + self.verify_post_dcap_and_store(&context, &report) } - // The verifier ran and rejected the quote. A definitive verdict: - // refund and resume now, with the reason, rather than waiting for - // the timeout. Ok(VerificationResult::Rejected(reason)) => { log!("verifier rejected quote for {account_id}: {reason}"); - FinalOutcome::Err(format!("verifier: {reason}")) + Err(TeeError::QuoteRejected { reason: reason.to_string() }.into()) } - Ok(VerificationResult::Verified(report)) => { - let pending = self.pending_attestations.get(&account_id).expect( - "PendingAttestation must exist while resolve_verification holds the yield", - ); - // Post-DCAP checks operate on the verified report plus state held - // here. The allowlist is read fresh — governance votes mid-flight - // take effect. - match finish_verify(pending, &report, self.allowlist_fresh()) { - Ok(()) => { - self.tee_state.stored_attestations.insert( - pending.tls_pk.clone(), - VerifiedAttestation::from((pending.clone(), report)), - ); - FinalOutcome::Ok - } - Err(reason) => { - log!("post-DCAP check failed for {account_id}: {reason}"); - FinalOutcome::Err(format!("post-DCAP: {reason}")) - } - } + Err(promise_err) => { + log!("verifier did not answer for {account_id}: {promise_err:?}"); + Err(TeeError::VerifierUnavailable.into()) } }; - let pending = self - .pending_attestations - .remove(&account_id) - .expect("PendingAttestation must exist while resolve_verification holds the yield"); - if matches!(final_outcome, FinalOutcome::Err(_)) { - refund_deposit(&account_id, pending.attached_deposit); + match attestation_result { + Ok(()) => PromiseOrValue::Value(()), + Err(err) => { + refund_to(&account_id, env::attached_deposit()); + let promise = Promise::new(env::current_account_id()).function_call( + "fail_attestation_submission".into(), + borsh::to_vec(&err.to_string()).unwrap(), + NearToken::from_yoctonear(0), + Gas::from_tgas(self.config.fail_attestation_submission_tera_gas), + ); + PromiseOrValue::Promise(promise.as_return()) + } } - // `promise_yield_resume` must be the LAST host call in this receipt: - // anything after it could panic and roll back the state mutations above. - env::promise_yield_resume(&pending.data_id, borsh::to_vec(&final_outcome).unwrap()); } - /// Yield-callback. Same shape as the sign-request callback - /// [`return_signature_and_clean_state_on_success`][sign-yield-callback]: the - /// `Verified` and `Rejected` outcomes (every case where the verifier - /// answered) were already finalized by `resolve_verification` (which removed - /// the pending entry and scheduled any refund before calling - /// `promise_yield_resume`), so this body just returns the outcome to the - /// caller. - /// - /// The only branch that does real work is `Err(PromiseError::Failed)`, fired - /// by the runtime ~200 blocks after submit if no `promise_yield_resume` has - /// landed: the verifier was unreachable / never responded so - /// `resolve_verification` deliberately returned early, or it ran but rolled - /// back (OOM / panic). On that branch the pending entry is still present, so - /// it removes the entry and schedules a deposit refund. - #[private] - pub fn on_attestation_verified( + /// Runs the post-DCAP checks and stores the attestation for a `Verified` + /// response. The callback receipt commits regardless of the `Err` returned, + /// so a failed storage charge cannot rely on implicit rollback: it reverts + /// the store explicitly, or the caller would get storage for free plus a + /// full refund. + fn verify_post_dcap_and_store( &mut self, - account_id: AccountId, - #[callback_result] result: Result, - ) -> Result<(), String> { - match result { - Ok(FinalOutcome::Ok) => Ok(()), - Ok(FinalOutcome::Err(reason)) => Err(reason), - Err(_promise_err) => { - if let Some(pending) = self.pending_attestations.remove(&account_id) { - refund_deposit(&account_id, pending.attached_deposit); - log!("yield timeout for {account_id}: refunded and cleaned up"); - } - Err("verifier did not respond within yield-resume window".to_string()) + context: &VerificationContext, + report: &VerifiedReport, + ) -> Result<(), Error> { + let account_id = &context.node_id.account_id; + let initial_storage = env::storage_usage(); + let insertion = self.tee_state.verify_and_store_dstack( + context.node_id.clone(), + &context.attestation, + report, + /* tee_upgrade_deadline_duration */ + )?; + + match self.charge_attestation_storage(account_id, initial_storage) { + Ok(()) => Ok(()), + Err(err) => { + self.tee_state + .revert_dstack_store(&context.node_id.tls_public_key, insertion); + Err(err) } } } -} -#[derive(BorshSerialize, BorshDeserialize)] -pub enum FinalOutcome { - Ok, - Err(String), + /// Separate receipt whose panic fails the caller's transaction after the + /// refund in `resolve_verification` has committed. + #[private] + pub fn fail_attestation_submission(#[serializer(borsh)] reason: String) { + log!("fail_attestation_submission: {reason}"); + env::panic_str(&reason); + } } ``` -`VERIFIER_GAS_TGAS`, `RESOLVE_GAS_TGAS`, and `YIELD_CALLBACK_GAS_TGAS` are placeholders until benchmarked. The verifier-side cost is dominated by ECDSA verifications and X.509-chain walking inside `dcap_qvl::verify::verify`. The bulk of the contract-side post-DCAP work — RTMR3 replay, app-compose validation, allowlist matching, report-data binding, plus the `stored_attestations.insert` — runs inside `resolve_verification`, so `RESOLVE_GAS_TGAS` gets the largest budget. `YIELD_CALLBACK_GAS_TGAS` can be conservatively small (on the order of 10 TGas with comfortable headroom): the yield-callback only does a `LookupMap::remove` and schedules a `Promise` on the timeout branch, and just returns a value on the resume branch. +`charge_attestation_storage` reads `env::attached_deposit()` itself: if the attached amount is less than the measured storage cost it returns `InsufficientDeposit`; otherwise it refunds the excess to the account via `refund_to`. `refund_to` is the generic refund helper (a detached `transfer` promise, no-op on zero). -The contract gains the following state fields: +`verifier_tera_gas`, `resolve_verification_tera_gas`, and `fail_attestation_submission_tera_gas` are unbenchmarked estimates until measured. The verifier-side cost (`verifier_tera_gas`) is dominated by ECDSA verifications and X.509-chain walking inside `dcap_qvl::verify::verify`, so it gets the largest budget. `resolve_verification_tera_gas` covers the contract-side post-DCAP work — RTMR3 replay, app-compose validation, allowlist matching, report-data binding — plus the `verify_and_store_dstack` insert and the storage charge. `fail_attestation_submission_tera_gas` can be tiny (a couple of TGas): the method only logs and panics. -```rust -pub struct MpcContract { - // ... existing fields, including tee_verifier_account_id and - // tee_verifier_votes from §Voting on the trusted verifier ... - pending_attestations: LookupMap, -} +### Contract state changes summary -pub struct PendingAttestation { - pub dstack: DstackAttestation, - pub tls_pk: Ed25519PublicKey, - pub attached_deposit: NearToken, - pub data_id: CryptoHash, -} -``` +No new attestation state fields. The chain carries a `VerificationContext { node_id, attestation }` as a borsh callback argument; nothing new is written to storage. The only new fields on the contract are `tee_verifier_account_id` and `tee_verifier_votes` from [§Voting on the trusted verifier](#voting-on-the-trusted-verifier-in-mpc-contract). ## Testing -The yield-resume split adds four resolution branches the synchronous version never had. Three do their work in `resolve_verification`, each from a distinct verifier answer: `Verified` + post-DCAP pass (store + resume `Ok`), `Verified` + post-DCAP fail (refund + resume `Err`), and `Rejected` (refund + resume `Err`, immediately — the path that recovers the synchronous-rejection behavior the split would otherwise lose). The fourth lives in `on_attestation_verified`, on its `Err(PromiseError::Failed)` branch, reached when the verifier gave no verdict — unreachable, panicked, or no resume landed within ~200 blocks (verifier silent, or a `resolve_verification` receipt that rolled back). That no-verdict case re-enters `resolve_verification`, which logs and returns early without resuming, so its cleanup happens in `on_attestation_verified`. Each branch needs test coverage, and exercising them requires the verifier to return specific answers on demand — a `Verified` or `Rejected` value for the three `resolve_verification` branches, and for the no-verdict path either an unreachable account or the test driver advancing the chain past the yield-resume window without resuming. +The no-yield chain adds a handful of resolution branches the synchronous version never had, all inside `resolve_verification` and the helper it delegates to: + +- **Verifier not configured** — `Dstack` submit while `tee_verifier_account_id` is `None` fails *synchronously* with `VerifierNotConfigured`; the submit transaction itself errors, no promise is scheduled. +- **Verified + store happy path** — `verify_quote` returns `Verified`, post-DCAP passes, storage charged, excess refunded, `Value(())`. The attestation is present in state afterward. +- **Verified + post-DCAP fail** — `verify_and_store_dstack` errors; `resolve_verification` refunds the whole deposit and fires the `fail_attestation_submission` receipt; nothing is stored. +- **Verified + insufficient deposit** — post-DCAP passes but `charge_attestation_storage` returns `InsufficientDeposit`; `verify_post_dcap_and_store` reverts the store explicitly, so state is unchanged; refund + fail receipt. +- **Rejected → fail + refund** — `verify_quote` returns `Rejected`; `resolve_verification` returns `QuoteRejected` carrying the reason; refund + fail receipt. +- **Verifier unreachable → `VerifierUnavailable`** — the callback observes `Err(PromiseError::Failed)`; refund + fail receipt. +- **OOG in `resolve_verification` rolls back atomically** — an out-of-gas or panic mid-callback rolls back the whole receipt (no partial store, no partial refund) and fails the caller's transaction; because nothing was inserted at submit time, there is no orphaned state to reclaim. The verifier-rotation design changes the test surface in three ways. First, the expiration window itself: an entry whose `expiry_timestamp_seconds` is in the past must be rejected by `re_verify` even when every post-DCAP allowlist invariant still holds, and an entry within the (shortened) window must still pass — this is the existing expiry check, now exercised against the lowered `DEFAULT_EXPIRATION_DURATION_SECONDS`. Second, rotation routing: after `vote_tee_verifier_change` crosses threshold, the next `submit_participant_info` must call `verify_quote` on the new `tee_verifier_account_id`, and existing stored entries must remain present (no purge) until they expire. Third, the in-flight case: a verification scheduled against the old verifier that resolves after the vote crosses threshold must still be stored as a normal entry — it is not treated specially and ages out via the same expiration window as any other entry. -To make that practical, we introduce a stub `tee-verifier` crate: same `tee-verifier-interface` DTOs as the real verifier, but `verify_quote` returns whatever `VerificationResult` (`Verified` or `Rejected`) the test asks for — and a stub that panics, or an undeployed account, covers the no-verdict path. Sandbox tests deploy the stub like any other verifier candidate — lock its account, then call `vote_tee_verifier_change` from the test setup to point `mpc-contract` at the stub. This runs the same code path as production; nothing in `mpc-contract` knows or cares whether it's talking to the real verifier or the stub. +To make that practical, we introduce a stub `tee-verifier` crate: same `tee-verifier-interface` DTOs as the real verifier, but `verify_quote` returns whatever `VerificationResult` (`Verified` or `Rejected`) the test asks for — and a stub that panics, or an undeployed account, covers the `VerifierUnavailable` path. Sandbox tests deploy the stub like any other verifier candidate — lock its account, then call `vote_tee_verifier_change` from the test setup to point `mpc-contract` at the stub. This runs the same code path as production; nothing in `mpc-contract` knows or cares whether it's talking to the real verifier or the stub. E2E tests in `crates/e2e-tests` deploy either the real `tee-verifier` (when the test wants real `dcap-qvl` against a fixture quote) or the stub (for everything else). The change is one extra `deploy` call in the setup helper. @@ -618,17 +569,9 @@ E2E tests in `crates/e2e-tests` deploy either the real `tee-verifier` (when the [nep-509]: https://github.com/near/NEPs/blob/master/neps/nep-0509.md [re-verify]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/mpc-attestation/src/attestation.rs#L93 [periodic-attestation-submission]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/tee/remote_attestation.rs#L140 -[attestation-resubmission-interval]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/run.rs#L43 -[attestation-attempts-metric]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/metrics.rs#L364 [verify-tee]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1543 [clean-invalid-attestations]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1646 -[clean-foreign-chain-data]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1669 [monitor-attestation-removal]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/tee/remote_attestation.rs#L209 [submit-participant-info]: https://github.com/near/mpc/blob/efe49230bb66854c55bba080e7610e42f9221506/crates/contract/src/lib.rs#L754-L782 [launcher-pattern]: https://github.com/near/mpc/blob/efe49230bb66854c55bba080e7610e42f9221506/docs/tee-lifecycle.md#upgrade [slack-launcher-discussion]: https://nearone.slack.com/archives/C0B12RKBSAV/p1777897902903889 -[promise-yield-create]: https://docs.rs/near-sdk/5.26.1/near_sdk/env/fn.promise_yield_create.html -[promise-yield-resume]: https://docs.rs/near-sdk/5.26.1/near_sdk/env/fn.promise_yield_resume.html -[enqueue-yield-request]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L301-L323 -[pending-requests-mod]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/pending_requests.rs -[sign-yield-callback]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1999-L2023 diff --git a/docs/localnet/tee-localnet.md b/docs/localnet/tee-localnet.md index 054a39042f..07c32cba98 100644 --- a/docs/localnet/tee-localnet.md +++ b/docs/localnet/tee-localnet.md @@ -276,7 +276,7 @@ near transaction view-status network-config mpc-localnet ``` ``` -(ExecutionError("Smart contract panicked: Invalid TEE Remote Attestation.: TeeQuoteStatus is invalid: the allowed mpc image hashes list is empty" +(ExecutionError("Smart contract panicked: the submitted attestation failed verification, reason: Custom(\"the allowed mpc image hashes list is empty\")" ``` ### Vote Commands diff --git a/docs/running-an-mpc-node-in-tdx-external-guide.md b/docs/running-an-mpc-node-in-tdx-external-guide.md index 3c455fc1f5..60c969e0ec 100644 --- a/docs/running-an-mpc-node-in-tdx-external-guide.md +++ b/docs/running-an-mpc-node-in-tdx-external-guide.md @@ -2062,8 +2062,7 @@ The error after `err=` is the NEAR runtime error. Common ones: If the transaction reaches execution and the contract panics, the node logs only the generic retry line above; the actual message lives in the transaction receipt. Find the tx on `https://testnet.nearblocks.io/address/` and open the failed `submit_participant_info` call — the error appears under the action's status / logs. The contract wraps the attestation-side error like this: ``` -Invalid TEE Remote Attestation: TeeQuoteStatus is invalid: - the submitted attestation failed verification, reason: Custom("...") +the submitted attestation failed verification, reason: Custom("...") ``` The `reason` is the same `VerificationError` the client-side WARN reports (see section 1) — for example `Custom("the allowed mpc image hashes list is empty")`. Errors that **only** surface on-chain (because they're checked against the contract's allowed-measurements list, the contract's deposit logic, or the contract's caller assertion): From b444cf6693b31ff0eda2edd4a855a358291a0085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 15 Jul 2026 16:52:45 +0200 Subject: [PATCH 44/44] docs: balance the ExecutionError parens in the tee-localnet example The localnet troubleshooting snippet dropped the closing `))`, so the pasted `ExecutionError(...)` example read as unbalanced. --- docs/localnet/tee-localnet.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/localnet/tee-localnet.md b/docs/localnet/tee-localnet.md index 07c32cba98..6d6b64a4a5 100644 --- a/docs/localnet/tee-localnet.md +++ b/docs/localnet/tee-localnet.md @@ -276,7 +276,7 @@ near transaction view-status network-config mpc-localnet ``` ``` -(ExecutionError("Smart contract panicked: the submitted attestation failed verification, reason: Custom(\"the allowed mpc image hashes list is empty\")" +(ExecutionError("Smart contract panicked: the submitted attestation failed verification, reason: Custom(\"the allowed mpc image hashes list is empty\")")) ``` ### Vote Commands