From face309ae801e349d323c346ae7d6e9095a94302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 24 Jun 2026 12:03:37 +0200 Subject: [PATCH 01/26] feat(contract): async TEE attestation verification via verifier contract `submit_participant_info` now offloads DCAP verification for `Dstack` attestations to the standalone `tee-verifier` contract over a cross-contract call, resolved through the yield-resume pattern; `Mock` attestations stay synchronous. This drops `dcap-qvl` from the `mpc-contract` WASM. - New `pending_attestations` state (one in-flight verification per account) and `FinalOutcome` (byte-pinned borsh layout, rides the yield-resume payload). - `submit_dstack_attestation` yields and calls `verify_quote`; `resolve_verification` bridges the verifier response, runs the post-DCAP checks via `verify_with_report`, charges storage, and resumes. `on_attestation_verified` handles the yield timeout, cleaning up and refunding from a separate `fail_on_attestation_timeout` receipt. - A failed storage charge in the committing callback receipt is reverted explicitly. - `tee_verifier_account_id` stays `Option`; `None` rejects `Dstack` submits with `VerifierNotConfigured` (non-Option migration tracked in #3639). - Drops `local-verify`/`dcap-qvl` from `mpc-contract`; contract dispatches via the post-DCAP-only `verify_with_report`. - New `test-tee-verifier` stub + sandbox tests for the rejection, crash/timeout, and not-configured branches. Closes #3642 --- Cargo.lock | 12 + Cargo.toml | 2 + crates/contract/Cargo.toml | 5 +- crates/contract/src/config.rs | 17 + crates/contract/src/dto_mapping.rs | 15 + crates/contract/src/errors.rs | 8 + crates/contract/src/lib.rs | 627 +++++++++-------- crates/contract/src/sandbox_test_methods.rs | 9 +- ...contract_borsh_schema_has_not_changed.snap | 16 + crates/contract/src/storage_keys.rs | 1 + crates/contract/src/tee.rs | 1 + .../contract/src/tee/pending_attestation.rs | 90 +++ crates/contract/src/tee/tee_state.rs | 133 +++- crates/contract/src/v3_12_0_state.rs | 2 + .../tests/inprocess/attestation_submission.rs | 14 +- .../tests/sandbox/contract_configuration.rs | 3 + crates/contract/tests/sandbox/mod.rs | 1 + crates/contract/tests/sandbox/tee_verifier.rs | 233 +++++++ .../sandbox/upgrade_from_current_contract.rs | 3 + .../tests/sandbox/utils/contract_build.rs | 9 + .../tests/sandbox/utils/mpc_contract.rs | 54 ++ .../snapshots/abi__abi_has_not_changed.snap | 658 +++++++++++++++++- crates/mpc-attestation/src/attestation.rs | 27 +- .../src/method_names.rs | 6 + .../src/types/config.rs | 18 + crates/test-tee-verifier/Cargo.toml | 31 + crates/test-tee-verifier/src/lib.rs | 82 +++ crates/test-utils/src/contract_types.rs | 3 + docs/design/attestation-verifier-contract.md | 14 + 29 files changed, 1763 insertions(+), 331 deletions(-) create mode 100644 crates/contract/src/tee/pending_attestation.rs 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 7349afc129..094c3e52bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5755,6 +5755,7 @@ version = "3.12.0" dependencies = [ "anyhow", "assert_matches", + "attestation", "blstrs", "borsh", "cargo-near-build", @@ -5789,6 +5790,7 @@ dependencies = [ "serde_with", "sha2 0.10.9", "signature", + "tee-verifier-interface", "test-utils", "thiserror 2.0.18", "threshold-signatures", @@ -11218,6 +11220,16 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "test-tee-verifier" +version = "3.12.0" +dependencies = [ + "borsh", + "getrandom 0.2.17", + "near-sdk", + "tee-verifier-interface", +] + [[package]] name = "test-utils" version = "3.12.0" diff --git a/Cargo.toml b/Cargo.toml index 9103a0ba1a..2d94b6883e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,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", @@ -71,6 +72,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 = { path = "crates/test-tee-verifier" } 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 8a2f7c4eb2..e5753df0ef 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. @@ -87,7 +88,8 @@ k256 = { workspace = true, features = [ "arithmetic", "expose-field", ] } -mpc-attestation = { workspace = true, features = ["local-verify"] } +attestation = { workspace = true } +mpc-attestation = { workspace = true } mpc-primitives = { workspace = true } near-account-id = { workspace = true, features = ["serde"] } near-mpc-bounded-collections = { workspace = true } @@ -98,6 +100,7 @@ near-mpc-contract-interface = { workspace = true, features = [ ] } near-mpc-signature-verifier = { workspace = true } near-sdk = { workspace = true } +tee-verifier-interface = { workspace = true } rand = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/contract/src/config.rs b/crates/contract/src/config.rs index 9acf2a28ca..cdb03e9f3a 100644 --- a/crates/contract/src/config.rs +++ b/crates/contract/src/config.rs @@ -34,6 +34,14 @@ 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. Only a trivial +/// map of the resumed outcome back to the caller, so it needs little. +const DEFAULT_ON_ATTESTATION_VERIFIED_TERA_GAS: u64 = 10; /// Config for V2 of the contract. #[near(serializers=[borsh, json])] @@ -68,6 +76,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 { @@ -94,6 +108,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..ab76239574 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -490,6 +490,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 } @@ -519,6 +528,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, } } } @@ -547,6 +559,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..32b89c27b7 100644 --- a/crates/contract/src/errors.rs +++ b/crates/contract/src/errors.rs @@ -28,6 +28,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)] diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index dfee3449c5..3514537540 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::{FinalOutcome, 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,6 +88,7 @@ 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::{ @@ -140,6 +143,25 @@ fn require_deposit(minimum_deposit: NearToken, predecessor: &AccountId) { } } +/// Refunds an attestation submitter's attached deposit (no-op for a zero +/// deposit). Used when a `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(); + } +} + +fn map_attestation_submission_error(err: AttestationSubmissionError) -> Error { + let reason = match &err { + AttestationSubmissionError::InvalidAttestation(_) => { + format!("TeeQuoteStatus is invalid: {err}") + } + AttestationSubmissionError::TlsKeyOwnedByOtherAccount => err.to_string(), + }; + InvalidParameters::InvalidTeeRemoteAttestation { reason }.into() +} + impl Default for MpcContract { fn default() -> Self { env::panic_str("Calling default not allowed."); @@ -165,11 +187,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. A `Dstack` `submit_participant_info` + /// 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 `Dstack` verifications, one entry per submitter account, held + /// between the cross-contract `verify_quote` call and its resolution (or the + /// yield timeout). See [`tee::pending_attestation`]. + pending_attestations: LookupMap, } #[near(serializers=[borsh])] @@ -761,7 +789,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()?; @@ -775,10 +803,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); @@ -792,62 +816,141 @@ 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, - ) - .map_err(|err| { - let reason = match &err { - AttestationSubmissionError::InvalidAttestation(_) => { - format!("TeeQuoteStatus is invalid: {err}") - } - AttestationSubmissionError::TlsKeyOwnedByOtherAccount => err.to_string(), - }; - InvalidParameters::InvalidTeeRemoteAttestation { reason } - })?; - + let node_id = NodeId { + account_id: account_id.clone(), + tls_public_key, + account_public_key, + }; + // Frozen at submit time and consumed later in the resolution callback. + // The callback receipt's predecessor is the contract itself, so participant + // status cannot be re-derived there — capture it now. A resharing that + // drops this submitter mid-flight therefore won't reclassify the storage + // charge, which is acceptable (the alternative is unavailable). let caller_is_not_participant = self.voter_account().is_err(); - let is_new_attestation = matches!( - attestation_insertion_result, - ParticipantInsertion::NewlyInsertedParticipant - ); - - let attestation_storage_must_be_paid_by_caller = - is_new_attestation || caller_is_not_participant; - - if attestation_storage_must_be_paid_by_caller { - // `saturating_sub`: if a re-submission shrinks the entry, charge nothing - // rather than underflow. Intentional asymmetry: we do not refund freed bytes - // either — the caller already paid for the larger entry, and we'd rather - // accept that asymmetry than open a refund path for payload-shrinking games. - let storage_used = env::storage_usage().saturating_sub(initial_storage); - let cost = env::storage_byte_cost().saturating_mul(storage_used as u128); - let attached = env::attached_deposit(); - - if attached < cost { - return Err(InvalidParameters::InsufficientDeposit { - attached: attached.as_yoctonear(), - required: cost.as_yoctonear(), - } - .into()); + + match proposed_participant_attestation { + Attestation::Mock(mock) => { + // Synchronous path: no DCAP, store immediately. + let initial_storage = env::storage_usage(); + let insertion = self + .tee_state + .add_mock_participant(node_id, mock, tee_upgrade_deadline_duration) + .map_err(map_attestation_submission_error)?; + self.charge_attestation_storage( + &account_id, + initial_storage, + insertion, + caller_is_not_participant, + env::attached_deposit(), + )?; + Ok(PromiseOrValue::Value(())) } + Attestation::Dstack(dstack) => { + Ok(self.submit_dstack_attestation(node_id, dstack, caller_is_not_participant)?) + } + } + } - // 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(); + /// Async `Dstack` submission: register a yield, fire a cross-contract + /// `verify_quote` at the trusted verifier, and bridge its response back into + /// a `promise_yield_resume` via the `resolve_verification` callback. + fn submit_dstack_attestation( + &mut self, + node_id: NodeId, + dstack: DstackAttestation, + caller_is_not_participant: bool, + ) -> Result, Error> { + let account_id = node_id.account_id.clone(); + + // One in-flight verification per account: a duplicate submit before the + // previous one finishes (verifier response or yield timeout) is rejected. + if self.pending_attestations.contains_key(&account_id) { + return Err(TeeError::VerificationAlreadyPending.into()); + } + + // Refuse to submit until a verifier has been 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) = (dstack.quote.clone(), dstack.collateral.clone()); + let attached_deposit = env::attached_deposit(); + let tls_public_key = node_id.tls_public_key.clone(); + + self.enqueue_yield_request( + method_names::ON_ATTESTATION_VERIFIED, + borsh::to_vec(&account_id).expect("borsh 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_not_participant, + data_id, + }, + ); + }, + ); + + // Cross-contract call to the verifier; its `.then` bridges the response + // into a `promise_yield_resume` on the yield registered above. + Promise::new(verifier_account_id) + .function_call( + method_names::VERIFY_QUOTE.to_string(), + borsh::to_vec(&(quote, collateral)) + .expect("borsh serialization of verify_quote args must succeed"), + 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)) + .resolve_verification(node_id), + ) + .detach(); + + // The yield handle is already the return value (`enqueue_yield_request` + // called `promise_return`); nothing further to return. + Ok(PromiseOrValue::Value(())) + } + + fn charge_attestation_storage( + &self, + account_id: &AccountId, + initial_storage: u64, + insertion: ParticipantInsertion, + caller_is_not_participant: bool, + attached: NearToken, + ) -> Result<(), Error> { + let is_new_attestation = + matches!(insertion, ParticipantInsertion::NewlyInsertedParticipant); + if !(is_new_attestation || caller_is_not_participant) { + 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 — 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); + + 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 +2072,7 @@ impl MpcContract { ), tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), + pending_attestations: LookupMap::new(StorageKey::PendingAttestationsV1), }) } @@ -2048,6 +2152,7 @@ impl MpcContract { ), tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), + pending_attestations: LookupMap::new(StorageKey::PendingAttestationsV1), }) } @@ -2271,6 +2376,159 @@ impl MpcContract { } } + /// `.then` bridge for the cross-contract `verify_quote` call. Maps the + /// verifier's response to a [`FinalOutcome`] and resumes the yield registered + /// in [`Self::submit_dstack_attestation`]. + #[private] + pub fn resolve_verification( + &mut self, + node_id: NodeId, + #[serializer(borsh)] + #[callback_result] + result: Result, + ) { + let account_id = node_id.account_id.clone(); + + // A late verifier response can arrive after the ~200-block yield timeout + // has already fired and `on_attestation_verified` cleaned up the pending + // entry. The yield is then already resolved, so there is nothing to do: + // log and return rather than panic on a missing entry or resume twice. + if !self.pending_attestations.contains_key(&account_id) { + log!( + "resolve_verification: no pending attestation for {account_id} (late response or already cleaned up); ignoring" + ); + return; + } + + let final_outcome = match result { + Err(promise_err) => { + // No verdict; let the yield timeout clean up. Do NOT resume, or + // we'd race the timeout for ownership of the cleanup path. + log!("verifier did not answer for {account_id}: {promise_err:?}"); + return; + } + Ok(VerificationResult::Rejected(reason)) => { + log!("verifier rejected quote for {account_id}: {reason}"); + FinalOutcome::Err(format!("verifier rejected quote: {reason}")) + } + Ok(VerificationResult::Verified(report)) => { + self.finish_verified_attestation(&node_id, &report) + } + }; + + let pending = self.pending_attestations.remove(&account_id).expect( + "checked contains_key above; no host call between mutates pending_attestations", + ); + if matches!(final_outcome, FinalOutcome::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, + borsh::to_vec(&final_outcome) + .expect("borsh serialization of FinalOutcome must succeed"), + ); + } + + /// Runs the post-DCAP checks and stores the attestation for a verifier + /// `Verified` response. Returns the outcome to resume the yield with; on a + /// post-DCAP or storage failure, reverts the store (the callback receipt + /// commits regardless, unlike the synchronous path). + fn finish_verified_attestation( + &mut self, + node_id: &NodeId, + report: &VerifiedReport, + ) -> FinalOutcome { + let account_id = node_id.account_id.clone(); + let pending = self + .pending_attestations + .get(&account_id) + .expect("resolve_verification confirmed the pending entry before calling us"); + let dstack = pending.dstack.clone(); + let caller_is_not_participant = pending.caller_is_not_participant; + let attached_deposit = pending.attached_deposit; + let tls_public_key = node_id.tls_public_key.clone(); + let tee_upgrade_deadline_duration = + Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); + + let initial_storage = env::storage_usage(); + let (insertion, previous) = match self.tee_state.finish_dstack_verify( + node_id.clone(), + dstack, + report, + tee_upgrade_deadline_duration, + ) { + Ok(result) => result, + Err(err) => { + log!("post-DCAP check failed for {account_id}: {err}"); + return FinalOutcome::Err(format!("post-DCAP check failed: {err}")); + } + }; + + match self.charge_attestation_storage( + &account_id, + initial_storage, + insertion, + caller_is_not_participant, + attached_deposit, + ) { + Ok(()) => FinalOutcome::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(&tls_public_key, previous); + FinalOutcome::Err(err.to_string()) + } + } + } + + /// Yield-resume callback for a `Dstack` submission. On success resolves the + /// caller's transaction; on a verifier rejection or the ~200-block timeout + /// it cleans up, refunds, and fails the transaction from a separate receipt. + #[private] + pub fn on_attestation_verified( + &mut self, + #[serializer(borsh)] account_id: AccountId, + #[serializer(borsh)] + #[callback_result] + result: Result, + ) -> PromiseOrValue<()> { + let reason = match result { + Ok(FinalOutcome::Ok) => return PromiseOrValue::Value(()), + Ok(FinalOutcome::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_ON_ATTESTATION_TIMEOUT.to_string(), + borsh::to_vec(&reason).expect("borsh serialization of reason must succeed"), + NearToken::from_near(0), + Gas::from_tgas(self.config.fail_on_timeout_tera_gas), + ); + PromiseOrValue::Promise(promise.as_return()) + } + + /// Fails the original `submit_participant_info` transaction with `reason`, + /// from a receipt separate from the cleanup so the cleanup is not rolled back. + #[private] + pub fn fail_on_attestation_timeout(#[serializer(borsh)] reason: String) { + env::panic_str(&reason); + } + /// Yield-resume callback for a single queued CKD request. /// /// On success, returns the confidential key to the original caller. On timeout, @@ -2703,7 +2961,6 @@ mod tests { use mpc_attestation::attestation::{ Attestation as MpcAttestation, MockAttestation as MpcMockAttestation, VerifiedAttestation, }; - use mpc_primitives::hash::DockerImageHash; use near_mpc_bounded_collections::{NonEmptyBTreeMap, NonEmptyBTreeSet}; use near_mpc_contract_interface::types::BackupServiceInfo; use near_mpc_contract_interface::types::CKDAppPublicKey; @@ -2721,10 +2978,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 _; @@ -3997,7 +4250,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( @@ -4414,7 +4669,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"); } @@ -4441,7 +4696,7 @@ mod tests { .build(); testing_env!(ctx); - contract + let _ = contract .submit_participant_info(valid_attestation, dto_public_key) .expect("Outsider attestation submission should succeed"); @@ -4503,7 +4758,7 @@ mod tests { .build() ); - contract + let _ = contract .submit_participant_info(Attestation::Mock(MockAttestation::Valid), dto_public_key) .unwrap(); @@ -4585,6 +4840,7 @@ mod tests { ), tee_verifier_account_id: None, tee_verifier_votes: Default::default(), + pending_attestations: LookupMap::new(StorageKey::PendingAttestationsV1), } } } @@ -5847,247 +6103,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]) } diff --git a/crates/contract/src/sandbox_test_methods.rs b/crates/contract/src/sandbox_test_methods.rs index 28997cd7af..76e7616187 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,11 @@ impl MpcContract { u32::try_from(len) .expect("queue length must fit in u32 — bounded by MAX_PENDING_REQUEST_FAN_OUT") } + + /// Whether an in-flight `Dstack` attestation verification is pending for + /// `account_id`. Lets the async attestation sandbox tests assert that the + /// pending entry was cleaned up after a rejection or yield timeout. + pub fn has_pending_attestation(&self, account_id: AccountId) -> bool { + self.pending_attestations.contains_key(&account_id) + } } 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..888cb27fb8 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 @@ -258,6 +258,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 +723,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..07ff74a065 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, + PendingAttestationsV1, } 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..aee90db9f7 --- /dev/null +++ b/crates/contract/src/tee/pending_attestation.rs @@ -0,0 +1,90 @@ +//! State for an in-flight `Dstack` attestation submission. +//! +//! `submit_participant_info` for a `Dstack` attestation is asynchronous: it +//! yields, fires a cross-contract `verify_quote` call to the trusted verifier, +//! and resumes from the response callback. Everything the callback needs that +//! is not re-readable from contract state at callback time is stashed here, +//! keyed by the submitter's `AccountId`, until the verification resolves (or +//! the yield times out). + +use borsh::{BorshDeserialize, BorshSerialize}; +use mpc_attestation::attestation::DstackAttestation; +use near_mpc_contract_interface::types::Ed25519PublicKey; +use near_sdk::{CryptoHash, NearToken}; + +/// One in-flight verification per submitter account. +// +// Plain borsh (not `#[near(serializers=[borsh])]`): this is internal contract +// state that never appears in a public method signature, so it does not need a +// `BorshSchema` for ABI generation — and avoiding it keeps `DstackAttestation` +// out of the ABI schema requirement. +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct PendingAttestation { + /// The submitted `Dstack` payload — RTMR3 event log, app-compose, and the + /// quote/collateral — that the post-DCAP checks consume once the verifier + /// returns its report. + pub dstack: DstackAttestation, + /// The submitter's TLS public key, hashed with its account public key and + /// compared against the quote's report-data during the post-DCAP checks. + pub tls_public_key: Ed25519PublicKey, + /// Deposit attached at submit time. `env::attached_deposit()` is not visible + /// from the callback receipt, so it is stashed here: consumed for storage + /// staking on success, refunded on failure. + pub attached_deposit: NearToken, + /// Whether the submitter was a non-participant at submit time. Together with + /// "is this a new attestation", this decides whether the caller pays for + /// storage (preserving the synchronous contract's charging rule). Captured + /// at submit time because participant status is re-derived from the caller, + /// which the callback receipt no longer is. + pub caller_is_not_participant: bool, + /// Yield handle from `env::promise_yield_create`. The resolution callback + /// reads it back to `promise_yield_resume` with the final outcome. + pub data_id: CryptoHash, +} + +/// Outcome the resolution callback resumes the yielded promise with. The +/// yield-callback maps it back to a `Result` for the original caller. +// +// `FinalOutcome` reaches ABI generation as the `#[callback_result]` argument +// type of `on_attestation_verified`, so it needs a `BorshSchema` under `abi` +// (unlike `PendingAttestation`, which is pure state). Both its variants are +// schema-able (`()` and `String`). +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[cfg_attr( + all(feature = "abi", not(target_arch = "wasm32")), + derive(borsh::BorshSchema) +)] +pub enum FinalOutcome { + Ok, + Err(String), +} + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::*; + + #[test] + fn final_outcome__should_round_trip_borsh() { + for original in [FinalOutcome::Ok, FinalOutcome::Err("rejected".to_string())] { + let bytes = borsh::to_vec(&original).expect("serialize"); + let decoded: FinalOutcome = borsh::from_slice(&bytes).expect("deserialize"); + assert_eq!(original, decoded); + } + } + + /// Pin the exact wire bytes. `FinalOutcome` is serialized into a live + /// `promise_yield_resume` payload, so a variant reorder would flip the tag + /// and silently break callback receipts in flight across an upgrade — a + /// regression the round-trip test above cannot catch. The tag is the + /// variant index (`Ok` = 0, `Err` = 1); `String` is borsh-encoded as a + /// little-endian `u32` length followed by the UTF-8 bytes. + #[test] + fn final_outcome__should_have_pinned_borsh_layout() { + assert_eq!(borsh::to_vec(&FinalOutcome::Ok).unwrap(), vec![0]); + assert_eq!( + borsh::to_vec(&FinalOutcome::Err("x".to_string())).unwrap(), + vec![1, 1, 0, 0, 0, b'x'], + ); + } +} diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 6d74a8f0e7..26ad76217d 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, Attestation, DstackAttestation, 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; @@ -147,27 +151,81 @@ impl TeeState { current_time_milliseconds / 1_000 } - /// Adds a participant attestation for the given node iff the attestation succeeds verification. + /// Test-only dispatcher kept for the many `Mock`-based unit tests. The + /// production path no longer calls this: `Mock` submissions go through + /// [`Self::add_mock_participant`] and `Dstack` submissions through the async + /// verifier flow ([`Self::finish_dstack_verify`]). + #[cfg(test)] pub(crate) fn add_participant( &mut self, node_id: NodeId, attestation: Attestation, 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(); + match attestation { + Attestation::Mock(mock) => { + self.add_mock_participant(node_id, mock, tee_upgrade_deadline_duration) + } + Attestation::Dstack(_) => { + panic!("add_participant test helper does not support Dstack attestations") + } + } + } + + /// Verifies and stores a `Mock` attestation synchronously. + /// + /// Mock attestations have no real quote, so there is no DCAP step and no + /// verifier round-trip — verification is local and immediate, unlike the + /// `Dstack` path which goes through [`Self::finish_dstack_verify`] after the + /// verifier contract responds. + pub(crate) fn add_mock_participant( + &mut self, + node_id: NodeId, + mock: MockAttestation, + tee_upgrade_deadline_duration: Duration, + ) -> Result { + let accepted_measurements = self.get_accepted_measurements(); + // Pure, always-compiled mock verification: no DCAP, so the contract does + // not link `dcap-qvl`. + let AcceptedAttestation { + attestation: verified_attestation, + advisory_ids, + } = Attestation::Mock(mock).verify_mock_only( + Self::current_time_seconds(), + &self.get_allowed_mpc_docker_image_hashes(tee_upgrade_deadline_duration), + &self.get_allowed_launcher_compose_hashes(), + &accepted_measurements, + )?; + log_informational_advisory_ids(&advisory_ids); + // Synchronous path: the deposit is charged in the same receipt as the + // store, so a charge failure rolls the store back automatically — no + // need for the displaced previous entry the async path captures. + let (insertion, _previous) = + self.store_verified_attestation(node_id, verified_attestation)?; + Ok(insertion) + } + + /// Runs the post-DCAP checks for a `Dstack` attestation against the + /// `VerifiedReport` the verifier contract returned, then stores the result. + /// Called from `resolve_verification` once the cross-contract `verify_quote` + /// succeeds; the DCAP cryptographic verification has already happened in the + /// verifier contract. + pub(crate) fn finish_dstack_verify( + &mut self, + node_id: NodeId, + dstack: DstackAttestation, + report: &VerifiedReport, + tee_upgrade_deadline_duration: Duration, + ) -> Result<(ParticipantInsertion, Option), AttestationSubmissionError> { + 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(), + } = Attestation::Dstack(dstack).verify_with_report( + 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 +233,31 @@ 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, enforcing TLS-key ownership. + /// + /// Returns the insertion result and, when an existing entry was displaced, + /// the previous [`NodeAttestation`]. The caller (the async `Dstack` flow) + /// uses that previous entry to [`Self::revert_dstack_store`] if the storage + /// charge fails — the synchronous path relied on the receipt rolling back, + /// but the callback receipt commits regardless, so the rollback must be + /// explicit. + fn store_verified_attestation( + &mut self, + node_id: NodeId, + verified_attestation: VerifiedAttestation, + ) -> Result<(ParticipantInsertion, Option), AttestationSubmissionError> { let tls_pk = node_id.tls_public_key.clone(); // Authorization: a TLS key registered to one account must not be @@ -188,7 +270,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,10 +278,31 @@ impl TeeState { }, ); - Ok(match insertion { - Some(_previous_attestation) => ParticipantInsertion::UpdatedExistingParticipant, + let insertion = match previous { + Some(_) => ParticipantInsertion::UpdatedExistingParticipant, None => ParticipantInsertion::NewlyInsertedParticipant, - }) + }; + Ok((insertion, previous)) + } + + /// Undoes a [`Self::finish_dstack_verify`] store: restores the displaced + /// previous 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, + previous: Option, + ) { + match previous { + Some(previous) => { + self.stored_attestations + .insert(tls_public_key.clone(), previous); + } + None => { + self.stored_attestations.remove(tls_public_key); + } + } } /// reverifies stored participant attestations. diff --git a/crates/contract/src/v3_12_0_state.rs b/crates/contract/src/v3_12_0_state.rs index cfa9567bd5..13d66b3e83 100644 --- a/crates/contract/src/v3_12_0_state.rs +++ b/crates/contract/src/v3_12_0_state.rs @@ -125,6 +125,8 @@ impl From for crate::MpcContract { ), tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), + // Nothing is in-flight across an upgrade: start with an empty map. + pending_attestations: LookupMap::new(StorageKey::PendingAttestationsV1), } } } diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index 937611a957..9bd409ba25 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -221,8 +221,11 @@ impl TestSetup { ) -> Result<(), mpc_contract::errors::Error> { let context = create_context_for_participant(&node_id.account_id); testing_env!(context); + // These tests submit `Mock` attestations, which resolve synchronously to + // `PromiseOrValue::Value(())`; discard the value and surface only errors. 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 +335,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 83e370a2f0..87f754259e 100644 --- a/crates/contract/tests/sandbox/contract_configuration.rs +++ b/crates/contract/tests/sandbox/contract_configuration.rs @@ -103,6 +103,9 @@ async fn contract_configuration_can_be_set_on_initialization() { 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/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..7cac803ab1 --- /dev/null +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -0,0 +1,233 @@ +//! 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. +//! +//! The `Verified` + post-DCAP-pass path (attestation stored) additionally needs +//! a stub report matching the fixture's post-DCAP expectations; it is a planned +//! follow-up once the off-chain report helper is wired into the sandbox harness. +//! The post-DCAP logic itself is unit-tested in `mpc-attestation`. +//! +//! They require the cross-contract runtime, so they live in sandbox rather than +//! the in-process tests. The WASM build needs the contract toolchain; these run +//! in CI. +#![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. +#[expect(clippy::large_enum_variant)] +#[derive(BorshSerialize)] +enum StubResponse { + // The Verified branch is exercised by the (deferred) post-DCAP-pass test; it + // is part of the stub's wire contract, so keep the variant even though no + // current test constructs it. + #[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__rejects_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__refunds_and_stores_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 yield-resume receipt, not the + // original call, so we assert observable state rather than the outer tx flag.) + 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__cleans_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; + 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/upgrade_from_current_contract.rs b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs index ef9d4e712b..06f3282599 100644 --- a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs +++ b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs @@ -119,6 +119,9 @@ async fn test_propose_update_config() { 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/sandbox/utils/contract_build.rs b/crates/contract/tests/sandbox/utils/contract_build.rs index cdaedf6e4d..6107478230 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,10 @@ pub fn migration_contract() -> &'static [u8] { pub fn parallel_contract() -> &'static [u8] { PARALLEL_CONTRACT.get_or_init(|| ContractBuilder::new(PARALLEL_CONTRACT_MANIFEST).build()) } + +/// Returns the `test-tee-verifier` stub WASM, used by the async attestation +/// sandbox tests as a stand-in for the real `tee-verifier` contract. +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 ce9690131e..f3a045b58f 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -57,6 +57,60 @@ pub async fn submit_participant_info( Ok(result) } +/// Like [`submit_participant_info`] but attaches `deposit` — used by the async +/// `Dstack` tests that assert the deposit is refunded on rejection/timeout. +pub async fn submit_participant_info_with_deposit( + account: &Account, + contract: &Contract, + attestation: &Attestation, + tls_key: &Ed25519PublicKey, + deposit: near_workspaces::types::NearToken, +) -> anyhow::Result { + let result = account + .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) + .args_json((attestation, tls_key)) + .deposit(deposit) + .max_gas() + .transact() + .await?; + Ok(result) +} + +/// Reads the `sandbox-test-methods`-only `has_pending_attestation` view. The +/// contract under test must be built with that feature (`with_sandbox_test_methods`). +pub async fn has_pending_attestation( + contract: &Contract, + account_id: &near_workspaces::AccountId, +) -> anyhow::Result { + let result = contract + .view("has_pending_attestation") + .args_json(serde_json::json!({ "account_id": account_id })) + .await?; + Ok(result.json()?) +} + +pub async fn vote_tee_verifier_change( + account: &Account, + contract: &Contract, + candidate_account_id: &near_workspaces::AccountId, + expected_code_hash: [u8; 32], +) -> anyhow::Result<()> { + // `expected_code_hash` is a `TeeVerifierCodeHash`, which the contract + // deserializes from a hex string (not a byte array), so wrap it in the typed + // hash to get the right JSON form. + let expected_code_hash = mpc_primitives::hash::TeeVerifierCodeHash::new(expected_code_hash); + let result = 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?; + all_receipts_successful(result)?; + Ok(()) +} + pub async fn get_participant_attestation( contract: &Contract, tls_key: &Ed25519PublicKey, 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 e522f87328..3b18eaa5aa 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,40 @@ expression: abi } } }, + { + "name": "fail_on_attestation_timeout", + "doc": " Fails the original `submit_participant_info` transaction with `reason`,\n from a receipt separate from the cleanup so the cleanup is not rolled back.", + "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 +954,99 @@ expression: abi } } }, + { + "name": "on_attestation_verified", + "doc": " Yield-resume callback for a `Dstack` submission. On success resolves the\n caller's transaction; on a verifier rejection or the ~200-block timeout\n it cleans up, refunds, and fails the transaction from a separate receipt.", + "kind": "call", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "borsh", + "args": [ + { + "name": "account_id", + "type_schema": { + "declaration": "AccountId", + "definitions": { + "AccountId": { + "Struct": [ + "String" + ] + }, + "String": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "u8": { + "Primitive": 1 + } + } + } + } + ] + }, + "callbacks": [ + { + "serialization_type": "borsh", + "type_schema": { + "declaration": "FinalOutcome", + "definitions": { + "FinalOutcome": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "Ok", + "FinalOutcome__Ok" + ], + [ + 1, + "Err", + "FinalOutcome__Err" + ] + ] + } + }, + "FinalOutcome__Err": { + "Struct": [ + "String" + ] + }, + "FinalOutcome__Ok": { + "Struct": null + }, + "String": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "u8": { + "Primitive": 1 + } + } + } + } + ], + "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.", @@ -1006,6 +1133,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 +1398,470 @@ expression: abi ] } }, + { + "name": "resolve_verification", + "doc": " `.then` bridge for the cross-contract `verify_quote` call. Maps the\n verifier's response to a [`FinalOutcome`] and resumes the yield registered\n in [`Self::submit_dstack_attestation`].", + "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", @@ -1561,7 +2164,7 @@ expression: abi "result": { "serialization_type": "json", "type_schema": { - "type": "null" + "$ref": "#/definitions/PromiseOrValueNull" } } }, @@ -2716,12 +3319,15 @@ expression: abi "contract_upgrade_deposit_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": { @@ -2772,6 +3378,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 +3396,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 +3425,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 } } }, @@ -3392,6 +4016,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 +4043,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 +4087,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 +4702,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..5e77cfa0a5 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -346,10 +346,33 @@ impl Attestation { } } + /// Verifies a `Mock` attestation, which needs no DCAP report. Used by the + /// contract's synchronous submission path; `Dstack` attestations go through + /// the async verifier-contract flow and [`Attestation::verify_with_report`]. + pub fn verify_mock_only( + &self, + current_timestamp_seconds: u64, + allowed_mpc_docker_image_hashes: &[NodeImageHash], + allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], + accepted_measurements: &[ExpectedMeasurements], + ) -> Result { + match self { + Self::Mock(mock_attestation) => mock_attestation.verify( + current_timestamp_seconds, + allowed_mpc_docker_image_hashes, + allowed_launcher_docker_compose_hashes, + accepted_measurements, + ), + Self::Dstack(_) => Err(VerificationError::Custom( + "verify_mock_only called on a Dstack attestation".to_string(), + )), + } + } + /// 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. + /// `dcap-qvl`. Used by off-chain callers (node, tee-authority, attestation-cli); + /// `mpc-contract` no longer links it, offloading DCAP to the verifier contract. #[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..dfadb88b19 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_ON_ATTESTATION_TIMEOUT: &str = "fail_on_attestation_timeout"; + +// 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..584d4f9932 100644 --- a/crates/near-mpc-contract-interface/src/types/config.rs +++ b/crates/near-mpc-contract-interface/src/types/config.rs @@ -49,6 +49,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. @@ -99,6 +105,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)] @@ -123,6 +135,9 @@ mod tests { 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(); @@ -173,6 +188,9 @@ mod tests { 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-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..573bdb3dd7 --- /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.clone())) + } + StubResponse::Panic => env::panic_str("stub verifier: simulated crash"), + } + } +} diff --git a/crates/test-utils/src/contract_types.rs b/crates/test-utils/src/contract_types.rs index 6334ab1536..ab744af85b 100644 --- a/crates/test-utils/src/contract_types.rs +++ b/crates/test-utils/src/contract_types.rs @@ -15,5 +15,8 @@ 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, } } diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 0346ce6c69..f7ed88c7d7 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -1,5 +1,19 @@ # Attestation Verifier Contract Breakout +> **Status: largely implemented.** The verifier contract (`tee-verifier`) and +> wire DTOs (`tee-verifier-interface`) shipped first; `mpc-contract` now offloads +> DCAP verification to the verifier via the async `submit_participant_info` flow +> described below, the verifier-change vote is in place, and `dcap-qvl` is no +> longer linked into `mpc-contract`. Remaining follow-ups: collapsing the +> JSON-facing `near-mpc-contract-interface::Collateral` into the interface type +> (issue #3494 — an API-wire migration deferred to its own change); making +> `tee_verifier_account_id` non-`Option` once a verifier is voted in (issue +> #3639 — until then an unconfigured verifier is `None` and `Dstack` submissions +> are rejected with `VerifierNotConfigured`); and a sandbox test for the +> `Verified` + post-DCAP-pass branch (the rejection, crash/timeout, and +> not-configured branches are covered; the post-DCAP logic itself is unit-tested +> in `mpc-attestation`). + This document outlines the design for moving on-chain TDX quote verification out of `mpc-contract`'s WASM into a standalone verifier contract. It supersedes [#3160](https://github.com/near/mpc/pull/3160), which sketched a three-contract architecture (shared verifier + per-team policy contract + TEE-agnostic application contract) for Defuse, Proximity, and other teams. That direction was deferred: a shared policy contract presumes shared lifecycle conventions (the [launcher pattern][launcher-pattern] `mpc-contract` uses), and aligning the other teams on those conventions is a separate, longer [conversation][slack-launcher-discussion] that has not yet converged. From 23d77820efdd96e19fd5a53711441cf488cc6e76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 24 Jun 2026 12:47:10 +0200 Subject: [PATCH 02/26] refactor(contract): address review on async attestation flow - Rename `fail_on_attestation_timeout` to `fail_attestation_submission`: it is reached for every failure outcome (verifier rejection, post-DCAP failure, and yield timeout), not just timeouts, so the old name was misleading. - Reorder `submit_dstack_attestation` so the detached `verify_quote` promise chain is built before `enqueue_yield_request`, keeping the latter's `promise_return` the final host call per its documented invariant. Behavior is unchanged; only statement order differs. - Regenerate the ABI snapshot for the renamed method. --- crates/contract/src/lib.rs | 42 ++++++++++--------- .../snapshots/abi__abi_has_not_changed.snap | 4 +- .../src/method_names.rs | 2 +- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 3514537540..579c671b58 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -878,6 +878,25 @@ impl MpcContract { let attached_deposit = env::attached_deposit(); let tls_public_key = node_id.tls_public_key.clone(); + // Detached cross-contract call to the verifier; its `.then` bridges the + // response into a `promise_yield_resume` on the yield registered below. + // Built before `enqueue_yield_request` so that the latter's + // `promise_return` stays the final host call (see its doc comment). + Promise::new(verifier_account_id) + .function_call( + method_names::VERIFY_QUOTE.to_string(), + borsh::to_vec(&(quote, collateral)) + .expect("borsh serialization of verify_quote args must succeed"), + 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)) + .resolve_verification(node_id), + ) + .detach(); + self.enqueue_yield_request( method_names::ON_ATTESTATION_VERIFIED, borsh::to_vec(&account_id).expect("borsh serialization of account_id must succeed"), @@ -896,23 +915,6 @@ impl MpcContract { }, ); - // Cross-contract call to the verifier; its `.then` bridges the response - // into a `promise_yield_resume` on the yield registered above. - Promise::new(verifier_account_id) - .function_call( - method_names::VERIFY_QUOTE.to_string(), - borsh::to_vec(&(quote, collateral)) - .expect("borsh serialization of verify_quote args must succeed"), - 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)) - .resolve_verification(node_id), - ) - .detach(); - // The yield handle is already the return value (`enqueue_yield_request` // called `promise_return`); nothing further to return. Ok(PromiseOrValue::Value(())) @@ -2514,7 +2516,7 @@ impl MpcContract { // 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_ON_ATTESTATION_TIMEOUT.to_string(), + 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_on_timeout_tera_gas), @@ -2524,8 +2526,10 @@ impl MpcContract { /// Fails the original `submit_participant_info` transaction with `reason`, /// from a receipt separate from the cleanup so the cleanup is not rolled back. + /// Reached for every failure outcome (verifier rejection, post-DCAP failure, + /// or yield timeout), not just timeouts. #[private] - pub fn fail_on_attestation_timeout(#[serializer(borsh)] reason: String) { + pub fn fail_attestation_submission(#[serializer(borsh)] reason: String) { env::panic_str(&reason); } 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 3b18eaa5aa..4b57a4996e 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -559,8 +559,8 @@ expression: abi } }, { - "name": "fail_on_attestation_timeout", - "doc": " Fails the original `submit_participant_info` transaction with `reason`,\n from a receipt separate from the cleanup so the cleanup is not rolled back.", + "name": "fail_attestation_submission", + "doc": " Fails the original `submit_participant_info` transaction with `reason`,\n from a receipt separate from the cleanup so the cleanup is not rolled back.\n Reached for every failure outcome (verifier rejection, post-DCAP failure,\n or yield timeout), not just timeouts.", "kind": "view", "modifiers": [ "private" diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index dfadb88b19..fde652c2d8 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -66,7 +66,7 @@ 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_ON_ATTESTATION_TIMEOUT: &str = "fail_on_attestation_timeout"; +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"; From 9128fc907e4461132ac36300cdd9086bbabc83ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 24 Jun 2026 12:47:10 +0200 Subject: [PATCH 03/26] chore(contract): sort Cargo.toml dependencies Order the `attestation` and `tee-verifier-interface` dependencies added for the async verifier flow so `cargo sort --grouped` passes. --- crates/contract/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/contract/Cargo.toml b/crates/contract/Cargo.toml index e5753df0ef..e9d8f991a6 100644 --- a/crates/contract/Cargo.toml +++ b/crates/contract/Cargo.toml @@ -75,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 } @@ -88,7 +89,6 @@ k256 = { workspace = true, features = [ "arithmetic", "expose-field", ] } -attestation = { workspace = true } mpc-attestation = { workspace = true } mpc-primitives = { workspace = true } near-account-id = { workspace = true, features = ["serde"] } @@ -100,11 +100,11 @@ near-mpc-contract-interface = { workspace = true, features = [ ] } near-mpc-signature-verifier = { workspace = true } near-sdk = { workspace = true } -tee-verifier-interface = { workspace = true } 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 } From 0dbc1e4a264824ead3d3c9537a4d6be4c736309d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 24 Jun 2026 13:43:29 +0200 Subject: [PATCH 04/26] refactor(contract): address review round 2 on async attestation flow - `submit_participant_info` returns `Result<(), Error>` instead of `Result, Error>`, matching the `sign` / `ckd` / `verify_foreign_transaction` yield producers; `submit_dstack_attestation` ends on `enqueue_yield_request` so its `promise_return` is the final host call (resolves the "must be the last operation" invariant). - Flesh out the `submit_participant_info` rustdoc: sync `Mock` vs async `Dstack`, the `VerifierNotConfigured` / `VerificationAlreadyPending` errors, and refund-on-failure behavior. - `verify_mock_only`'s `Dstack` arm is `unreachable!()` (guaranteed-dead path). - Clarify the `on_attestation_verified` gas-budget docstring (covers the heavier timeout branch). - Serialize the verify_quote args by reference to avoid cloning the quote / collateral; read `tls_public_key` from the pending entry in `finish_verified_attestation` for rollback symmetry. - Rename the sandbox tests to the `__should_` form. - Regenerate the ABI snapshot. A Dstack rejection resolves in the verifier's response receipt (a later receipt than the original call), so the outcome is asserted via contract state rather than the original transaction's result. --- crates/contract/src/config.rs | 5 +- crates/contract/src/lib.rs | 50 ++++++++++++------- .../tests/inprocess/attestation_submission.rs | 14 ++---- crates/contract/tests/sandbox/tee_verifier.rs | 16 ++++-- .../snapshots/abi__abi_has_not_changed.snap | 4 +- crates/mpc-attestation/src/attestation.rs | 8 +-- 6 files changed, 56 insertions(+), 41 deletions(-) diff --git a/crates/contract/src/config.rs b/crates/contract/src/config.rs index cdb03e9f3a..9b296fdec5 100644 --- a/crates/contract/src/config.rs +++ b/crates/contract/src/config.rs @@ -39,8 +39,9 @@ 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. Only a trivial -/// map of the resumed outcome back to the caller, so it needs little. +/// 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. diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 579c671b58..e0bb16535b 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -781,15 +781,25 @@ impl MpcContract { ) } - /// (Prospective) Participants can submit their tee participant information through this - /// endpoint. + /// (Prospective) participants submit their TEE attestation through this endpoint. + /// + /// `Mock` attestations are verified synchronously and stored in this call. + /// `Dstack` attestations are verified asynchronously: the call yields and + /// dispatches a cross-contract `verify_quote` to the trusted verifier, and the + /// submitter's transaction resolves only once the verifier responds (or the + /// yield times out). A `Dstack` submission therefore requires a verifier to be + /// configured (else [`TeeError::VerifierNotConfigured`]) and rejects a second + /// submission from the same account while one is in flight + /// ([`TeeError::VerificationAlreadyPending`]). The attached deposit covers + /// attestation storage on success and is refunded if verification is rejected + /// or times out. #[payable] #[handle_result] pub fn submit_participant_info( &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()?; @@ -843,10 +853,10 @@ impl MpcContract { caller_is_not_participant, env::attached_deposit(), )?; - Ok(PromiseOrValue::Value(())) + Ok(()) } Attestation::Dstack(dstack) => { - Ok(self.submit_dstack_attestation(node_id, dstack, caller_is_not_participant)?) + self.submit_dstack_attestation(node_id, dstack, caller_is_not_participant) } } } @@ -859,7 +869,7 @@ impl MpcContract { node_id: NodeId, dstack: DstackAttestation, caller_is_not_participant: bool, - ) -> Result, Error> { + ) -> Result<(), Error> { let account_id = node_id.account_id.clone(); // One in-flight verification per account: a duplicate submit before the @@ -874,18 +884,19 @@ impl MpcContract { return Err(TeeError::VerifierNotConfigured.into()); }; - let (quote, collateral) = (dstack.quote.clone(), dstack.collateral.clone()); let attached_deposit = env::attached_deposit(); let tls_public_key = node_id.tls_public_key.clone(); // Detached cross-contract call to the verifier; its `.then` bridges the // response into a `promise_yield_resume` on the yield registered below. // Built before `enqueue_yield_request` so that the latter's - // `promise_return` stays the final host call (see its doc comment). + // `promise_return` stays the method's final host call (see its doc + // comment). Serialize the quote/collateral by reference so `dstack` can + // move into the pending entry below without cloning the (large) payload. Promise::new(verifier_account_id) .function_call( method_names::VERIFY_QUOTE.to_string(), - borsh::to_vec(&(quote, collateral)) + 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), @@ -915,9 +926,10 @@ impl MpcContract { }, ); - // The yield handle is already the return value (`enqueue_yield_request` - // called `promise_return`); nothing further to return. - Ok(PromiseOrValue::Value(())) + // 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(()) } fn charge_attestation_storage( @@ -2450,7 +2462,9 @@ impl MpcContract { let dstack = pending.dstack.clone(); let caller_is_not_participant = pending.caller_is_not_participant; let attached_deposit = pending.attached_deposit; - let tls_public_key = node_id.tls_public_key.clone(); + // The key `revert_dstack_store` unwinds; equal to `node_id`'s by + // construction, but read it from the pending entry for rollback symmetry. + let tls_public_key = pending.tls_public_key.clone(); let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); @@ -4254,9 +4268,7 @@ mod tests { .build(); testing_env!(participant_context); - contract - .submit_participant_info(Attestation::Mock(attestation), dto_public_key) - .map(|_| ()) + contract.submit_participant_info(Attestation::Mock(attestation), dto_public_key) } fn submit_valid_attestations( @@ -4673,7 +4685,7 @@ mod tests { .build(); testing_env!(ctx); - let _ = contract + contract .submit_participant_info(valid_attestation, participant_info.tls_public_key.clone()) .expect("Expected panic if predecessor != signer"); } @@ -4700,7 +4712,7 @@ mod tests { .build(); testing_env!(ctx); - let _ = contract + contract .submit_participant_info(valid_attestation, dto_public_key) .expect("Outsider attestation submission should succeed"); @@ -4762,7 +4774,7 @@ mod tests { .build() ); - let _ = contract + contract .submit_participant_info(Attestation::Mock(MockAttestation::Valid), dto_public_key) .unwrap(); diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index 9bd409ba25..937611a957 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -221,11 +221,8 @@ impl TestSetup { ) -> Result<(), mpc_contract::errors::Error> { let context = create_context_for_participant(&node_id.account_id); testing_env!(context); - // These tests submit `Mock` attestations, which resolve synchronously to - // `PromiseOrValue::Value(())`; discard the value and surface only errors. self.contract .submit_participant_info(attestation, node_id.tls_public_key.clone()) - .map(|_| ()) } /// Switches testing context to a given participant at a specific timestamp @@ -335,13 +332,10 @@ 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(), - ) - .map(|_| ()); + let attack_result = setup.contract.submit_participant_info( + Attestation::Mock(MockAttestation::Valid), + attacker_node.tls_public_key.clone(), + ); // 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/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 7cac803ab1..58a006617e 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -90,7 +90,8 @@ fn tls_key() -> dtos::Ed25519PublicKey { } #[tokio::test] -async fn submit_participant_info__rejects_dstack_when_verifier_not_configured() -> Result<()> { +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, @@ -121,7 +122,8 @@ async fn submit_participant_info__rejects_dstack_when_verifier_not_configured() } #[tokio::test] -async fn submit_participant_info__refunds_and_stores_nothing_on_verifier_rejection() -> Result<()> { +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, @@ -154,8 +156,9 @@ async fn submit_participant_info__refunds_and_stores_nothing_on_verifier_rejecti .await?; // Then: nothing is stored, the pending entry is cleaned up, and the deposit - // is refunded. (The rejection resolves in the yield-resume receipt, not the - // original call, so we assert observable state rather than the outer tx flag.) + // 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!( @@ -167,7 +170,7 @@ async fn submit_participant_info__refunds_and_stores_nothing_on_verifier_rejecti } #[tokio::test] -async fn submit_participant_info__cleans_up_on_verifier_crash() -> Result<()> { +async fn submit_participant_info__should_clean_up_on_verifier_crash() -> Result<()> { // Given: a contract whose trusted verifier panics (no verdict). let SandboxTestSetup { worker, @@ -192,6 +195,9 @@ async fn submit_participant_info__cleans_up_on_verifier_crash() -> Result<()> { // `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, 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 4b57a4996e..2505a32c22 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -2139,7 +2139,7 @@ expression: abi }, { "name": "submit_participant_info", - "doc": " (Prospective) Participants can submit their tee participant information through this\n endpoint.", + "doc": " (Prospective) participants submit their TEE attestation through this endpoint.\n\n `Mock` attestations are verified synchronously and stored in this call.\n `Dstack` attestations are verified asynchronously: the call yields and\n dispatches a cross-contract `verify_quote` to the trusted verifier, and the\n submitter's transaction resolves only once the verifier responds (or the\n yield times out). A `Dstack` submission therefore requires a verifier to be\n configured (else [`TeeError::VerifierNotConfigured`]) and rejects a second\n submission from the same account while one is in flight\n ([`TeeError::VerificationAlreadyPending`]). The attached deposit covers\n attestation storage on success and is refunded if verification is rejected\n or times out.", "kind": "call", "modifiers": [ "payable" @@ -2164,7 +2164,7 @@ expression: abi "result": { "serialization_type": "json", "type_schema": { - "$ref": "#/definitions/PromiseOrValueNull" + "type": "null" } } }, diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 5e77cfa0a5..79c773a728 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -363,9 +363,11 @@ impl Attestation { allowed_launcher_docker_compose_hashes, accepted_measurements, ), - Self::Dstack(_) => Err(VerificationError::Custom( - "verify_mock_only called on a Dstack attestation".to_string(), - )), + Self::Dstack(_) => { + unreachable!( + "verify_mock_only is only called on the Mock arm of submit_participant_info" + ) + } } } From b4d10204a9180681d4963620cad3a837bac8c5ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 24 Jun 2026 13:43:29 +0200 Subject: [PATCH 05/26] chore: drop unused test-tee-verifier workspace dependency The sandbox tests build the stub by manifest path via `ContractBuilder`, not as a Cargo dependency, so the `[workspace.dependencies]` alias was never consumed and `cargo shear` flagged it. The workspace member entry is kept so the crate is still built and checked. --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 2d94b6883e..80be739c9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,7 +72,6 @@ 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 = { path = "crates/test-tee-verifier" } test-utils = { path = "crates/test-utils" } threshold-signatures = { path = "crates/threshold-signatures" } From f63cc6caa718a4e35515f8ca5ac717fe7bc3ee88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 24 Jun 2026 14:15:24 +0200 Subject: [PATCH 06/26] docs(contract): fix public-to-private intra-doc link in resolve_verification `resolve_verification` is public, so linking `[`Self::submit_dstack_attestation`]` (a private fn) tripped `rustdoc::private_intra_doc_links` under `-D warnings`, failing `cargo doc`. Refer to it by name instead. --- crates/contract/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index e0bb16535b..1b2a23b722 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -2392,7 +2392,7 @@ impl MpcContract { /// `.then` bridge for the cross-contract `verify_quote` call. Maps the /// verifier's response to a [`FinalOutcome`] and resumes the yield registered - /// in [`Self::submit_dstack_attestation`]. + /// in `submit_dstack_attestation`. #[private] pub fn resolve_verification( &mut self, From 763ad1959e194ac03bbf08dbda14eebeadcf3513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 24 Jun 2026 14:23:41 +0200 Subject: [PATCH 07/26] test(contract): regenerate ABI snapshot for resolve_verification doc The doc-link fix changed `resolve_verification`'s rustdoc, which the ABI captures in its `doc` field; regenerate 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 2505a32c22..506e1450eb 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -1400,7 +1400,7 @@ expression: abi }, { "name": "resolve_verification", - "doc": " `.then` bridge for the cross-contract `verify_quote` call. Maps the\n verifier's response to a [`FinalOutcome`] and resumes the yield registered\n in [`Self::submit_dstack_attestation`].", + "doc": " `.then` bridge for the cross-contract `verify_quote` call. Maps the\n verifier's response to a [`FinalOutcome`] and resumes the yield registered\n in `submit_dstack_attestation`.", "kind": "call", "modifiers": [ "private" From 3596b6a42fd98240d70bd61f81af57b84cb99821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 24 Jun 2026 15:00:44 +0200 Subject: [PATCH 08/26] refactor(contract): address review round 3 on async attestation flow - Drop the redundant `Attestation::verify_mock_only` wrapper: `add_mock_participant` already holds a `MockAttestation` and now calls `MockAttestation::verify` directly (the wrapper only round-tripped through the `Attestation` enum to match the Mock arm back out, and its `Dstack` arm was an unreachable panic). - Reword the `InvalidAttestation` error prefix from "TeeQuoteStatus is invalid" to "attestation verification failed": the wrapped error is an `attestation::VerificationError` (allowlist / measurement / report-data checks), not a `TeeQuoteStatus`. --- crates/contract/src/lib.rs | 6 +++--- crates/contract/src/tee/tee_state.rs | 5 ++--- crates/mpc-attestation/src/attestation.rs | 25 ----------------------- 3 files changed, 5 insertions(+), 31 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 1b2a23b722..ce0e87b224 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -155,7 +155,7 @@ fn refund_attestation_deposit(account_id: &AccountId, deposit: NearToken) { fn map_attestation_submission_error(err: AttestationSubmissionError) -> Error { let reason = match &err { AttestationSubmissionError::InvalidAttestation(_) => { - format!("TeeQuoteStatus is invalid: {err}") + format!("attestation verification failed: {err}") } AttestationSubmissionError::TlsKeyOwnedByOtherAccount => err.to_string(), }; @@ -4380,8 +4380,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("attestation verification failed"), + "Error should mention attestation verification failure, got: {}", error_string ); } diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 26ad76217d..b6ca829cb2 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -185,12 +185,11 @@ impl TeeState { tee_upgrade_deadline_duration: Duration, ) -> Result { let accepted_measurements = self.get_accepted_measurements(); - // Pure, always-compiled mock verification: no DCAP, so the contract does - // not link `dcap-qvl`. + // Pure mock verification: no DCAP, so the contract does not link `dcap-qvl`. let AcceptedAttestation { attestation: verified_attestation, advisory_ids, - } = Attestation::Mock(mock).verify_mock_only( + } = mock.verify( Self::current_time_seconds(), &self.get_allowed_mpc_docker_image_hashes(tee_upgrade_deadline_duration), &self.get_allowed_launcher_compose_hashes(), diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 79c773a728..6e3e3ac3a7 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -346,31 +346,6 @@ impl Attestation { } } - /// Verifies a `Mock` attestation, which needs no DCAP report. Used by the - /// contract's synchronous submission path; `Dstack` attestations go through - /// the async verifier-contract flow and [`Attestation::verify_with_report`]. - pub fn verify_mock_only( - &self, - current_timestamp_seconds: u64, - allowed_mpc_docker_image_hashes: &[NodeImageHash], - allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], - accepted_measurements: &[ExpectedMeasurements], - ) -> Result { - match self { - Self::Mock(mock_attestation) => mock_attestation.verify( - current_timestamp_seconds, - allowed_mpc_docker_image_hashes, - allowed_launcher_docker_compose_hashes, - accepted_measurements, - ), - Self::Dstack(_) => { - unreachable!( - "verify_mock_only is only called on the Mock arm of submit_participant_info" - ) - } - } - } - /// 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 (node, tee-authority, attestation-cli); From c1ea4d78920c813fe0fa13c31136fb27a455c085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 24 Jun 2026 15:19:50 +0200 Subject: [PATCH 09/26] chore: bump test-tee-verifier to 3.13.0 in Cargo.lock The v3.13.0 release bumped existing crates' lockfile versions, but test-tee-verifier is new on this branch and was still pinned at 3.12.0. --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index d376dff4c2..120d576029 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11222,7 +11222,7 @@ dependencies = [ [[package]] name = "test-tee-verifier" -version = "3.12.0" +version = "3.13.0" dependencies = [ "borsh", "getrandom 0.2.17", From 2ae9481b9d8bd0bb147ccb386804fae1a485a00e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 24 Jun 2026 17:34:35 +0200 Subject: [PATCH 10/26] docs(contract): use intra-doc links or prose for code refs, tighten wording Replace bare-backtick code references in the async TEE attestation doc comments with [`...`] intra-doc links where the item is linkable and plain prose otherwise, so cargo doc catches drift instead of letting it rot silently. Tighten the same comments. Align the verifier-contract design doc with the shipped implementation (Result<(), Error> return, Option verifier, separate fail_attestation_submission receipt) and update the two TDX guides for the renamed attestation-verification error string. --- crates/contract/src/lib.rs | 68 +++++----- .../contract/src/tee/pending_attestation.rs | 62 ++++----- crates/contract/src/tee/tee_state.rs | 36 +++-- .../snapshots/abi__abi_has_not_changed.snap | 8 +- crates/mpc-attestation/src/attestation.rs | 7 +- docs/design/attestation-verifier-contract.md | 125 +++++++++--------- docs/localnet/tee-localnet.md | 2 +- ...nning-an-mpc-node-in-tdx-external-guide.md | 2 +- 8 files changed, 145 insertions(+), 165 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index ce0e87b224..bb6b077ff5 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -144,7 +144,8 @@ fn require_deposit(minimum_deposit: NearToken, predecessor: &AccountId) { } /// Refunds an attestation submitter's attached deposit (no-op for a zero -/// deposit). Used when a `Dstack` verification is rejected or times out. +/// 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}"); @@ -187,16 +188,16 @@ pub struct MpcContract { metrics: Metrics, foreign_chains: Lazy, /// The verifier contract account trusted for DCAP verification, or [`None`] - /// until participants vote one in. A `Dstack` `submit_participant_info` + /// 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 `Dstack` verifications, one entry per submitter account, held - /// between the cross-contract `verify_quote` call and its resolution (or the - /// yield timeout). See [`tee::pending_attestation`]. + /// 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). See [`tee::pending_attestation`]. pending_attestations: LookupMap, } @@ -332,11 +333,11 @@ impl MpcContract { (domain_config, predecessor) } - /// Creates a yield-resume promise that calls back into `callback_method` with the - /// pre-serialized `callback_args`, and stores the resulting yield id via `insert`. + /// Registers a yield-resume promise for the given callback and stores its + /// yield id via the supplied closure. /// - /// This function calls `env::promise_return` and so must be the last operation performed - /// in the enclosing contract method. + /// Issues the promise-return host call, so it must be the last operation in + /// the enclosing contract method. fn enqueue_yield_request( &mut self, callback_method: &str, @@ -783,16 +784,14 @@ impl MpcContract { /// (Prospective) participants submit their TEE attestation through this endpoint. /// - /// `Mock` attestations are verified synchronously and stored in this call. - /// `Dstack` attestations are verified asynchronously: the call yields and - /// dispatches a cross-contract `verify_quote` to the trusted verifier, and the - /// submitter's transaction resolves only once the verifier responds (or the - /// yield times out). A `Dstack` submission therefore requires a verifier to be - /// configured (else [`TeeError::VerifierNotConfigured`]) and rejects a second - /// submission from the same account while one is in flight + /// [`Attestation::Mock`] is verified synchronously in this call. + /// [`Attestation::Dstack`] is verified asynchronously: the call yields on a + /// cross-contract verify-quote call and resolves only once the verifier + /// responds (or the yield times out). It therefore needs a verifier + /// configured (else [`TeeError::VerifierNotConfigured`]) and rejects a + /// concurrent submission from the same account /// ([`TeeError::VerificationAlreadyPending`]). The attached deposit covers - /// attestation storage on success and is refunded if verification is rejected - /// or times out. + /// storage on success and is refunded on failure. #[payable] #[handle_result] pub fn submit_participant_info( @@ -861,9 +860,9 @@ impl MpcContract { } } - /// Async `Dstack` submission: register a yield, fire a cross-contract - /// `verify_quote` at the trusted verifier, and bridge its response back into - /// a `promise_yield_resume` via the `resolve_verification` callback. + /// 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, @@ -2390,9 +2389,8 @@ impl MpcContract { } } - /// `.then` bridge for the cross-contract `verify_quote` call. Maps the - /// verifier's response to a [`FinalOutcome`] and resumes the yield registered - /// in `submit_dstack_attestation`. + /// Verify-quote callback: maps the verifier's response to a [`FinalOutcome`] + /// and resumes the yield. #[private] pub fn resolve_verification( &mut self, @@ -2445,10 +2443,10 @@ impl MpcContract { ); } - /// Runs the post-DCAP checks and stores the attestation for a verifier - /// `Verified` response. Returns the outcome to resume the yield with; on a - /// post-DCAP or storage failure, reverts the store (the callback receipt - /// commits regardless, unlike the synchronous path). + /// 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, @@ -2502,9 +2500,10 @@ impl MpcContract { } } - /// Yield-resume callback for a `Dstack` submission. On success resolves the - /// caller's transaction; on a verifier rejection or the ~200-block timeout - /// it cleans up, refunds, and fails the transaction from a separate receipt. + /// 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, @@ -2538,10 +2537,9 @@ impl MpcContract { PromiseOrValue::Promise(promise.as_return()) } - /// Fails the original `submit_participant_info` transaction with `reason`, - /// from a receipt separate from the cleanup so the cleanup is not rolled back. - /// Reached for every failure outcome (verifier rejection, post-DCAP failure, - /// or yield timeout), not just timeouts. + /// Fails the original [`Self::submit_participant_info`] transaction, from a + /// receipt separate from the cleanup so the cleanup is not rolled back. + /// Reached for every failure outcome, not just timeouts. #[private] pub fn fail_attestation_submission(#[serializer(borsh)] reason: String) { env::panic_str(&reason); diff --git a/crates/contract/src/tee/pending_attestation.rs b/crates/contract/src/tee/pending_attestation.rs index aee90db9f7..a45c28edc9 100644 --- a/crates/contract/src/tee/pending_attestation.rs +++ b/crates/contract/src/tee/pending_attestation.rs @@ -1,11 +1,12 @@ -//! State for an in-flight `Dstack` attestation submission. +//! State for an in-flight [`Attestation::Dstack`] submission. //! -//! `submit_participant_info` for a `Dstack` attestation is asynchronous: it -//! yields, fires a cross-contract `verify_quote` call to the trusted verifier, -//! and resumes from the response callback. Everything the callback needs that -//! is not re-readable from contract state at callback time is stashed here, -//! keyed by the submitter's `AccountId`, until the verification resolves (or -//! the yield times out). +//! A [`Attestation::Dstack`] 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 [`AccountId`], until the yield resolves. +//! +//! [`Attestation::Dstack`]: mpc_attestation::attestation::Attestation::Dstack +//! [`AccountId`]: near_sdk::AccountId use borsh::{BorshDeserialize, BorshSerialize}; use mpc_attestation::attestation::DstackAttestation; @@ -13,42 +14,28 @@ use near_mpc_contract_interface::types::Ed25519PublicKey; use near_sdk::{CryptoHash, NearToken}; /// One in-flight verification per submitter account. -// -// Plain borsh (not `#[near(serializers=[borsh])]`): this is internal contract -// state that never appears in a public method signature, so it does not need a -// `BorshSchema` for ABI generation — and avoiding it keeps `DstackAttestation` -// out of the ABI schema requirement. #[derive(Debug, BorshSerialize, BorshDeserialize)] pub struct PendingAttestation { - /// The submitted `Dstack` payload — RTMR3 event log, app-compose, and the - /// quote/collateral — that the post-DCAP checks consume once the verifier + /// The submitted payload the post-DCAP checks consume once the verifier /// returns its report. pub dstack: DstackAttestation, - /// The submitter's TLS public key, hashed with its account public key and - /// compared against the quote's report-data during the post-DCAP checks. + /// Checked against the quote's report-data during the post-DCAP checks. pub tls_public_key: Ed25519PublicKey, - /// Deposit attached at submit time. `env::attached_deposit()` is not visible - /// from the callback receipt, so it is stashed here: consumed for storage - /// staking on success, refunded on failure. + /// Stashed because the deposit is not visible from the callback receipt: + /// consumed for storage on success, refunded on failure. pub attached_deposit: NearToken, - /// Whether the submitter was a non-participant at submit time. Together with - /// "is this a new attestation", this decides whether the caller pays for - /// storage (preserving the synchronous contract's charging rule). Captured - /// at submit time because participant status is re-derived from the caller, - /// which the callback receipt no longer is. + /// 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_not_participant: bool, - /// Yield handle from `env::promise_yield_create`. The resolution callback - /// reads it back to `promise_yield_resume` with the final outcome. + /// Yield handle, read back by the callback to resume the yield. pub data_id: CryptoHash, } -/// Outcome the resolution callback resumes the yielded promise with. The -/// yield-callback maps it back to a `Result` for the original caller. -// -// `FinalOutcome` reaches ABI generation as the `#[callback_result]` argument -// type of `on_attestation_verified`, so it needs a `BorshSchema` under `abi` -// (unlike `PendingAttestation`, which is pure state). Both its variants are -// schema-able (`()` and `String`). +/// Outcome the resolution callback resumes the yield with. +/// +/// Appears in a public callback signature, so it derives `BorshSchema` under +/// the `abi` feature (unlike [`PendingAttestation`], which is pure state). #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), @@ -73,12 +60,9 @@ mod tests { } } - /// Pin the exact wire bytes. `FinalOutcome` is serialized into a live - /// `promise_yield_resume` payload, so a variant reorder would flip the tag - /// and silently break callback receipts in flight across an upgrade — a - /// regression the round-trip test above cannot catch. The tag is the - /// variant index (`Ok` = 0, `Err` = 1); `String` is borsh-encoded as a - /// little-endian `u32` length followed by the UTF-8 bytes. + /// Pins the wire bytes: a variant reorder would flip the borsh tag and + /// silently break callback receipts in flight across an upgrade, which the + /// round-trip test above cannot catch. #[test] fn final_outcome__should_have_pinned_borsh_layout() { assert_eq!(borsh::to_vec(&FinalOutcome::Ok).unwrap(), vec![0]); diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index b6ca829cb2..d6923d7d5c 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -151,10 +151,10 @@ impl TeeState { current_time_milliseconds / 1_000 } - /// Test-only dispatcher kept for the many `Mock`-based unit tests. The - /// production path no longer calls this: `Mock` submissions go through - /// [`Self::add_mock_participant`] and `Dstack` submissions through the async - /// verifier flow ([`Self::finish_dstack_verify`]). + /// Test-only dispatcher for [`Attestation::Mock`]-based unit tests. In + /// production, [`Attestation::Mock`] goes through + /// [`Self::add_mock_participant`] and [`Attestation::Dstack`] through + /// [`Self::finish_dstack_verify`]. #[cfg(test)] pub(crate) fn add_participant( &mut self, @@ -172,12 +172,10 @@ impl TeeState { } } - /// Verifies and stores a `Mock` attestation synchronously. + /// Verifies and stores a [`Attestation::Mock`] attestation synchronously. /// - /// Mock attestations have no real quote, so there is no DCAP step and no - /// verifier round-trip — verification is local and immediate, unlike the - /// `Dstack` path which goes through [`Self::finish_dstack_verify`] after the - /// verifier contract responds. + /// Mock has no real quote, so verification is local and immediate, unlike + /// the [`Attestation::Dstack`] path through [`Self::finish_dstack_verify`]. pub(crate) fn add_mock_participant( &mut self, node_id: NodeId, @@ -205,11 +203,10 @@ impl TeeState { Ok(insertion) } - /// Runs the post-DCAP checks for a `Dstack` attestation against the - /// `VerifiedReport` the verifier contract returned, then stores the result. - /// Called from `resolve_verification` once the cross-contract `verify_quote` - /// succeeds; the DCAP cryptographic verification has already happened in the - /// verifier contract. + /// Runs the post-DCAP checks for a [`Attestation::Dstack`] attestation + /// against the [`VerifiedReport`] the verifier returned, then stores the + /// result. Called from [`crate::MpcContract::resolve_verification`]; the DCAP + /// verification itself has already happened in the verifier contract. pub(crate) fn finish_dstack_verify( &mut self, node_id: NodeId, @@ -246,12 +243,11 @@ impl TeeState { /// Stores an already-verified attestation, enforcing TLS-key ownership. /// - /// Returns the insertion result and, when an existing entry was displaced, - /// the previous [`NodeAttestation`]. The caller (the async `Dstack` flow) - /// uses that previous entry to [`Self::revert_dstack_store`] if the storage - /// charge fails — the synchronous path relied on the receipt rolling back, - /// but the callback receipt commits regardless, so the rollback must be - /// explicit. + /// Returns the insertion result and any displaced [`NodeAttestation`], which + /// the async [`Attestation::Dstack`] flow feeds to + /// [`Self::revert_dstack_store`] if the storage charge fails: the callback + /// receipt commits regardless, so the rollback must be explicit (the + /// synchronous path relied on the receipt rolling back). fn store_verified_attestation( &mut self, node_id: NodeId, 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 58ea7e5663..e46c378201 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -560,7 +560,7 @@ expression: abi }, { "name": "fail_attestation_submission", - "doc": " Fails the original `submit_participant_info` transaction with `reason`,\n from a receipt separate from the cleanup so the cleanup is not rolled back.\n Reached for every failure outcome (verifier rejection, post-DCAP failure,\n or yield timeout), not just timeouts.", + "doc": " Fails the original [`Self::submit_participant_info`] transaction, from a\n receipt separate from the cleanup so the cleanup is not rolled back.\n Reached for every failure outcome, not just timeouts.", "kind": "view", "modifiers": [ "private" @@ -956,7 +956,7 @@ expression: abi }, { "name": "on_attestation_verified", - "doc": " Yield-resume callback for a `Dstack` submission. On success resolves the\n caller's transaction; on a verifier rejection or the ~200-block timeout\n it cleans up, refunds, and fails the transaction from a separate receipt.", + "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" @@ -1400,7 +1400,7 @@ expression: abi }, { "name": "resolve_verification", - "doc": " `.then` bridge for the cross-contract `verify_quote` call. Maps the\n verifier's response to a [`FinalOutcome`] and resumes the yield registered\n in `submit_dstack_attestation`.", + "doc": " Verify-quote callback: maps the verifier's response to a [`FinalOutcome`]\n and resumes the yield.", "kind": "call", "modifiers": [ "private" @@ -2139,7 +2139,7 @@ expression: abi }, { "name": "submit_participant_info", - "doc": " (Prospective) participants submit their TEE attestation through this endpoint.\n\n `Mock` attestations are verified synchronously and stored in this call.\n `Dstack` attestations are verified asynchronously: the call yields and\n dispatches a cross-contract `verify_quote` to the trusted verifier, and the\n submitter's transaction resolves only once the verifier responds (or the\n yield times out). A `Dstack` submission therefore requires a verifier to be\n configured (else [`TeeError::VerifierNotConfigured`]) and rejects a second\n submission from the same account while one is in flight\n ([`TeeError::VerificationAlreadyPending`]). The attached deposit covers\n attestation storage on success and is refunded if verification is rejected\n or times out.", + "doc": " (Prospective) participants submit their TEE attestation through this endpoint.\n\n [`Attestation::Mock`] is verified synchronously in this call.\n [`Attestation::Dstack`] is verified asynchronously: the call yields on a\n cross-contract verify-quote call and resolves only once the verifier\n responds (or the yield times out). It therefore needs a verifier\n configured (else [`TeeError::VerifierNotConfigured`]) and rejects a\n concurrent submission from the same account\n ([`TeeError::VerificationAlreadyPending`]). The attached deposit covers\n storage on success and is refunded on failure.", "kind": "call", "modifiers": [ "payable" diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 6e3e3ac3a7..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 (node, tee-authority, attestation-cli); - /// `mpc-contract` no longer links it, offloading DCAP 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/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index f7ed88c7d7..9f14734746 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -1,19 +1,5 @@ # Attestation Verifier Contract Breakout -> **Status: largely implemented.** The verifier contract (`tee-verifier`) and -> wire DTOs (`tee-verifier-interface`) shipped first; `mpc-contract` now offloads -> DCAP verification to the verifier via the async `submit_participant_info` flow -> described below, the verifier-change vote is in place, and `dcap-qvl` is no -> longer linked into `mpc-contract`. Remaining follow-ups: collapsing the -> JSON-facing `near-mpc-contract-interface::Collateral` into the interface type -> (issue #3494 — an API-wire migration deferred to its own change); making -> `tee_verifier_account_id` non-`Option` once a verifier is voted in (issue -> #3639 — until then an unconfigured verifier is `None` and `Dstack` submissions -> are rejected with `VerifierNotConfigured`); and a sandbox test for the -> `Verified` + post-DCAP-pass branch (the rejection, crash/timeout, and -> not-configured branches are covered; the post-DCAP logic itself is unit-tested -> in `mpc-attestation`). - This document outlines the design for moving on-chain TDX quote verification out of `mpc-contract`'s WASM into a standalone verifier contract. It supersedes [#3160](https://github.com/near/mpc/pull/3160), which sketched a three-contract architecture (shared verifier + per-team policy contract + TEE-agnostic application contract) for Defuse, Proximity, and other teams. That direction was deferred: a shared policy contract presumes shared lifecycle conventions (the [launcher pattern][launcher-pattern] `mpc-contract` uses), and aligning the other teams on those conventions is a separate, longer [conversation][slack-launcher-discussion] that has not yet converged. @@ -351,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 @@ -395,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 { @@ -403,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) => { @@ -420,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(), @@ -448,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(()) } } } @@ -576,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 3a6f67e768..096f006f04 100644 --- a/docs/running-an-mpc-node-in-tdx-external-guide.md +++ b/docs/running-an-mpc-node-in-tdx-external-guide.md @@ -1941,7 +1941,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 d029573f94d194f0cb1e50e7d4972644da41b979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 24 Jun 2026 17:40:50 +0200 Subject: [PATCH 11/26] refactor(contract): inline single-use bindings in TEE attestation code Drop four throwaway intermediate bindings flagged as redundant: the millisecond temporary in current_time_seconds, and the let result; Ok(result) tails in the three new sandbox test helpers (submit_participant_info_with_deposit, has_pending_attestation, vote_tee_verifier_change). No behavior change. --- crates/contract/src/tee/tee_state.rs | 3 +- .../tests/sandbox/utils/mpc_contract.rs | 31 +++++++++---------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index d6923d7d5c..52d6984708 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -147,8 +147,7 @@ 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 } /// Test-only dispatcher for [`Attestation::Mock`]-based unit tests. In diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index f3a045b58f..1fcace8a4c 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -66,14 +66,13 @@ pub async fn submit_participant_info_with_deposit( tls_key: &Ed25519PublicKey, deposit: near_workspaces::types::NearToken, ) -> anyhow::Result { - let result = account + Ok(account .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) .args_json((attestation, tls_key)) .deposit(deposit) .max_gas() .transact() - .await?; - Ok(result) + .await?) } /// Reads the `sandbox-test-methods`-only `has_pending_attestation` view. The @@ -82,11 +81,11 @@ pub async fn has_pending_attestation( contract: &Contract, account_id: &near_workspaces::AccountId, ) -> anyhow::Result { - let result = contract + Ok(contract .view("has_pending_attestation") .args_json(serde_json::json!({ "account_id": account_id })) - .await?; - Ok(result.json()?) + .await? + .json()?) } pub async fn vote_tee_verifier_change( @@ -99,16 +98,16 @@ pub async fn vote_tee_verifier_change( // deserializes from a hex string (not a byte array), so wrap it in the typed // hash to get the right JSON form. let expected_code_hash = mpc_primitives::hash::TeeVerifierCodeHash::new(expected_code_hash); - let result = 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?; - all_receipts_successful(result)?; - Ok(()) + 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( From b70354db0045899b15da145fbd9f8e068726aee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 29 Jun 2026 10:57:31 +0200 Subject: [PATCH 12/26] refactor(contract): borrow dstack in finish_dstack_verify to drop a clone `finish_verified_attestation` cloned `pending.dstack` (the TDX quote and collateral buffers) into `finish_dstack_verify` even though verification only ever borrows it: the owned value was wrapped in `Attestation::Dstack(_)` to reach the `&self` `verify_with_report`, then dropped. Take `&DstackAttestation` and call the `DstackVerify::verify` trait method directly on the borrow, so the clone is gone and `resolve_verification`'s single-remove control flow is untouched. --- crates/contract/src/lib.rs | 3 +-- crates/contract/src/tee/tee_state.rs | 9 ++++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index bb6b077ff5..5896bf8cf3 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -2457,7 +2457,6 @@ impl MpcContract { .pending_attestations .get(&account_id) .expect("resolve_verification confirmed the pending entry before calling us"); - let dstack = pending.dstack.clone(); let caller_is_not_participant = pending.caller_is_not_participant; let attached_deposit = pending.attached_deposit; // The key `revert_dstack_store` unwinds; equal to `node_id`'s by @@ -2469,7 +2468,7 @@ impl MpcContract { let initial_storage = env::storage_usage(); let (insertion, previous) = match self.tee_state.finish_dstack_verify( node_id.clone(), - dstack, + &pending.dstack, report, tee_upgrade_deadline_duration, ) { diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 52d6984708..7475a1eb8f 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -12,7 +12,7 @@ use crate::{ use borsh::{BorshDeserialize, BorshSerialize}; use mpc_attestation::{ attestation::{ - self, AcceptedAttestation, Attestation, DstackAttestation, MockAttestation, + self, AcceptedAttestation, DstackAttestation, DstackVerify, MockAttestation, VerifiedAttestation, }, report_data::{ReportData, ReportDataV1}, @@ -23,6 +23,9 @@ use near_sdk::{env, near, store::IterableMap}; use std::time::Duration; use tee_verifier_interface::VerifiedReport; +#[cfg(test)] +use mpc_attestation::attestation::Attestation; + pub use near_mpc_contract_interface::types::NodeId; #[derive(Debug, Clone, PartialEq, Eq)] @@ -209,7 +212,7 @@ impl TeeState { pub(crate) fn finish_dstack_verify( &mut self, node_id: NodeId, - dstack: DstackAttestation, + dstack: &DstackAttestation, report: &VerifiedReport, tee_upgrade_deadline_duration: Duration, ) -> Result<(ParticipantInsertion, Option), AttestationSubmissionError> { @@ -218,7 +221,7 @@ impl TeeState { let AcceptedAttestation { attestation: verified_attestation, advisory_ids, - } = Attestation::Dstack(dstack).verify_with_report( + } = dstack.verify( report, expected_report_data, Self::current_time_seconds(), From c3803ef853cf36643f91ba99718b5477fa3027fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 29 Jun 2026 13:04:03 +0200 Subject: [PATCH 13/26] refactor(contract): JSON-serialize the attestation yield-resume payload The attestation flow resumed its yield with a Borsh-encoded `FinalOutcome`, while the contract's other three yield-resume callbacks (sign, CKD, foreign-tx) all use JSON. `FinalOutcome` is a private, internal payload that never leaves `mpc-contract`, so there is no reason for it to differ. Switch it and `on_attestation_verified`'s args to JSON, matching the existing convention. The verifier-interface leg (`verify_quote` args/return, `VerificationResult`, `resolve_verification`'s callback arg) stays Borsh: that is the cross-contract wire format shared with external verifier callers, not the internal resume leg. --- crates/contract/src/lib.rs | 13 ++- .../contract/src/tee/pending_attestation.rs | 31 ++----- .../snapshots/abi__abi_has_not_changed.snap | 92 ++++++------------- 3 files changed, 42 insertions(+), 94 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 5896bf8cf3..1f8cd89b5f 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -909,7 +909,8 @@ impl MpcContract { self.enqueue_yield_request( method_names::ON_ATTESTATION_VERIFIED, - borsh::to_vec(&account_id).expect("borsh serialization of account_id must succeed"), + 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( @@ -2438,8 +2439,8 @@ impl MpcContract { // the state mutations above. env::promise_yield_resume( &pending.data_id, - borsh::to_vec(&final_outcome) - .expect("borsh serialization of FinalOutcome must succeed"), + serde_json::to_vec(&final_outcome) + .expect("json serialization of FinalOutcome must succeed"), ); } @@ -2506,10 +2507,8 @@ impl MpcContract { #[private] pub fn on_attestation_verified( &mut self, - #[serializer(borsh)] account_id: AccountId, - #[serializer(borsh)] - #[callback_result] - result: Result, + account_id: AccountId, + #[callback_result] result: Result, ) -> PromiseOrValue<()> { let reason = match result { Ok(FinalOutcome::Ok) => return PromiseOrValue::Value(()), diff --git a/crates/contract/src/tee/pending_attestation.rs b/crates/contract/src/tee/pending_attestation.rs index a45c28edc9..1997e66112 100644 --- a/crates/contract/src/tee/pending_attestation.rs +++ b/crates/contract/src/tee/pending_attestation.rs @@ -11,7 +11,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use mpc_attestation::attestation::DstackAttestation; use near_mpc_contract_interface::types::Ed25519PublicKey; -use near_sdk::{CryptoHash, NearToken}; +use near_sdk::{CryptoHash, NearToken, near}; /// One in-flight verification per submitter account. #[derive(Debug, BorshSerialize, BorshDeserialize)] @@ -34,13 +34,10 @@ pub struct PendingAttestation { /// Outcome the resolution callback resumes the yield with. /// -/// Appears in a public callback signature, so it derives `BorshSchema` under -/// the `abi` feature (unlike [`PendingAttestation`], which is pure state). -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] -#[cfg_attr( - all(feature = "abi", not(target_arch = "wasm32")), - derive(borsh::BorshSchema) -)] +/// JSON-serialized on the wire, matching the contract's other yield-resume +/// callbacks (sign, CKD, foreign-tx). +#[near(serializers = [json])] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum FinalOutcome { Ok, Err(String), @@ -52,23 +49,11 @@ mod tests { use super::*; #[test] - fn final_outcome__should_round_trip_borsh() { + fn final_outcome__should_round_trip_json() { for original in [FinalOutcome::Ok, FinalOutcome::Err("rejected".to_string())] { - let bytes = borsh::to_vec(&original).expect("serialize"); - let decoded: FinalOutcome = borsh::from_slice(&bytes).expect("deserialize"); + let bytes = serde_json::to_vec(&original).expect("serialize"); + let decoded: FinalOutcome = serde_json::from_slice(&bytes).expect("deserialize"); assert_eq!(original, decoded); } } - - /// Pins the wire bytes: a variant reorder would flip the borsh tag and - /// silently break callback receipts in flight across an upgrade, which the - /// round-trip test above cannot catch. - #[test] - fn final_outcome__should_have_pinned_borsh_layout() { - assert_eq!(borsh::to_vec(&FinalOutcome::Ok).unwrap(), vec![0]); - assert_eq!( - borsh::to_vec(&FinalOutcome::Err("x".to_string())).unwrap(), - vec![1, 1, 0, 0, 0, b'x'], - ); - } } 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 e46c378201..3ed7edae0b 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -962,81 +962,22 @@ expression: abi "private" ], "params": { - "serialization_type": "borsh", + "serialization_type": "json", "args": [ { "name": "account_id", "type_schema": { - "declaration": "AccountId", - "definitions": { - "AccountId": { - "Struct": [ - "String" - ] - }, - "String": { - "Sequence": { - "length_width": 4, - "length_range": { - "start": 0, - "end": 4294967295 - }, - "elements": "u8" - } - }, - "u8": { - "Primitive": 1 - } - } + "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": "borsh", + "serialization_type": "json", "type_schema": { - "declaration": "FinalOutcome", - "definitions": { - "FinalOutcome": { - "Enum": { - "tag_width": 1, - "variants": [ - [ - 0, - "Ok", - "FinalOutcome__Ok" - ], - [ - 1, - "Err", - "FinalOutcome__Err" - ] - ] - } - }, - "FinalOutcome__Err": { - "Struct": [ - "String" - ] - }, - "FinalOutcome__Ok": { - "Struct": null - }, - "String": { - "Sequence": { - "length_width": 4, - "length_range": { - "start": 0, - "end": 4294967295 - }, - "elements": "u8" - } - }, - "u8": { - "Primitive": 1 - } - } + "$ref": "#/definitions/FinalOutcome" } } ], @@ -3723,6 +3664,29 @@ expression: abi "type": "string", "pattern": "^(?:[0-9A-Fa-f]{2})*$" }, + "FinalOutcome": { + "description": "Outcome the resolution callback resumes the yield with.\n\nJSON-serialized on the wire, matching the contract's other yield-resume callbacks (sign, CKD, foreign-tx).", + "oneOf": [ + { + "type": "string", + "enum": [ + "Ok" + ] + }, + { + "type": "object", + "required": [ + "Err" + ], + "properties": { + "Err": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, "ForeignChain": { "type": "string", "enum": [ From 7e1fd2773f6f20e17a6fe1c75c9058de9c961346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 29 Jun 2026 13:24:07 +0200 Subject: [PATCH 14/26] refactor(contract): drop V1 suffix from pending-attestations storage key The TEE verifier vote storage keys already dropped their V1 suffix on this branch; `PendingAttestationsV1` was the lone new key still carrying one. Rename it to `PendingAttestations` so the keys added in this PR are consistent. The borsh storage key derives from the enum variant's position, not its name, and this is the last variant, so the on-chain key is unchanged. --- crates/contract/src/lib.rs | 6 +++--- crates/contract/src/storage_keys.rs | 2 +- crates/contract/src/v3_12_0_state.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 1f8cd89b5f..03dc0b26c8 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -2086,7 +2086,7 @@ impl MpcContract { ), tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), - pending_attestations: LookupMap::new(StorageKey::PendingAttestationsV1), + pending_attestations: LookupMap::new(StorageKey::PendingAttestations), }) } @@ -2166,7 +2166,7 @@ impl MpcContract { ), tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), - pending_attestations: LookupMap::new(StorageKey::PendingAttestationsV1), + pending_attestations: LookupMap::new(StorageKey::PendingAttestations), }) } @@ -4852,7 +4852,7 @@ mod tests { ), tee_verifier_account_id: None, tee_verifier_votes: Default::default(), - pending_attestations: LookupMap::new(StorageKey::PendingAttestationsV1), + pending_attestations: LookupMap::new(StorageKey::PendingAttestations), } } } diff --git a/crates/contract/src/storage_keys.rs b/crates/contract/src/storage_keys.rs index 07ff74a065..1bb1dc88e8 100644 --- a/crates/contract/src/storage_keys.rs +++ b/crates/contract/src/storage_keys.rs @@ -34,5 +34,5 @@ pub enum StorageKey { ForeignChainMetadata, TeeVerifierVotesByVoter, TeeVerifierVotesByProposal, - PendingAttestationsV1, + PendingAttestations, } diff --git a/crates/contract/src/v3_12_0_state.rs b/crates/contract/src/v3_12_0_state.rs index 13d66b3e83..f514c80238 100644 --- a/crates/contract/src/v3_12_0_state.rs +++ b/crates/contract/src/v3_12_0_state.rs @@ -126,7 +126,7 @@ impl From for crate::MpcContract { tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), // Nothing is in-flight across an upgrade: start with an empty map. - pending_attestations: LookupMap::new(StorageKey::PendingAttestationsV1), + pending_attestations: LookupMap::new(StorageKey::PendingAttestations), } } } From fbf3ec493527925bc0773d7ff064130f5e6eaea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 29 Jun 2026 14:10:30 +0200 Subject: [PATCH 15/26] refactor(contract): drop data-presence expects in the resolve path `resolve_verification` looked up the same `pending_attestations` entry three times (a `contains_key` guard, a `get().expect()` inside `finish_verified_attestation`, and a trailing `remove().expect()`), with two `expect()`s that only existed because the guard's presence guarantee was invisible to the type system. Remove the entry once, up front, into an owned binding via `let Some(...) else` (the idiom the sibling yield flows use), and pass it by reference to `finish_verified_attestation`. Three lookups collapse to one and both data-presence `expect()`s are gone; behavior is unchanged. The no-verdict arm is handled before the remove so the entry survives for the timeout path. The remaining `expect()` on `serde_json::to_vec` is a genuine can't-fail invariant. --- crates/contract/src/lib.rs | 56 ++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 03dc0b26c8..8379182457 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -2402,36 +2402,36 @@ impl MpcContract { ) { let account_id = node_id.account_id.clone(); - // A late verifier response can arrive after the ~200-block yield timeout - // has already fired and `on_attestation_verified` cleaned up the pending - // entry. The yield is then already resolved, so there is nothing to do: - // log and return rather than panic on a missing entry or resume twice. - if !self.pending_attestations.contains_key(&account_id) { + // 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 final_outcome = match result { - Err(promise_err) => { - // No verdict; let the yield timeout clean up. Do NOT resume, or - // we'd race the timeout for ownership of the cleanup path. - log!("verifier did not answer for {account_id}: {promise_err:?}"); - return; - } - Ok(VerificationResult::Rejected(reason)) => { + VerificationResult::Rejected(reason) => { log!("verifier rejected quote for {account_id}: {reason}"); FinalOutcome::Err(format!("verifier rejected quote: {reason}")) } - Ok(VerificationResult::Verified(report)) => { - self.finish_verified_attestation(&node_id, &report) + VerificationResult::Verified(report) => { + self.finish_verified_attestation(&node_id, &pending, &report) } }; - let pending = self.pending_attestations.remove(&account_id).expect( - "checked contains_key above; no host call between mutates pending_attestations", - ); if matches!(final_outcome, FinalOutcome::Err(_)) { refund_attestation_deposit(&account_id, pending.attached_deposit); } @@ -2451,18 +2451,10 @@ impl MpcContract { fn finish_verified_attestation( &mut self, node_id: &NodeId, + pending: &PendingAttestation, report: &VerifiedReport, ) -> FinalOutcome { - let account_id = node_id.account_id.clone(); - let pending = self - .pending_attestations - .get(&account_id) - .expect("resolve_verification confirmed the pending entry before calling us"); - let caller_is_not_participant = pending.caller_is_not_participant; - let attached_deposit = pending.attached_deposit; - // The key `revert_dstack_store` unwinds; equal to `node_id`'s by - // construction, but read it from the pending entry for rollback symmetry. - let tls_public_key = pending.tls_public_key.clone(); + let account_id = &node_id.account_id; let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); @@ -2481,11 +2473,11 @@ impl MpcContract { }; match self.charge_attestation_storage( - &account_id, + account_id, initial_storage, insertion, - caller_is_not_participant, - attached_deposit, + pending.caller_is_not_participant, + pending.attached_deposit, ) { Ok(()) => FinalOutcome::Ok, Err(err) => { @@ -2494,7 +2486,7 @@ impl MpcContract { // (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(&tls_public_key, previous); + .revert_dstack_store(&pending.tls_public_key, previous); FinalOutcome::Err(err.to_string()) } } From e91879ea82db54c3ed52a45dff26dcecf4a8b318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 29 Jun 2026 14:30:16 +0200 Subject: [PATCH 16/26] refactor(contract): rename FinalOutcome to AttestationResult `FinalOutcome` said nothing about what it was the outcome of. Rename it to `AttestationResult` to match the domain (it is the pass/fail result of an attestation submission, returned to the caller) and the codebase's operation-prefixed naming (`TeeValidationResult`, `SignatureResult`). The name also avoids confusion with `tee_verifier_interface::VerificationResult`, the verifier's Verified/Rejected answer that `resolve_verification` consumes one line away. --- crates/contract/src/lib.rs | 28 +++++------ .../contract/src/tee/pending_attestation.rs | 11 ++-- .../snapshots/abi__abi_has_not_changed.snap | 50 +++++++++---------- 3 files changed, 46 insertions(+), 43 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 8379182457..2bc3ae487e 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -48,7 +48,7 @@ use crate::{ votes::ProposalHash, }, storage_keys::StorageKey, - tee::pending_attestation::{FinalOutcome, PendingAttestation}, + tee::pending_attestation::{AttestationResult, PendingAttestation}, tee::tee_state::{TeeQuoteStatus, TeeState}, tee::verifier_votes::{TeeVerifierVotes, VerifierChangeProposal}, update::{ProposeUpdateArgs, ProposedUpdates, Update, UpdateId}, @@ -2390,7 +2390,7 @@ impl MpcContract { } } - /// Verify-quote callback: maps the verifier's response to a [`FinalOutcome`] + /// Verify-quote callback: maps the verifier's response to an [`AttestationResult`] /// and resumes the yield. #[private] pub fn resolve_verification( @@ -2422,25 +2422,25 @@ impl MpcContract { return; }; - let final_outcome = match result { + let attestation_result = match result { VerificationResult::Rejected(reason) => { log!("verifier rejected quote for {account_id}: {reason}"); - FinalOutcome::Err(format!("verifier rejected quote: {reason}")) + AttestationResult::Err(format!("verifier rejected quote: {reason}")) } VerificationResult::Verified(report) => { self.finish_verified_attestation(&node_id, &pending, &report) } }; - if matches!(final_outcome, FinalOutcome::Err(_)) { + 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(&final_outcome) - .expect("json serialization of FinalOutcome must succeed"), + serde_json::to_vec(&attestation_result) + .expect("json serialization of AttestationResult must succeed"), ); } @@ -2453,7 +2453,7 @@ impl MpcContract { node_id: &NodeId, pending: &PendingAttestation, report: &VerifiedReport, - ) -> FinalOutcome { + ) -> AttestationResult { let account_id = &node_id.account_id; let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); @@ -2468,7 +2468,7 @@ impl MpcContract { Ok(result) => result, Err(err) => { log!("post-DCAP check failed for {account_id}: {err}"); - return FinalOutcome::Err(format!("post-DCAP check failed: {err}")); + return AttestationResult::Err(format!("post-DCAP check failed: {err}")); } }; @@ -2479,7 +2479,7 @@ impl MpcContract { pending.caller_is_not_participant, pending.attached_deposit, ) { - Ok(()) => FinalOutcome::Ok, + 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 @@ -2487,7 +2487,7 @@ impl MpcContract { // caller would get storage for free plus a full refund. self.tee_state .revert_dstack_store(&pending.tls_public_key, previous); - FinalOutcome::Err(err.to_string()) + AttestationResult::Err(err.to_string()) } } } @@ -2500,11 +2500,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) => { // Timeout: the resolution callback never resumed us, so the // pending entry is still here. Clean it up and refund. diff --git a/crates/contract/src/tee/pending_attestation.rs b/crates/contract/src/tee/pending_attestation.rs index 1997e66112..b4b216d55b 100644 --- a/crates/contract/src/tee/pending_attestation.rs +++ b/crates/contract/src/tee/pending_attestation.rs @@ -38,7 +38,7 @@ pub struct PendingAttestation { /// callbacks (sign, CKD, foreign-tx). #[near(serializers = [json])] #[derive(Debug, Clone, PartialEq, Eq)] -pub enum FinalOutcome { +pub enum AttestationResult { Ok, Err(String), } @@ -49,10 +49,13 @@ mod tests { use super::*; #[test] - fn final_outcome__should_round_trip_json() { - for original in [FinalOutcome::Ok, FinalOutcome::Err("rejected".to_string())] { + fn attestation_result__should_round_trip_json() { + for original in [ + AttestationResult::Ok, + AttestationResult::Err("rejected".to_string()), + ] { let bytes = serde_json::to_vec(&original).expect("serialize"); - let decoded: FinalOutcome = serde_json::from_slice(&bytes).expect("deserialize"); + let decoded: AttestationResult = serde_json::from_slice(&bytes).expect("deserialize"); assert_eq!(original, decoded); } } 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 3ed7edae0b..62b66fb28f 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -977,7 +977,7 @@ expression: abi { "serialization_type": "json", "type_schema": { - "$ref": "#/definitions/FinalOutcome" + "$ref": "#/definitions/AttestationResult" } } ], @@ -1341,7 +1341,7 @@ expression: abi }, { "name": "resolve_verification", - "doc": " Verify-quote callback: maps the verifier's response to a [`FinalOutcome`]\n and resumes the yield.", + "doc": " Verify-quote callback: maps the verifier's response to an [`AttestationResult`]\n and resumes the yield.", "kind": "call", "modifiers": [ "private" @@ -2986,6 +2986,29 @@ expression: abi } ] }, + "AttestationResult": { + "description": "Outcome the resolution callback resumes the yield with.\n\nJSON-serialized on the wire, matching the contract's other yield-resume callbacks (sign, CKD, foreign-tx).", + "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" @@ -3664,29 +3687,6 @@ expression: abi "type": "string", "pattern": "^(?:[0-9A-Fa-f]{2})*$" }, - "FinalOutcome": { - "description": "Outcome the resolution callback resumes the yield with.\n\nJSON-serialized on the wire, matching the contract's other yield-resume callbacks (sign, CKD, foreign-tx).", - "oneOf": [ - { - "type": "string", - "enum": [ - "Ok" - ] - }, - { - "type": "object", - "required": [ - "Err" - ], - "properties": { - "Err": { - "type": "string" - } - }, - "additionalProperties": false - } - ] - }, "ForeignChain": { "type": "string", "enum": [ From 4558a1d3f54bf71f35e7bd240b62b678e5bb991f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 29 Jun 2026 17:12:32 +0200 Subject: [PATCH 17/26] refactor(contract): map AttestationSubmissionError via #[from], not a helper Drop `map_attestation_submission_error` and make `AttestationSubmissionError` a transparent `#[from]` variant of `Error`, matching every other domain error in the contract (RespondError, TeeError, DomainError, ...). The call site uses `?`. This also removes a double-prefixed message ("attestation verification failed: the submitted attestation failed verification, ..."); the user-facing error is now the transparent domain message. Also use `String::to_string` over `.clone()` in the test verifier stub. --- crates/contract/src/errors.rs | 4 ++++ crates/contract/src/lib.rs | 17 +++-------------- crates/contract/src/tee/tee_state.rs | 4 ++-- crates/test-tee-verifier/src/lib.rs | 2 +- 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/crates/contract/src/errors.rs b/crates/contract/src/errors.rs index 32b89c27b7..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}; @@ -326,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 2bc3ae487e..3119954493 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::{AttestationSubmissionError, NodeId, ParticipantInsertion, TeeValidationResult}, + tee_state::{NodeId, ParticipantInsertion, TeeValidationResult}, }; /// Register used to receive data id from `promise_await_data`. @@ -153,16 +153,6 @@ fn refund_attestation_deposit(account_id: &AccountId, deposit: NearToken) { } } -fn map_attestation_submission_error(err: AttestationSubmissionError) -> Error { - let reason = match &err { - AttestationSubmissionError::InvalidAttestation(_) => { - format!("attestation verification failed: {err}") - } - AttestationSubmissionError::TlsKeyOwnedByOtherAccount => err.to_string(), - }; - InvalidParameters::InvalidTeeRemoteAttestation { reason }.into() -} - impl Default for MpcContract { fn default() -> Self { env::panic_str("Calling default not allowed."); @@ -843,8 +833,7 @@ impl MpcContract { let initial_storage = env::storage_usage(); let insertion = self .tee_state - .add_mock_participant(node_id, mock, tee_upgrade_deadline_duration) - .map_err(map_attestation_submission_error)?; + .add_mock_participant(node_id, mock, tee_upgrade_deadline_duration)?; self.charge_attestation_storage( &account_id, initial_storage, @@ -4368,7 +4357,7 @@ mod tests { if let Err(error) = result { let error_string = error.to_string(); assert!( - error_string.contains("attestation verification failed"), + error_string.contains("failed verification"), "Error should mention attestation verification failure, got: {}", error_string ); diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 7475a1eb8f..262698ac3d 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -40,8 +40,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( diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs index 573bdb3dd7..bd5168f1ef 100644 --- a/crates/test-tee-verifier/src/lib.rs +++ b/crates/test-tee-verifier/src/lib.rs @@ -74,7 +74,7 @@ impl TestTeeVerifier { match &self.response { StubResponse::Verified(report) => VerificationResult::Verified(report.clone()), StubResponse::Rejected(reason) => { - VerificationResult::Rejected(VerifierError::DcapVerification(reason.clone())) + VerificationResult::Rejected(VerifierError::DcapVerification(reason.to_string())) } StubResponse::Panic => env::panic_str("stub verifier: simulated crash"), } From e4dd91935d0974bf1d8ecc331a3a59baab4a0cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 29 Jun 2026 17:18:28 +0200 Subject: [PATCH 18/26] style(contract): rustfmt the add_mock_participant call site --- crates/contract/src/lib.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 3119954493..999c550b11 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -831,9 +831,11 @@ impl MpcContract { Attestation::Mock(mock) => { // Synchronous path: no DCAP, store immediately. let initial_storage = env::storage_usage(); - let insertion = self - .tee_state - .add_mock_participant(node_id, mock, tee_upgrade_deadline_duration)?; + let insertion = self.tee_state.add_mock_participant( + node_id, + mock, + tee_upgrade_deadline_duration, + )?; self.charge_attestation_storage( &account_id, initial_storage, From 18d117505981f5426cde94303e6211bf43c47c33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 29 Jun 2026 18:46:29 +0200 Subject: [PATCH 19/26] refactor(contract): de-negate caller_is_participant, tidy attestation docs Flip `caller_is_not_participant` to `caller_is_participant` (a non-negated boolean is easier to reason about), inverting its derivation and simplifying the storage-charge guard from `!(is_new || not_participant)` to the positive `caller_is_participant && !is_new_attestation`. Also move the `tee_upgrade_deadline_duration` binding into the Mock arm that is its only user (the Dstack path recomputes it at resolution time), revert the unrelated `enqueue_yield_request` doc comment to match main, fix the `pending_attestations` intra-doc link to the resolvable `crate::`-prefixed form, and tighten the `submit_participant_info` and verifier-call comments. --- crates/contract/src/lib.rs | 70 ++++++++----------- .../contract/src/tee/pending_attestation.rs | 2 +- 2 files changed, 30 insertions(+), 42 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 999c550b11..57fca7fca2 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -187,7 +187,7 @@ pub struct MpcContract { 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). See [`tee::pending_attestation`]. + /// resolution (or the yield timeout). See [`crate::tee::pending_attestation`]. pending_attestations: LookupMap, } @@ -323,11 +323,11 @@ impl MpcContract { (domain_config, predecessor) } - /// Registers a yield-resume promise for the given callback and stores its - /// yield id via the supplied closure. + /// Creates a yield-resume promise that calls back into `callback_method` with the + /// pre-serialized `callback_args`, and stores the resulting yield id via `insert`. /// - /// Issues the promise-return host call, so it must be the last operation in - /// the enclosing contract method. + /// This function calls `env::promise_return` and so must be the last operation performed + /// in the enclosing contract method. fn enqueue_yield_request( &mut self, callback_method: &str, @@ -772,16 +772,14 @@ impl MpcContract { ) } - /// (Prospective) participants submit their TEE attestation through this endpoint. + /// Submit a TEE attestation for a current or prospective participant. /// - /// [`Attestation::Mock`] is verified synchronously in this call. - /// [`Attestation::Dstack`] is verified asynchronously: the call yields on a - /// cross-contract verify-quote call and resolves only once the verifier - /// responds (or the yield times out). It therefore needs a verifier - /// configured (else [`TeeError::VerifierNotConfigured`]) and rejects a - /// concurrent submission from the same account - /// ([`TeeError::VerificationAlreadyPending`]). The attached deposit covers - /// storage on success and is refunded on failure. + /// - [`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( @@ -802,9 +800,6 @@ impl MpcContract { account_key ); - 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 @@ -820,16 +815,15 @@ impl MpcContract { tls_public_key, account_public_key, }; - // Frozen at submit time and consumed later in the resolution callback. - // The callback receipt's predecessor is the contract itself, so participant - // status cannot be re-derived there — capture it now. A resharing that - // drops this submitter mid-flight therefore won't reclassify the storage - // charge, which is acceptable (the alternative is unavailable). - let caller_is_not_participant = self.voter_account().is_err(); + // 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) => { - // Synchronous path: no DCAP, store immediately. + 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, @@ -840,13 +834,13 @@ impl MpcContract { &account_id, initial_storage, insertion, - caller_is_not_participant, + caller_is_participant, env::attached_deposit(), )?; Ok(()) } Attestation::Dstack(dstack) => { - self.submit_dstack_attestation(node_id, dstack, caller_is_not_participant) + self.submit_dstack_attestation(node_id, dstack, caller_is_participant) } } } @@ -858,18 +852,14 @@ impl MpcContract { &mut self, node_id: NodeId, dstack: DstackAttestation, - caller_is_not_participant: bool, + caller_is_participant: bool, ) -> Result<(), Error> { let account_id = node_id.account_id.clone(); - // One in-flight verification per account: a duplicate submit before the - // previous one finishes (verifier response or yield timeout) is rejected. if self.pending_attestations.contains_key(&account_id) { return Err(TeeError::VerificationAlreadyPending.into()); } - // Refuse to submit until a verifier has been 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()); }; @@ -877,12 +867,9 @@ impl MpcContract { let attached_deposit = env::attached_deposit(); let tls_public_key = node_id.tls_public_key.clone(); - // Detached cross-contract call to the verifier; its `.then` bridges the - // response into a `promise_yield_resume` on the yield registered below. - // Built before `enqueue_yield_request` so that the latter's - // `promise_return` stays the method's final host call (see its doc - // comment). Serialize the quote/collateral by reference so `dstack` can - // move into the pending entry below without cloning the (large) payload. + // 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(), @@ -910,7 +897,7 @@ impl MpcContract { dstack, tls_public_key, attached_deposit, - caller_is_not_participant, + caller_is_participant, data_id, }, ); @@ -928,12 +915,13 @@ impl MpcContract { account_id: &AccountId, initial_storage: u64, insertion: ParticipantInsertion, - caller_is_not_participant: bool, + caller_is_participant: bool, attached: NearToken, ) -> Result<(), Error> { let is_new_attestation = matches!(insertion, ParticipantInsertion::NewlyInsertedParticipant); - if !(is_new_attestation || caller_is_not_participant) { + // A participant refreshing an existing attestation is not charged. + if caller_is_participant && !is_new_attestation { return Ok(()); } @@ -2467,7 +2455,7 @@ impl MpcContract { account_id, initial_storage, insertion, - pending.caller_is_not_participant, + pending.caller_is_participant, pending.attached_deposit, ) { Ok(()) => AttestationResult::Ok, diff --git a/crates/contract/src/tee/pending_attestation.rs b/crates/contract/src/tee/pending_attestation.rs index b4b216d55b..37a16226f3 100644 --- a/crates/contract/src/tee/pending_attestation.rs +++ b/crates/contract/src/tee/pending_attestation.rs @@ -27,7 +27,7 @@ pub struct PendingAttestation { /// 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_not_participant: bool, + pub caller_is_participant: bool, /// Yield handle, read back by the callback to resume the yield. pub data_id: CryptoHash, } From da9848c3624ad537074424d5df6f103a513ca3d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 30 Jun 2026 11:34:56 +0200 Subject: [PATCH 20/26] refactor(contract): drop the add_participant test dispatcher The #[cfg(test)] add_participant helper only matched Attestation::Mock through to add_mock_participant and panicked on Dstack, a branch no test exercised. Delete it and have the tests call add_mock_participant directly, passing the inner MockAttestation. Also tidy nearby doc comments and inline a single-use binding in add_mock_participant. --- crates/contract/src/lib.rs | 27 ++-- crates/contract/src/tee/tee_state.rs | 205 +++++++++------------------ 2 files changed, 82 insertions(+), 150 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 57fca7fca2..552385312f 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -187,7 +187,7 @@ pub struct MpcContract { 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). See [`crate::tee::pending_attestation`]. + /// resolution (or the yield timeout). pending_attestations: LookupMap, } @@ -2944,7 +2944,7 @@ 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 near_mpc_bounded_collections::{NonEmptyBTreeMap, NonEmptyBTreeSet}; use near_mpc_contract_interface::types::BackupServiceInfo; @@ -5275,14 +5275,13 @@ mod tests { destination_node_info, ); } - let valid_participant_attestation = mpc_attestation::attestation::Attestation::Mock( - mpc_attestation::attestation::MockAttestation::Valid, - ); + let valid_participant_attestation = + mpc_attestation::attestation::MockAttestation::Valid; let tee_upgrade_duration = Duration::from_secs(contract.config.tee_upgrade_deadline_duration_seconds); - let insertion_result = contract.tee_state.add_participant( + 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(), @@ -5936,15 +5935,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 @@ -6055,15 +6054,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]; @@ -7502,13 +7501,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/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 262698ac3d..bd5d9c2112 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -23,9 +23,6 @@ use near_sdk::{env, near, store::IterableMap}; use std::time::Duration; use tee_verifier_interface::VerifiedReport; -#[cfg(test)] -use mpc_attestation::attestation::Attestation; - pub use near_mpc_contract_interface::types::NodeId; #[derive(Debug, Clone, PartialEq, Eq)] @@ -153,39 +150,12 @@ impl TeeState { env::block_timestamp_ms() / 1_000 } - /// Test-only dispatcher for [`Attestation::Mock`]-based unit tests. In - /// production, [`Attestation::Mock`] goes through - /// [`Self::add_mock_participant`] and [`Attestation::Dstack`] through - /// [`Self::finish_dstack_verify`]. - #[cfg(test)] - pub(crate) fn add_participant( - &mut self, - node_id: NodeId, - attestation: Attestation, - tee_upgrade_deadline_duration: Duration, - ) -> Result { - match attestation { - Attestation::Mock(mock) => { - self.add_mock_participant(node_id, mock, tee_upgrade_deadline_duration) - } - Attestation::Dstack(_) => { - panic!("add_participant test helper does not support Dstack attestations") - } - } - } - - /// Verifies and stores a [`Attestation::Mock`] attestation synchronously. - /// - /// Mock has no real quote, so verification is local and immediate, unlike - /// the [`Attestation::Dstack`] path through [`Self::finish_dstack_verify`]. pub(crate) fn add_mock_participant( &mut self, node_id: NodeId, mock: MockAttestation, tee_upgrade_deadline_duration: Duration, ) -> Result { - let accepted_measurements = self.get_accepted_measurements(); - // Pure mock verification: no DCAP, so the contract does not link `dcap-qvl`. let AcceptedAttestation { attestation: verified_attestation, advisory_ids, @@ -193,13 +163,11 @@ impl TeeState { Self::current_time_seconds(), &self.get_allowed_mpc_docker_image_hashes(tee_upgrade_deadline_duration), &self.get_allowed_launcher_compose_hashes(), - &accepted_measurements, + &self.get_accepted_measurements(), )?; log_informational_advisory_ids(&advisory_ids); - // Synchronous path: the deposit is charged in the same receipt as the - // store, so a charge failure rolls the store back automatically — no - // need for the displaced previous entry the async path captures. + let (insertion, _previous) = self.store_verified_attestation(node_id, verified_attestation)?; Ok(insertion) @@ -243,13 +211,13 @@ impl TeeState { report_data.into() } - /// Stores an already-verified attestation, enforcing TLS-key ownership. + /// Stores an already-verified attestation, rejecting a TLS key owned by a + /// different account. /// - /// Returns the insertion result and any displaced [`NodeAttestation`], which - /// the async [`Attestation::Dstack`] flow feeds to - /// [`Self::revert_dstack_store`] if the storage charge fails: the callback - /// receipt commits regardless, so the rollback must be explicit (the - /// synchronous path relied on the receipt rolling back). + /// Returns the insertion result and the displaced [`NodeAttestation`], if any. + /// The Dstack path needs the displaced entry 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, @@ -641,7 +609,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; @@ -680,7 +648,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(), @@ -690,7 +658,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, @@ -698,7 +666,7 @@ mod tests { .unwrap(); } tee_state - .add_participant( + .add_mock_participant( non_participant_uid.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, @@ -749,24 +717,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); @@ -799,12 +767,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 { @@ -813,7 +781,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); @@ -849,14 +817,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. @@ -878,7 +846,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(), @@ -886,7 +854,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, @@ -897,7 +865,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, @@ -919,11 +887,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 @@ -943,11 +911,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 @@ -968,11 +936,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 @@ -1006,16 +974,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(); @@ -1048,15 +1016,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 @@ -1081,15 +1049,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 @@ -1126,15 +1094,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 @@ -1187,11 +1155,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 @@ -1252,11 +1216,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); @@ -1288,11 +1248,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 @@ -1335,11 +1291,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"); } @@ -1360,11 +1312,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 @@ -1394,25 +1342,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 @@ -1445,17 +1389,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"); } @@ -1486,9 +1430,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"); @@ -1499,9 +1443,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, ); @@ -1531,11 +1475,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. @@ -1544,9 +1484,9 @@ 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, ); @@ -1570,22 +1510,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, From 6ff5e6cf067e83b846f7e35a3967588bc88bf6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 30 Jun 2026 12:21:46 +0200 Subject: [PATCH 21/26] refactor(contract): return ParticipantInsertion instead of a tuple store_verified_attestation and finish_dstack_verify returned (ParticipantInsertion, Option), but the enum was just previous.is_some() re-encoded. Carry the overwritten entry inside the UpdatedExistingParticipant variant so both return a single value, and have revert_dstack_store recover it from there. --- crates/contract/src/lib.rs | 12 +++---- crates/contract/src/tee/tee_state.rs | 52 +++++++++++++++------------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 552385312f..510d7665e7 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -833,7 +833,7 @@ impl MpcContract { self.charge_attestation_storage( &account_id, initial_storage, - insertion, + &insertion, caller_is_participant, env::attached_deposit(), )?; @@ -914,7 +914,7 @@ impl MpcContract { &self, account_id: &AccountId, initial_storage: u64, - insertion: ParticipantInsertion, + insertion: &ParticipantInsertion, caller_is_participant: bool, attached: NearToken, ) -> Result<(), Error> { @@ -2438,13 +2438,13 @@ impl MpcContract { Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); let initial_storage = env::storage_usage(); - let (insertion, previous) = match self.tee_state.finish_dstack_verify( + let insertion = match self.tee_state.finish_dstack_verify( node_id.clone(), &pending.dstack, report, tee_upgrade_deadline_duration, ) { - Ok(result) => result, + Ok(insertion) => insertion, Err(err) => { log!("post-DCAP check failed for {account_id}: {err}"); return AttestationResult::Err(format!("post-DCAP check failed: {err}")); @@ -2454,7 +2454,7 @@ impl MpcContract { match self.charge_attestation_storage( account_id, initial_storage, - insertion, + &insertion, pending.caller_is_participant, pending.attached_deposit, ) { @@ -2465,7 +2465,7 @@ impl MpcContract { // (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, previous); + .revert_dstack_store(&pending.tls_public_key, insertion); AttestationResult::Err(err.to_string()) } } diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index bd5d9c2112..13dba9d5c2 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -48,9 +48,12 @@ pub 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)] @@ -168,22 +171,19 @@ impl TeeState { log_informational_advisory_ids(&advisory_ids); - let (insertion, _previous) = - self.store_verified_attestation(node_id, verified_attestation)?; - Ok(insertion) + 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. Called from [`crate::MpcContract::resolve_verification`]; the DCAP - /// verification itself has already happened in the verifier contract. + /// result. pub(crate) fn finish_dstack_verify( &mut self, node_id: NodeId, dstack: &DstackAttestation, report: &VerifiedReport, tee_upgrade_deadline_duration: Duration, - ) -> Result<(ParticipantInsertion, Option), AttestationSubmissionError> { + ) -> Result { let expected_report_data = Self::expected_report_data(&node_id); let accepted_measurements = self.get_accepted_measurements(); let AcceptedAttestation { @@ -214,15 +214,15 @@ impl TeeState { /// Stores an already-verified attestation, rejecting a TLS key owned by a /// different account. /// - /// Returns the insertion result and the displaced [`NodeAttestation`], if any. - /// The Dstack path needs the displaced entry to undo this store via - /// [`Self::revert_dstack_store`], because its callback receipt commits even - /// when the later storage charge fails. + /// 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<(ParticipantInsertion, Option), AttestationSubmissionError> { + ) -> Result { let tls_pk = node_id.tls_public_key.clone(); // Authorization: a TLS key registered to one account must not be @@ -243,28 +243,27 @@ impl TeeState { }, ); - let insertion = match previous { - Some(_) => ParticipantInsertion::UpdatedExistingParticipant, + Ok(match previous { + Some(previous) => ParticipantInsertion::UpdatedExistingParticipant(previous), None => ParticipantInsertion::NewlyInsertedParticipant, - }; - Ok((insertion, previous)) + }) } /// Undoes a [`Self::finish_dstack_verify`] store: restores the displaced - /// previous 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. + /// 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, - previous: Option, + insertion: ParticipantInsertion, ) { - match previous { - Some(previous) => { + match insertion { + ParticipantInsertion::UpdatedExistingParticipant(previous) => { self.stored_attestations .insert(tls_public_key.clone(), previous); } - None => { + ParticipantInsertion::NewlyInsertedParticipant => { self.stored_attestations.remove(tls_public_key); } } @@ -874,7 +873,7 @@ mod tests { // then assert_matches!( re_insertion_result, - Ok(ParticipantInsertion::UpdatedExistingParticipant) + Ok(ParticipantInsertion::UpdatedExistingParticipant(_)) ); } @@ -1491,7 +1490,10 @@ mod tests { ); // Then: the update is accepted and the stored entry reflects the new key. - assert_matches!(result, Ok(ParticipantInsertion::UpdatedExistingParticipant)); + assert_matches!( + result, + Ok(ParticipantInsertion::UpdatedExistingParticipant(_)) + ); let stored = tee_state .stored_attestations .get(&rotated_node.tls_public_key) From 6c6184026e9159a93e04d0faf1b673bc835999ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 30 Jun 2026 14:10:27 +0200 Subject: [PATCH 22/26] refactor(contract): address review feedback on TEE attestation tests Tidy the async-attestation test code and docs: - reuse test_tee_verifier::StubResponse instead of a hand-copied mirror - collapse submit_participant_info into submit_participant_info_with_deposit - parametrize the AttestationResult round-trip test with rstest - import near_workspaces/wycheproof types instead of fully-qualified paths - drop obvious comments and trim verbose docs; remove em dashes - regenerate the ABI snapshot for the trimmed doc comments --- Cargo.lock | 1 + Cargo.toml | 1 + crates/contract/Cargo.toml | 1 + crates/contract/src/lib.rs | 18 +++----- crates/contract/src/sandbox_test_methods.rs | 3 -- .../contract/src/tee/pending_attestation.rs | 25 ++++------- crates/contract/src/v3_12_0_state.rs | 1 - crates/contract/tests/sandbox/tee_verifier.rs | 30 ++----------- .../tests/sandbox/utils/contract_build.rs | 2 - .../tests/sandbox/utils/mpc_contract.rs | 44 ++++++++----------- .../snapshots/abi__abi_has_not_changed.snap | 4 +- .../tests/wycheproof_vectors.rs | 8 ++-- .../tests/wycheproof_ecdsa.rs | 11 +++-- 13 files changed, 52 insertions(+), 97 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a708cda7cf..778c0da946 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5791,6 +5791,7 @@ dependencies = [ "sha2 0.10.9", "signature", "tee-verifier-interface", + "test-tee-verifier", "test-utils", "thiserror 2.0.18", "threshold-signatures", diff --git a/Cargo.toml b/Cargo.toml index fe31f2bae7..c133e33104 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,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 = { path = "crates/test-tee-verifier" } 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..fa2a41231b 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 = { workspace = true } test-utils = { workspace = true } threshold-signatures = { workspace = true } tokio = { workspace = true } diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 510d7665e7..764a3ad2fa 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -927,7 +927,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 — the caller already paid for the larger entry. + // 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); @@ -2506,14 +2506,6 @@ impl MpcContract { PromiseOrValue::Promise(promise.as_return()) } - /// Fails the original [`Self::submit_participant_info`] transaction, from a - /// receipt separate from the cleanup so the cleanup is not rolled back. - /// Reached for every failure outcome, not just timeouts. - #[private] - pub fn fail_attestation_submission(#[serializer(borsh)] reason: String) { - env::panic_str(&reason); - } - /// Yield-resume callback for a single queued CKD request. /// /// On success, returns the confidential key to the original caller. On timeout, @@ -2580,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 @@ -5275,8 +5272,7 @@ mod tests { destination_node_info, ); } - let valid_participant_attestation = - 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); diff --git a/crates/contract/src/sandbox_test_methods.rs b/crates/contract/src/sandbox_test_methods.rs index 76e7616187..63120a02ad 100644 --- a/crates/contract/src/sandbox_test_methods.rs +++ b/crates/contract/src/sandbox_test_methods.rs @@ -49,9 +49,6 @@ impl MpcContract { .expect("queue length must fit in u32 — bounded by MAX_PENDING_REQUEST_FAN_OUT") } - /// Whether an in-flight `Dstack` attestation verification is pending for - /// `account_id`. Lets the async attestation sandbox tests assert that the - /// pending entry was cleaned up after a rejection or yield timeout. pub fn has_pending_attestation(&self, account_id: AccountId) -> bool { self.pending_attestations.contains_key(&account_id) } diff --git a/crates/contract/src/tee/pending_attestation.rs b/crates/contract/src/tee/pending_attestation.rs index 37a16226f3..74bba64098 100644 --- a/crates/contract/src/tee/pending_attestation.rs +++ b/crates/contract/src/tee/pending_attestation.rs @@ -4,9 +4,6 @@ //! 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 [`AccountId`], until the yield resolves. -//! -//! [`Attestation::Dstack`]: mpc_attestation::attestation::Attestation::Dstack -//! [`AccountId`]: near_sdk::AccountId use borsh::{BorshDeserialize, BorshSerialize}; use mpc_attestation::attestation::DstackAttestation; @@ -32,10 +29,6 @@ pub struct PendingAttestation { pub data_id: CryptoHash, } -/// Outcome the resolution callback resumes the yield with. -/// -/// JSON-serialized on the wire, matching the contract's other yield-resume -/// callbacks (sign, CKD, foreign-tx). #[near(serializers = [json])] #[derive(Debug, Clone, PartialEq, Eq)] pub enum AttestationResult { @@ -47,16 +40,14 @@ pub enum AttestationResult { #[expect(non_snake_case)] mod tests { use super::*; + use rstest::rstest; - #[test] - fn attestation_result__should_round_trip_json() { - for original in [ - AttestationResult::Ok, - AttestationResult::Err("rejected".to_string()), - ] { - let bytes = serde_json::to_vec(&original).expect("serialize"); - let decoded: AttestationResult = serde_json::from_slice(&bytes).expect("deserialize"); - assert_eq!(original, decoded); - } + #[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/v3_12_0_state.rs b/crates/contract/src/v3_12_0_state.rs index f514c80238..48bccc24a2 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(), - // Nothing is in-flight across an upgrade: start with an empty map. pending_attestations: LookupMap::new(StorageKey::PendingAttestations), } } diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 58a006617e..d582e8caf4 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -9,15 +9,6 @@ //! - 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. -//! -//! The `Verified` + post-DCAP-pass path (attestation stored) additionally needs -//! a stub report matching the fixture's post-DCAP expectations; it is a planned -//! follow-up once the off-chain report helper is wired into the sandbox harness. -//! The post-DCAP logic itself is unit-tested in `mpc-attestation`. -//! -//! They require the cross-contract runtime, so they live in sandbox rather than -//! the in-process tests. The WASM build needs the contract toolchain; these run -//! in CI. #![allow(non_snake_case)] use crate::sandbox::{ @@ -32,30 +23,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::StubResponse; 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. -#[expect(clippy::large_enum_variant)] -#[derive(BorshSerialize)] -enum StubResponse { - // The Verified branch is exercised by the (deferred) post-DCAP-pass test; it - // is part of the stub's wire contract, so keep the variant even though no - // current test constructs it. - #[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). @@ -197,7 +173,7 @@ async fn submit_participant_info__should_clean_up_on_verifier_crash() -> Result< 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. + // does not surface on the original `transact()`, so we assert state instead. let _ = submit_participant_info_with_deposit( submitter, &contract, @@ -208,7 +184,7 @@ async fn submit_participant_info__should_clean_up_on_verifier_crash() -> Result< .await?; worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await?; - // Then: nothing is stored, and the timeout cleanup actually committed — the + // 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.) diff --git a/crates/contract/tests/sandbox/utils/contract_build.rs b/crates/contract/tests/sandbox/utils/contract_build.rs index 6107478230..1f9361694e 100644 --- a/crates/contract/tests/sandbox/utils/contract_build.rs +++ b/crates/contract/tests/sandbox/utils/contract_build.rs @@ -57,8 +57,6 @@ pub fn parallel_contract() -> &'static [u8] { PARALLEL_CONTRACT.get_or_init(|| ContractBuilder::new(PARALLEL_CONTRACT_MANIFEST).build()) } -/// Returns the `test-tee-verifier` stub WASM, used by the async attestation -/// sandbox tests as a stand-in for the real `tee-verifier` contract. 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 1fcace8a4c..11f58f9fc6 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -2,12 +2,14 @@ use std::collections::BTreeSet; 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 @@ -40,31 +42,28 @@ pub async fn get_tee_accounts(contract: &Contract) -> anyhow::Result anyhow::Result { - let result = account - .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) - .args_json((attestation, tls_key)) - .max_gas() - .transact() - .await?; - dbg!(&result); - Ok(result) + submit_participant_info_with_deposit( + account, + contract, + attestation, + tls_key, + NearToken::from_near(0), + ) + .await } -/// Like [`submit_participant_info`] but attaches `deposit` — used by the async -/// `Dstack` tests that assert the deposit is refunded on rejection/timeout. pub async fn submit_participant_info_with_deposit( account: &Account, contract: &Contract, attestation: &Attestation, tls_key: &Ed25519PublicKey, - deposit: near_workspaces::types::NearToken, + deposit: NearToken, ) -> anyhow::Result { Ok(account .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) @@ -75,11 +74,9 @@ pub async fn submit_participant_info_with_deposit( .await?) } -/// Reads the `sandbox-test-methods`-only `has_pending_attestation` view. The -/// contract under test must be built with that feature (`with_sandbox_test_methods`). pub async fn has_pending_attestation( contract: &Contract, - account_id: &near_workspaces::AccountId, + account_id: &AccountId, ) -> anyhow::Result { Ok(contract .view("has_pending_attestation") @@ -91,13 +88,10 @@ pub async fn has_pending_attestation( pub async fn vote_tee_verifier_change( account: &Account, contract: &Contract, - candidate_account_id: &near_workspaces::AccountId, + candidate_account_id: &AccountId, expected_code_hash: [u8; 32], ) -> anyhow::Result<()> { - // `expected_code_hash` is a `TeeVerifierCodeHash`, which the contract - // deserializes from a hex string (not a byte array), so wrap it in the typed - // hash to get the right JSON form. - let expected_code_hash = mpc_primitives::hash::TeeVerifierCodeHash::new(expected_code_hash); + let expected_code_hash = TeeVerifierCodeHash::new(expected_code_hash); all_receipts_successful( account .call(contract.id(), method_names::VOTE_TEE_VERIFIER_CHANGE) 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 62b66fb28f..f69e2354cd 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -560,7 +560,6 @@ expression: abi }, { "name": "fail_attestation_submission", - "doc": " Fails the original [`Self::submit_participant_info`] transaction, from a\n receipt separate from the cleanup so the cleanup is not rolled back.\n Reached for every failure outcome, not just timeouts.", "kind": "view", "modifiers": [ "private" @@ -2080,7 +2079,7 @@ expression: abi }, { "name": "submit_participant_info", - "doc": " (Prospective) participants submit their TEE attestation through this endpoint.\n\n [`Attestation::Mock`] is verified synchronously in this call.\n [`Attestation::Dstack`] is verified asynchronously: the call yields on a\n cross-contract verify-quote call and resolves only once the verifier\n responds (or the yield times out). It therefore needs a verifier\n configured (else [`TeeError::VerifierNotConfigured`]) and rejects a\n concurrent submission from the same account\n ([`TeeError::VerificationAlreadyPending`]). The attached deposit covers\n 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, 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" @@ -2987,7 +2986,6 @@ expression: abi ] }, "AttestationResult": { - "description": "Outcome the resolution callback resumes the yield with.\n\nJSON-serialized on the wire, matching the contract's other yield-resume callbacks (sign, CKD, foreign-tx).", "oneOf": [ { "type": "string", diff --git a/crates/near-mpc-signature-verifier/tests/wycheproof_vectors.rs b/crates/near-mpc-signature-verifier/tests/wycheproof_vectors.rs index cedfb9a0df..71556e3f38 100644 --- a/crates/near-mpc-signature-verifier/tests/wycheproof_vectors.rs +++ b/crates/near-mpc-signature-verifier/tests/wycheproof_vectors.rs @@ -18,12 +18,12 @@ use near_mpc_contract_interface::types::{ }; use near_mpc_signature_verifier::{verify_ecdsa_signature, verify_eddsa_signature}; use sha2::{Digest, Sha256}; -use wycheproof::TestResult; +use wycheproof::{TestResult, ecdsa, eddsa}; #[test] fn verify_eddsa_signature__should_match_all_wycheproof_ed25519_vectors() { // Given - let test_set = wycheproof::eddsa::TestSet::load(wycheproof::eddsa::TestName::Ed25519) + let test_set = eddsa::TestSet::load(eddsa::TestName::Ed25519) .expect("wycheproof ed25519 vectors should load"); // When / Then @@ -156,8 +156,8 @@ fn verify_ecdsa_signature__should_reject_high_s_but_accept_after_normalization() assert!(checked > 0, "no high-S valid vectors were exercised"); } -fn load_ecdsa_secp256k1_sha256() -> wycheproof::ecdsa::TestSet { - wycheproof::ecdsa::TestSet::load(wycheproof::ecdsa::TestName::EcdsaSecp256k1Sha256) +fn load_ecdsa_secp256k1_sha256() -> ecdsa::TestSet { + ecdsa::TestSet::load(ecdsa::TestName::EcdsaSecp256k1Sha256) .expect("wycheproof secp256k1/sha256 vectors should load") } diff --git a/crates/threshold-signatures/tests/wycheproof_ecdsa.rs b/crates/threshold-signatures/tests/wycheproof_ecdsa.rs index ec9f34ff02..e10a27b956 100644 --- a/crates/threshold-signatures/tests/wycheproof_ecdsa.rs +++ b/crates/threshold-signatures/tests/wycheproof_ecdsa.rs @@ -4,7 +4,7 @@ //! //! `verify` operates on the full `R` point, but Wycheproof signatures only carry //! the scalar `r`. We reconstruct a point whose x-coordinate is `r` (its -//! y-coordinate is irrelevant — `verify` only reads `x_coordinate(big_r)`), then +//! y-coordinate is irrelevant, since `verify` only reads `x_coordinate(big_r)`), then //! map results, mirroring the malleability policy `verify` enforces (rejects //! high-S, `r = 0`, `s = 0`): //! * `Invalid` -> rejected, @@ -22,7 +22,10 @@ use k256::ecdsa::Signature as K256EcdsaSignature; use k256::{AffinePoint, EncodedPoint, PublicKey, Secp256k1}; use sha2::{Digest, Sha256}; use threshold_signatures::ecdsa::{Scalar, Signature}; -use wycheproof::TestResult; +use wycheproof::{ + TestResult, + ecdsa::{TestName, TestSet}, +}; #[test] fn signature_verify__should_reject_all_wycheproof_invalid_vectors() { @@ -134,8 +137,8 @@ fn signature_verify__should_reject_high_s_but_accept_after_normalization() { assert!(checked > 0, "no high-S valid vectors were exercised"); } -fn load() -> wycheproof::ecdsa::TestSet { - wycheproof::ecdsa::TestSet::load(wycheproof::ecdsa::TestName::EcdsaSecp256k1Sha256) +fn load() -> TestSet { + TestSet::load(TestName::EcdsaSecp256k1Sha256) .expect("wycheproof secp256k1/sha256 vectors should load") } From ad7d6d4ec59d63a42e7818f918c5c68467367884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 30 Jun 2026 14:54:22 +0200 Subject: [PATCH 23/26] fix(contract): restore wire-only StubResponse mirror and fix doc links Depending on the test-tee-verifier #[near] contract crate as a Rust dev-dependency broke 'cargo test --all-features': StubResponse's abi-gated BorshSchema bound went unsatisfied and the two contracts' __near_abi_contract_source_metadata symbols collided at link time. Revert to the local wire-only mirror (the stub stays a WASM artifact) and drop the dev-dependency. Also turn the dangling intra-doc links in pending_attestation.rs (left without their reference definitions after the module doc was shortened) into prose, fixing the broken-intra-doc-links doc check. --- Cargo.lock | 1 - Cargo.toml | 1 - crates/contract/Cargo.toml | 1 - crates/contract/src/tee/pending_attestation.rs | 10 +++++----- crates/contract/tests/sandbox/tee_verifier.rs | 15 ++++++++++++++- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d04c4623f3..8bb805b617 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5809,7 +5809,6 @@ dependencies = [ "sha2 0.10.9", "signature", "tee-verifier-interface", - "test-tee-verifier", "test-utils", "thiserror 2.0.18", "threshold-signatures", diff --git a/Cargo.toml b/Cargo.toml index ba9d759cd6..2afdb767b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,7 +74,6 @@ 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 = { path = "crates/test-tee-verifier" } 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 fa2a41231b..e9d8f991a6 100644 --- a/crates/contract/Cargo.toml +++ b/crates/contract/Cargo.toml @@ -139,7 +139,6 @@ rand_core = { workspace = true } rstest = { workspace = true } sha2 = { workspace = true } signature = { workspace = true } -test-tee-verifier = { workspace = true } test-utils = { workspace = true } threshold-signatures = { workspace = true } tokio = { workspace = true } diff --git a/crates/contract/src/tee/pending_attestation.rs b/crates/contract/src/tee/pending_attestation.rs index 74bba64098..ce9fc5cc57 100644 --- a/crates/contract/src/tee/pending_attestation.rs +++ b/crates/contract/src/tee/pending_attestation.rs @@ -1,9 +1,9 @@ -//! State for an in-flight [`Attestation::Dstack`] submission. +//! State for an in-flight [`DstackAttestation`] submission. //! -//! A [`Attestation::Dstack`] 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 [`AccountId`], until the yield resolves. +//! 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; diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index d582e8caf4..511fd0a23d 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -23,15 +23,28 @@ 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::StubResponse; 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). From e6ea55b89723e38ffb860d85594ba161a1237e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 30 Jun 2026 16:50:34 +0200 Subject: [PATCH 24/26] test(contract): expect typed TlsKeyOwnedByOtherAccount error The overwrite-rejection test asserted the pre-refactor InvalidTeeRemoteAttestation error; this branch surfaces that rejection as the dedicated AttestationSubmissionError::TlsKeyOwnedByOtherAccount variant. Match the typed error and drop the now-unused InvalidParameters import. --- .../contract/tests/inprocess/attestation_submission.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 From 7f656c408006c7b0ba2dee400324935dabe01d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 30 Jun 2026 16:58:08 +0200 Subject: [PATCH 25/26] revert: undo unrelated import/em-dash churn in wycheproof tests These two test files come from a separately-merged PR, not this branch. They were edited during a branch-wide comment/import cleanup before main was merged, so the changes were out of scope. Restore them to match main. --- .../tests/wycheproof_vectors.rs | 8 ++++---- crates/threshold-signatures/tests/wycheproof_ecdsa.rs | 11 ++++------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/crates/near-mpc-signature-verifier/tests/wycheproof_vectors.rs b/crates/near-mpc-signature-verifier/tests/wycheproof_vectors.rs index 71556e3f38..cedfb9a0df 100644 --- a/crates/near-mpc-signature-verifier/tests/wycheproof_vectors.rs +++ b/crates/near-mpc-signature-verifier/tests/wycheproof_vectors.rs @@ -18,12 +18,12 @@ use near_mpc_contract_interface::types::{ }; use near_mpc_signature_verifier::{verify_ecdsa_signature, verify_eddsa_signature}; use sha2::{Digest, Sha256}; -use wycheproof::{TestResult, ecdsa, eddsa}; +use wycheproof::TestResult; #[test] fn verify_eddsa_signature__should_match_all_wycheproof_ed25519_vectors() { // Given - let test_set = eddsa::TestSet::load(eddsa::TestName::Ed25519) + let test_set = wycheproof::eddsa::TestSet::load(wycheproof::eddsa::TestName::Ed25519) .expect("wycheproof ed25519 vectors should load"); // When / Then @@ -156,8 +156,8 @@ fn verify_ecdsa_signature__should_reject_high_s_but_accept_after_normalization() assert!(checked > 0, "no high-S valid vectors were exercised"); } -fn load_ecdsa_secp256k1_sha256() -> ecdsa::TestSet { - ecdsa::TestSet::load(ecdsa::TestName::EcdsaSecp256k1Sha256) +fn load_ecdsa_secp256k1_sha256() -> wycheproof::ecdsa::TestSet { + wycheproof::ecdsa::TestSet::load(wycheproof::ecdsa::TestName::EcdsaSecp256k1Sha256) .expect("wycheproof secp256k1/sha256 vectors should load") } diff --git a/crates/threshold-signatures/tests/wycheproof_ecdsa.rs b/crates/threshold-signatures/tests/wycheproof_ecdsa.rs index e10a27b956..ec9f34ff02 100644 --- a/crates/threshold-signatures/tests/wycheproof_ecdsa.rs +++ b/crates/threshold-signatures/tests/wycheproof_ecdsa.rs @@ -4,7 +4,7 @@ //! //! `verify` operates on the full `R` point, but Wycheproof signatures only carry //! the scalar `r`. We reconstruct a point whose x-coordinate is `r` (its -//! y-coordinate is irrelevant, since `verify` only reads `x_coordinate(big_r)`), then +//! y-coordinate is irrelevant — `verify` only reads `x_coordinate(big_r)`), then //! map results, mirroring the malleability policy `verify` enforces (rejects //! high-S, `r = 0`, `s = 0`): //! * `Invalid` -> rejected, @@ -22,10 +22,7 @@ use k256::ecdsa::Signature as K256EcdsaSignature; use k256::{AffinePoint, EncodedPoint, PublicKey, Secp256k1}; use sha2::{Digest, Sha256}; use threshold_signatures::ecdsa::{Scalar, Signature}; -use wycheproof::{ - TestResult, - ecdsa::{TestName, TestSet}, -}; +use wycheproof::TestResult; #[test] fn signature_verify__should_reject_all_wycheproof_invalid_vectors() { @@ -137,8 +134,8 @@ fn signature_verify__should_reject_high_s_but_accept_after_normalization() { assert!(checked > 0, "no high-S valid vectors were exercised"); } -fn load() -> TestSet { - TestSet::load(TestName::EcdsaSecp256k1Sha256) +fn load() -> wycheproof::ecdsa::TestSet { + wycheproof::ecdsa::TestSet::load(wycheproof::ecdsa::TestName::EcdsaSecp256k1Sha256) .expect("wycheproof secp256k1/sha256 vectors should load") } From 6defe3b6e97f3089530e6ea19660267095a1a352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 30 Jun 2026 17:24:48 +0200 Subject: [PATCH 26/26] feat(contract): give fail_attestation_submission its own gas config The attestation fail-promise borrowed fail_on_timeout_tera_gas, a config field named for the sign/CKD/foreign-tx timeout path. Add a dedicated fail_attestation_submission_tera_gas (default 2 Tgas, the same trivial panic cost) so the budget matches the method it funds and tuning one no longer silently affects the other. Threaded through the interface DTO, Config, dto_mapping, defaults, and test configs; ABI and borsh-schema snapshots regenerated. --- crates/contract/src/config.rs | 5 +++++ crates/contract/src/dto_mapping.rs | 5 +++++ crates/contract/src/lib.rs | 2 +- ...contract_borsh_schema_has_not_changed.snap | 4 ++++ .../tests/sandbox/contract_configuration.rs | 1 + .../sandbox/upgrade_from_current_contract.rs | 1 + .../snapshots/abi__abi_has_not_changed.snap | 20 +++++++++++++++++++ .../src/types/config.rs | 6 ++++++ crates/test-utils/src/contract_types.rs | 1 + 9 files changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/contract/src/config.rs b/crates/contract/src/config.rs index 9b296fdec5..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. @@ -65,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. @@ -100,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: diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index ab76239574..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; } @@ -519,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 @@ -550,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 diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 764a3ad2fa..9e927a54c8 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -2501,7 +2501,7 @@ impl MpcContract { 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_on_timeout_tera_gas), + Gas::from_tgas(self.config.fail_attestation_submission_tera_gas), ); PromiseOrValue::Promise(promise.as_return()) } 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 888cb27fb8..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", diff --git a/crates/contract/tests/sandbox/contract_configuration.rs b/crates/contract/tests/sandbox/contract_configuration.rs index 87f754259e..bcf4419223 100644 --- a/crates/contract/tests/sandbox/contract_configuration.rs +++ b/crates/contract/tests/sandbox/contract_configuration.rs @@ -97,6 +97,7 @@ 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), diff --git a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs index 06f3282599..eef1390cc2 100644 --- a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs +++ b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs @@ -113,6 +113,7 @@ 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, 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 f69e2354cd..7f626962d0 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -1050,6 +1050,10 @@ expression: abi "fail_on_timeout_tera_gas", "u64" ], + [ + "fail_attestation_submission_tera_gas", + "u64" + ], [ "clean_tee_status_tera_gas", "u64" @@ -3279,6 +3283,7 @@ 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", @@ -3328,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", @@ -3960,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": [ diff --git a/crates/near-mpc-contract-interface/src/types/config.rs b/crates/near-mpc-contract-interface/src/types/config.rs index 584d4f9932..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. @@ -93,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. @@ -129,6 +133,7 @@ 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), @@ -182,6 +187,7 @@ 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, diff --git a/crates/test-utils/src/contract_types.rs b/crates/test-utils/src/contract_types.rs index ab744af85b..9b64d77631 100644 --- a/crates/test-utils/src/contract_types.rs +++ b/crates/test-utils/src/contract_types.rs @@ -18,5 +18,6 @@ pub fn dummy_config(value: u64) -> near_mpc_contract_interface::types::Config { 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, } }