From 87c77a95ee6901adbdf7de0baeb5a573fd5aa052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 30 Jun 2026 18:59:49 +0200 Subject: [PATCH 01/26] test(contract): sandbox coverage + stub verifier for async attestation Adds the test-tee-verifier stub contract (a wire-compatible verify_quote that returns a test-chosen response instead of running dcap-qvl) and sandbox tests exercising the async submit_participant_info branches: verifier-not-configured, Rejected, and no-verdict (yield timeout). Also adds the has_pending_attestation sandbox view and the design doc for the verifier-contract flow. Builds on the async-verification feature PR. --- Cargo.lock | 10 + Cargo.toml | 1 + crates/contract/src/sandbox_test_methods.rs | 6 +- crates/contract/tests/sandbox/mod.rs | 1 + crates/contract/tests/sandbox/tee_verifier.rs | 228 ++++++++++++++++++ .../tests/sandbox/utils/contract_build.rs | 7 + .../tests/sandbox/utils/mpc_contract.rs | 68 +++++- crates/test-tee-verifier/Cargo.toml | 31 +++ crates/test-tee-verifier/src/lib.rs | 82 +++++++ docs/design/attestation-verifier-contract.md | 111 +++++---- docs/localnet/tee-localnet.md | 2 +- ...nning-an-mpc-node-in-tdx-external-guide.md | 2 +- 12 files changed, 488 insertions(+), 61 deletions(-) create mode 100644 crates/contract/tests/sandbox/tee_verifier.rs create mode 100644 crates/test-tee-verifier/Cargo.toml create mode 100644 crates/test-tee-verifier/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 2c3058a06d..e7380a0033 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11376,6 +11376,16 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "test-tee-verifier" +version = "3.13.0" +dependencies = [ + "borsh", + "getrandom 0.2.17", + "near-sdk", + "tee-verifier-interface", +] + [[package]] name = "test-utils" version = "3.13.0" diff --git a/Cargo.toml b/Cargo.toml index 11d47cf3df..f03fbec84b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ members = [ "crates/test-migration-contract", "crates/test-parallel-contract", "crates/test-port-allocator", + "crates/test-tee-verifier", "crates/test-utils", "crates/threshold-signatures", "crates/tls", diff --git a/crates/contract/src/sandbox_test_methods.rs b/crates/contract/src/sandbox_test_methods.rs index 28997cd7af..63120a02ad 100644 --- a/crates/contract/src/sandbox_test_methods.rs +++ b/crates/contract/src/sandbox_test_methods.rs @@ -11,7 +11,7 @@ use crate::MpcContract; use crate::primitives::ckd::CKDRequest; use crate::primitives::signature::SignatureRequest; -use near_sdk::near; +use near_sdk::{AccountId, near}; // Import the generated extension trait from near use crate::MpcContractExt; @@ -48,4 +48,8 @@ impl MpcContract { u32::try_from(len) .expect("queue length must fit in u32 — bounded by MAX_PENDING_REQUEST_FAN_OUT") } + + pub fn has_pending_attestation(&self, account_id: AccountId) -> bool { + self.pending_attestations.contains_key(&account_id) + } } diff --git a/crates/contract/tests/sandbox/mod.rs b/crates/contract/tests/sandbox/mod.rs index c4e4cbaf3d..e0f25b397a 100644 --- a/crates/contract/tests/sandbox/mod.rs +++ b/crates/contract/tests/sandbox/mod.rs @@ -6,6 +6,7 @@ pub mod participants_gas; pub mod sign; pub mod tee; pub mod tee_cleanup_after_resharing; +pub mod tee_verifier; pub mod update_votes_cleanup_after_resharing; pub mod upgrade_from_current_contract; pub mod upgrade_to_current_contract; diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs new file mode 100644 index 0000000000..511fd0a23d --- /dev/null +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -0,0 +1,228 @@ +//! Sandbox tests for the async `submit_participant_info` flow that offloads DCAP +//! verification to a separate `tee-verifier` contract. +//! +//! These deploy the `test-tee-verifier` stub (which returns a test-chosen +//! `verify_quote` answer instead of running real `dcap-qvl`) and point +//! `mpc-contract` at it via `vote_tee_verifier_change`, then exercise each +//! resolution branch of the yield-resume flow: +//! +//! - verifier not configured → submission rejected, nothing stored. +//! - `Rejected` → submission fails, deposit refunded, no stored attestation. +//! - no-verdict (stub panics) → the ~200-block yield timeout cleans up. +#![allow(non_snake_case)] + +use crate::sandbox::{ + common::SandboxTestSetup, + utils::{ + consts::ALL_PROTOCOLS, + contract_build::stub_tee_verifier_contract, + mpc_contract::{ + get_participant_attestation, has_pending_attestation, submit_participant_info, + submit_participant_info_with_deposit, vote_tee_verifier_change, + }, + }, +}; +use anyhow::Result; +use borsh::BorshSerialize; +use near_mpc_contract_interface::types::{self as dtos, Attestation}; +use near_workspaces::{Account, Contract, Worker, network::Sandbox, types::NearToken}; +use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key}; + +/// Blocks to fast-forward past the ~200-block yield-resume timeout so the +/// runtime fires `on_attestation_verified`'s timeout branch. +const YIELD_TIMEOUT_BLOCKS: u64 = 250; + +/// Mirror of `test_tee_verifier::StubResponse`. Re-declared here (rather than +/// depending on the stub crate) so the test only needs its Borsh encoding to +/// initialize the deployed stub; the stub is a separate `#[near]` contract and +/// linking its crate into this test binary would collide on ABI symbols. +#[expect(clippy::large_enum_variant)] +#[derive(BorshSerialize)] +enum StubResponse { + #[expect(dead_code)] + Verified(tee_verifier_interface::VerifiedReport), + Rejected(String), + Panic, +} + +/// Deploys the stub verifier with the given response, initializes it, and votes +/// it in as `mpc-contract`'s trusted verifier (all participants vote so the +/// change crosses threshold). +async fn deploy_and_trust_stub( + worker: &Worker, + contract: &Contract, + participants: &[Account], + response: StubResponse, +) -> Result { + let stub = worker.dev_deploy(stub_tee_verifier_contract()).await?; + stub.call("new") + .args_borsh(response) + .transact() + .await? + .into_result()?; + + // The contract only consumes `candidate_account_id`; the hash is a voter + // commitment, so any agreed value works for the test. + let expected_code_hash = [7u8; 32]; + for account in participants { + vote_tee_verifier_change(account, contract, stub.id(), expected_code_hash).await?; + } + Ok(stub) +} + +fn dstack_attestation() -> Attestation { + mock_dto_dstack_attestation() +} + +fn tls_key() -> dtos::Ed25519PublicKey { + p2p_tls_key().into() +} + +#[tokio::test] +async fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() -> Result<()> +{ + // Given: a running contract with no verifier voted in. + let SandboxTestSetup { + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .build() + .await; + + // When: a participant submits a Dstack attestation. + let result = submit_participant_info( + &mpc_signer_accounts[0], + &contract, + &dstack_attestation(), + &tls_key(), + ) + .await?; + + // Then: it is rejected (no verifier configured) and nothing is stored. + assert!( + result.is_failure(), + "Dstack submit must fail when no verifier is configured: {result:#?}" + ); + let stored = get_participant_attestation(&contract, &tls_key()).await?; + assert!(stored.is_none(), "no attestation should be stored"); + Ok(()) +} + +#[tokio::test] +async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_rejection() +-> Result<()> { + // Given: a contract whose trusted verifier always rejects. + let SandboxTestSetup { + worker, + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .with_sandbox_test_methods() + .build() + .await; + deploy_and_trust_stub( + &worker, + &contract, + &mpc_signer_accounts, + StubResponse::Rejected("test rejection".to_string()), + ) + .await?; + + // When: a participant submits a Dstack attestation with a 1 NEAR deposit. + let submitter = &mpc_signer_accounts[0]; + let balance_before = submitter.view_account().await?.balance; + let _ = submit_participant_info_with_deposit( + submitter, + &contract, + &dstack_attestation(), + &tls_key(), + NearToken::from_near(1), + ) + .await?; + + // Then: nothing is stored, the pending entry is cleaned up, and the deposit + // is refunded. The rejection resolves in the verifier's response receipt (a + // later receipt than the original call), so the outcome is observable in + // state rather than on the original transaction's result. + let stored = get_participant_attestation(&contract, &tls_key()).await?; + assert!(stored.is_none(), "a rejected quote must not be stored"); + assert!( + !has_pending_attestation(&contract, submitter.id()).await?, + "the pending entry must be cleaned up on rejection" + ); + assert_deposit_refunded(submitter, balance_before).await?; + Ok(()) +} + +#[tokio::test] +async fn submit_participant_info__should_clean_up_on_verifier_crash() -> Result<()> { + // Given: a contract whose trusted verifier panics (no verdict). + let SandboxTestSetup { + worker, + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .with_sandbox_test_methods() + .build() + .await; + deploy_and_trust_stub( + &worker, + &contract, + &mpc_signer_accounts, + StubResponse::Panic, + ) + .await?; + + // When: a participant submits, the verifier crashes (no resume lands), and + // the chain advances past the ~200-block yield timeout so the runtime fires + // `on_attestation_verified`'s timeout branch. + let submitter = &mpc_signer_accounts[0]; + let balance_before = submitter.view_account().await?.balance; + // Unlike the rejection test, the outer-tx result isn't asserted here: the + // failure only resolves when the yield times out, which `near-workspaces` + // does not surface on the original `transact()`, so we assert state instead. + let _ = submit_participant_info_with_deposit( + submitter, + &contract, + &dstack_attestation(), + &tls_key(), + NearToken::from_near(1), + ) + .await?; + worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await?; + + // Then: nothing is stored, and the timeout cleanup actually committed: the + // pending entry is gone and the deposit refunded. (Guards the regression + // where the cleanup was rolled back by a panic in the same receipt, leaking + // the entry and locking the account out of resubmitting.) + let stored = get_participant_attestation(&contract, &tls_key()).await?; + assert!( + stored.is_none(), + "nothing should be stored when the verifier crashes" + ); + assert!( + !has_pending_attestation(&contract, submitter.id()).await?, + "the pending entry must be cleaned up after the yield timeout" + ); + assert_deposit_refunded(submitter, balance_before).await?; + Ok(()) +} + +/// Asserts the 1 NEAR storage deposit was returned: the net spend since +/// `balance_before` is well under 1 NEAR (only gas), rather than the full +/// deposit being retained by the contract. +async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) -> Result<()> { + let balance_after = account.view_account().await?.balance; + let net_spent = balance_before.saturating_sub(balance_after); + assert!( + net_spent < NearToken::from_near(1), + "deposit should be refunded (net spent {net_spent} should be < 1 NEAR, gas only)" + ); + Ok(()) +} diff --git a/crates/contract/tests/sandbox/utils/contract_build.rs b/crates/contract/tests/sandbox/utils/contract_build.rs index cdaedf6e4d..1f9361694e 100644 --- a/crates/contract/tests/sandbox/utils/contract_build.rs +++ b/crates/contract/tests/sandbox/utils/contract_build.rs @@ -4,6 +4,7 @@ use test_utils::contract_build::ContractBuilder; const MPC_CONTRACT_MANIFEST: &str = "crates/contract/Cargo.toml"; const MIGRATION_CONTRACT_MANIFEST: &str = "crates/test-migration-contract/Cargo.toml"; const PARALLEL_CONTRACT_MANIFEST: &str = "crates/test-parallel-contract/Cargo.toml"; +const STUB_TEE_VERIFIER_MANIFEST: &str = "crates/test-tee-verifier/Cargo.toml"; const MPC_CONTRACT_OUT_DIR: &str = "target/near/contract-noabi"; const MPC_CONTRACT_BENCH_OUT_DIR: &str = "target/near/contract-noabi-bench"; const MPC_CONTRACT_SANDBOX_OUT_DIR: &str = "target/near/contract-noabi-sandbox"; @@ -13,6 +14,7 @@ static CONTRACT_WITH_BENCH_METHODS: OnceLock> = OnceLock::new(); static CONTRACT_WITH_SANDBOX_TEST_METHODS: OnceLock> = OnceLock::new(); static MIGRATION_CONTRACT: OnceLock> = OnceLock::new(); static PARALLEL_CONTRACT: OnceLock> = OnceLock::new(); +static STUB_TEE_VERIFIER_CONTRACT: OnceLock> = OnceLock::new(); /// Returns the current contract WASM without benchmark utilities. /// Use this for most sandbox tests. @@ -54,3 +56,8 @@ pub fn migration_contract() -> &'static [u8] { pub fn parallel_contract() -> &'static [u8] { PARALLEL_CONTRACT.get_or_init(|| ContractBuilder::new(PARALLEL_CONTRACT_MANIFEST).build()) } + +pub fn stub_tee_verifier_contract() -> &'static [u8] { + STUB_TEE_VERIFIER_CONTRACT + .get_or_init(|| ContractBuilder::new(STUB_TEE_VERIFIER_MANIFEST).build()) +} diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index 8e5aa06c80..ef6f5e484e 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -3,12 +3,14 @@ use std::collections::BTreeSet; use super::consts::SUBMIT_PARTICIPANT_INFO_DEPOSIT; use super::transactions::all_receipts_successful; use mpc_contract::tee::tee_state::NodeId; -use mpc_primitives::hash::{LauncherImageHash, NodeImageHash}; -use near_mpc_contract_interface::method_names; -use near_mpc_contract_interface::types::{ - Attestation, Ed25519PublicKey, Participants, ProtocolContractState, Threshold, +use mpc_primitives::hash::{LauncherImageHash, NodeImageHash, TeeVerifierCodeHash}; +use near_mpc_contract_interface::{ + method_names, + types::{Attestation, Ed25519PublicKey, Participants, ProtocolContractState, Threshold}, +}; +use near_workspaces::{ + Account, AccountId, Contract, result::ExecutionFinalResult, types::NearToken, }; -use near_workspaces::{Account, Contract, result::ExecutionFinalResult}; pub async fn get_state(contract: &Contract) -> ProtocolContractState { contract @@ -41,22 +43,66 @@ pub async fn get_tee_accounts(contract: &Contract) -> anyhow::Result anyhow::Result { - let result = account + submit_participant_info_with_deposit( + account, + contract, + attestation, + tls_key, + SUBMIT_PARTICIPANT_INFO_DEPOSIT, + ) + .await +} + +pub async fn submit_participant_info_with_deposit( + account: &Account, + contract: &Contract, + attestation: &Attestation, + tls_key: &Ed25519PublicKey, + deposit: NearToken, +) -> anyhow::Result { + Ok(account .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) .args_json((attestation, tls_key)) - .deposit(SUBMIT_PARTICIPANT_INFO_DEPOSIT) + .deposit(deposit) .max_gas() .transact() - .await?; - dbg!(&result); - Ok(result) + .await?) +} + +pub async fn has_pending_attestation( + contract: &Contract, + account_id: &AccountId, +) -> anyhow::Result { + Ok(contract + .view("has_pending_attestation") + .args_json(serde_json::json!({ "account_id": account_id })) + .await? + .json()?) +} + +pub async fn vote_tee_verifier_change( + account: &Account, + contract: &Contract, + candidate_account_id: &AccountId, + expected_code_hash: [u8; 32], +) -> anyhow::Result<()> { + let expected_code_hash = TeeVerifierCodeHash::new(expected_code_hash); + all_receipts_successful( + account + .call(contract.id(), method_names::VOTE_TEE_VERIFIER_CHANGE) + .args_json(serde_json::json!({ + "candidate_account_id": candidate_account_id, + "expected_code_hash": expected_code_hash, + })) + .transact() + .await?, + ) } pub async fn get_participant_attestation( diff --git a/crates/test-tee-verifier/Cargo.toml b/crates/test-tee-verifier/Cargo.toml new file mode 100644 index 0000000000..f9612d5569 --- /dev/null +++ b/crates/test-tee-verifier/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "test-tee-verifier" +version = { workspace = true } +license = { workspace = true } +edition = { workspace = true } + +# A test-only stub of the `tee-verifier` contract: `verify_quote` returns a +# response the test chose at init, instead of running real `dcap-qvl`. Lets the +# `mpc-contract` sandbox tests drive every branch of the async attestation flow +# (Verified / Rejected / post-DCAP failure / no-verdict) deterministically. +# Speaks the same `tee-verifier-interface` Borsh DTOs as the real verifier, so +# `mpc-contract` cannot tell them apart. + +[lib] +crate-type = ["cdylib", "lib"] + +[features] +# Enabled by `cargo near build` / `--all-features` for ABI generation, mirroring +# the real `tee-verifier`: pulls in the borsh schema for the wire DTOs. +abi = ["borsh/unstable__schema", "tee-verifier-interface/borsh-schema"] + +[dependencies] +borsh = { workspace = true } +near-sdk = { workspace = true } +tee-verifier-interface = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { workspace = true, features = ["custom"] } + +[lints] +workspace = true diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs new file mode 100644 index 0000000000..bd5168f1ef --- /dev/null +++ b/crates/test-tee-verifier/src/lib.rs @@ -0,0 +1,82 @@ +//! Test-only stub of the `tee-verifier` contract. +//! +//! `verify_quote` ignores its inputs and returns a response fixed at init time, +//! instead of running real `dcap_qvl::verify`. This lets `mpc-contract` sandbox +//! tests drive every branch of the async attestation flow deterministically: +//! a `Verified` report (which the test supplies so it matches the fixture's +//! post-DCAP expectations), a `Rejected` verdict, or a panic (the no-verdict / +//! verifier-unreachable path). +//! +//! It speaks the same `tee-verifier-interface` Borsh DTOs and uses the same +//! `#[result_serializer(borsh)]` as the real verifier, so `mpc-contract` cannot +//! tell the two apart. + +use borsh::{BorshDeserialize, BorshSerialize}; +use near_sdk::{env, near}; +use tee_verifier_interface::{Collateral, QuoteBytes, VerificationResult, VerifierError}; + +// Match the real verifier's getrandom handling on wasm so the crate links. +#[cfg(target_arch = "wasm32")] +fn randomness_unsupported(_buf: &mut [u8]) -> Result<(), getrandom::Error> { + Err(getrandom::Error::UNSUPPORTED) +} +#[cfg(target_arch = "wasm32")] +getrandom::register_custom_getrandom!(randomness_unsupported); + +/// What the stub's `verify_quote` should do, chosen by the test at deploy time. +#[expect(clippy::large_enum_variant)] +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +#[cfg_attr( + all(feature = "abi", not(target_arch = "wasm32")), + derive(borsh::BorshSchema) +)] +pub enum StubResponse { + /// Return `VerificationResult::Verified` with this exact report. Tests that + /// want the post-DCAP checks to pass supply the report obtained from the + /// real fixture quote (e.g. via `DstackAttestation::dcap_report`). + Verified(tee_verifier_interface::VerifiedReport), + /// Return `VerificationResult::Rejected` with this reason. + Rejected(String), + /// Panic, simulating an unreachable / crashing verifier (the no-verdict + /// path that `mpc-contract` resolves via the yield timeout). + Panic, +} + +#[derive(Debug)] +#[near(contract_state)] +pub struct TestTeeVerifier { + response: StubResponse, +} + +impl Default for TestTeeVerifier { + fn default() -> Self { + // A contract must be initialized via `new`; default would never be used + // by a test, but `#[near(contract_state)]` requires the bound. + env::panic_str("TestTeeVerifier must be initialized with `new`") + } +} + +#[near] +impl TestTeeVerifier { + #[init] + pub fn new(#[serializer(borsh)] response: StubResponse) -> Self { + Self { response } + } + + /// Stub mirror of `tee_verifier::verify_quote`: ignores `quote`/`collateral` + /// and returns the canned response. Panics on `StubResponse::Panic`. + #[result_serializer(borsh)] + pub fn verify_quote( + &self, + #[serializer(borsh)] _quote: QuoteBytes, + #[serializer(borsh)] _collateral: Collateral, + ) -> VerificationResult { + match &self.response { + StubResponse::Verified(report) => VerificationResult::Verified(report.clone()), + StubResponse::Rejected(reason) => { + VerificationResult::Rejected(VerifierError::DcapVerification(reason.to_string())) + } + StubResponse::Panic => env::panic_str("stub verifier: simulated crash"), + } + } +} diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 0346ce6c69..9f14734746 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -337,12 +337,14 @@ The contract gains two new state fields: pub struct MpcContract { // ... existing fields ... - /// The locked account `mpc-contract` currently trusts as the verifier. - /// `submit_participant_info` calls `verify_quote` on this account. - /// Mutated only by the threshold-crossing vote above; the mutation - /// re-routes future submissions and does not touch already-stored - /// attestations. - tee_verifier_account_id: AccountId, + /// The locked account `mpc-contract` currently trusts as the verifier, or + /// `None` until participants vote one in (a `Dstack` `submit_participant_info` + /// is then rejected with `VerifierNotConfigured`). `submit_participant_info` + /// calls `verify_quote` on this account. Mutated only by the threshold-crossing + /// vote above; the mutation re-routes future submissions and does not touch + /// already-stored attestations. (Making this non-`Option` once a verifier is + /// voted in is the follow-up #3639.) + tee_verifier_account_id: Option, /// Pending votes for changing `tee_verifier_account_id`. Each voter is an /// active MPC participant; each proposal is hashed from @@ -381,7 +383,7 @@ sequenceDiagram ### `mpc-contract::submit_participant_info` -The method splits across three receipts joined by yield-resume — see [§Submission flow](#submission-flow) above for the architecture. The return type is [`PromiseOrValue<()>`](https://docs.rs/near-sdk/5.26.1/near_sdk/enum.PromiseOrValue.html), `near-sdk`'s "sometimes synchronous, sometimes a Promise chain" type: `Mock` attestations return `Value(())` immediately, and `Dstack` attestations return the yielded `Promise` from [`env::promise_yield_create`][promise-yield-create], which the runtime resolves either when `resolve_verification` calls [`env::promise_yield_resume`][promise-yield-resume] or after ~200 blocks of silence. The post-DCAP checks and the `stored_attestations` insert live in `resolve_verification`, not in the yield-callback — that's what keeps `on_attestation_verified` trivial enough to match the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]. Draft implementation: +The method splits across three receipts joined by yield-resume — see [§Submission flow](#submission-flow) above for the architecture. It returns `Result<(), Error>`, like the existing yield producers (`sign` / `request_app_private_key` / `verify_foreign_transaction`): `Mock` attestations are verified synchronously and return `Ok(())`; `Dstack` attestations register a yield via [`env::promise_yield_create`][promise-yield-create] and end on `enqueue_yield_request` so that its `env::promise_return` is the method's result. The runtime resolves the yield either when `resolve_verification` calls [`env::promise_yield_resume`][promise-yield-resume] or after ~200 blocks of silence. The post-DCAP checks and the `stored_attestations` insert live in `resolve_verification`, not in the yield-callback — that's what keeps `on_attestation_verified` trivial enough to match the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]. Draft implementation: ```rust impl MpcContract { @@ -389,15 +391,15 @@ impl MpcContract { &mut self, attestation: Attestation, tls_pk: Ed25519PublicKey, - ) -> PromiseOrValue<()> { + ) -> Result<(), Error> { // Existing convention: caller must be the signer of this transaction, // not a relayer or proxy. let account_id = Self::assert_caller_is_signer(); match attestation { - // Unchanged from today. + // Synchronous: no DCAP, verified and stored in this call. Attestation::Mock(mock) => { - self.verify_mock_synchronously(mock, tls_pk); - PromiseOrValue::Value(()) + self.tee_state.add_mock_participant(node_id, mock, ...)?; + Ok(()) } // Dstack: yield-resume. Attestation::Dstack(dstack) => { @@ -406,18 +408,45 @@ impl MpcContract { // runtime timeout) is rejected outright — same shape as // duplicate sign requests. if self.pending_attestations.contains_key(&account_id) { - env::panic_str("verification already pending"); + return Err(TeeError::VerificationAlreadyPending.into()); } + // Refuse until a verifier is voted in: there is no account to + // call `verify_quote` on. + let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { + return Err(TeeError::VerifierNotConfigured.into()); + }; - let (quote, collateral) = extract_dcap_inputs(&dstack); let attached_deposit = env::attached_deposit(); - // Reuses the existing `enqueue_yield_request` helper that - // wraps `env::promise_yield_create`. The helper allocates - // `data_id`, registers `on_attestation_verified` as the - // yield-callback, and surfaces `data_id` via the `insert` - // closure so we can stash it together with the rest of the - // `PendingAttestation` fields. + // Cross-contract call to the verifier, built first so the + // `enqueue_yield_request` below stays the final host call. Its + // `.then` callback (`resolve_verification`) is the bridge that + // turns the verifier's response into a `promise_yield_resume` on + // the yield this method registers next. Quote/collateral are + // serialized by reference so `dstack` can move into the pending + // entry without cloning the (large) payload. + Promise::new(verifier_account_id) + .function_call( + "verify_quote".into(), + borsh::to_vec(&(&dstack.quote, &dstack.collateral)).unwrap(), + NearToken::from_yoctonear(0), + Gas::from_tgas(VERIFIER_GAS_TGAS), + ) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(Gas::from_tgas(RESOLVE_GAS_TGAS)) + .resolve_verification(node_id.clone()), + ) + .detach(); + + // Reuses the existing `enqueue_yield_request` helper that wraps + // `env::promise_yield_create`. The helper allocates `data_id`, + // registers `on_attestation_verified` as the yield-callback, and + // surfaces `data_id` via the `insert` closure so we can stash it + // together with the rest of the `PendingAttestation` fields. It + // calls `env::promise_return` last, making the yield the method's + // result — so we just return `Ok(())` (no `value_return` that + // would override it). self.enqueue_yield_request( "on_attestation_verified", borsh::to_vec(&account_id).unwrap(), @@ -434,28 +463,7 @@ impl MpcContract { ); }, ); - - // Cross-contract call to the verifier. Its `.then` callback - // (`resolve_verification`) is the bridge that turns the - // verifier's response into a `promise_yield_resume` on the - // yield this method registered above. - Promise::new(self.tee_verifier_account_id.clone()) - .function_call( - "verify_quote".into(), - borsh::to_vec(&(quote, collateral)).unwrap(), - NearToken::from_yoctonear(0), - Gas::from_tgas(VERIFIER_GAS_TGAS), - ) - .then( - Self::ext(env::current_account_id()) - .with_static_gas(Gas::from_tgas(RESOLVE_GAS_TGAS)) - .resolve_verification(account_id), - ); - - // The yield handle was returned by `enqueue_yield_request` - // via `env::promise_return`, so the caller's `Promise` - // resolves with whatever the yield-callback returns. - PromiseOrValue::Value(()) + Ok(()) } } } @@ -562,18 +570,27 @@ impl MpcContract { &mut self, account_id: AccountId, #[callback_result] result: Result, - ) -> Result<(), String> { - match result { - Ok(FinalOutcome::Ok) => Ok(()), - Ok(FinalOutcome::Err(reason)) => Err(reason), + ) -> PromiseOrValue<()> { + let reason = match result { + Ok(FinalOutcome::Ok) => return PromiseOrValue::Value(()), + Ok(FinalOutcome::Err(reason)) => reason, Err(_promise_err) => { if let Some(pending) = self.pending_attestations.remove(&account_id) { refund_deposit(&account_id, pending.attached_deposit); log!("yield timeout for {account_id}: refunded and cleaned up"); } - Err("verifier did not respond within yield-resume window".to_string()) + "verifier did not respond within yield-resume window".to_string() } - } + }; + // Fail the submitter's transaction from a SEPARATE receipt: a panic here + // would roll back the cleanup above. + let promise = Promise::new(env::current_account_id()).function_call( + "fail_attestation_submission".into(), + borsh::to_vec(&reason).unwrap(), + NearToken::from_near(0), + Gas::from_tgas(FAIL_GAS_TGAS), + ); + PromiseOrValue::Promise(promise.as_return()) } } diff --git a/docs/localnet/tee-localnet.md b/docs/localnet/tee-localnet.md index 054a39042f..70ab1573b7 100644 --- a/docs/localnet/tee-localnet.md +++ b/docs/localnet/tee-localnet.md @@ -276,7 +276,7 @@ near transaction view-status network-config mpc-localnet ``` ``` -(ExecutionError("Smart contract panicked: Invalid TEE Remote Attestation.: TeeQuoteStatus is invalid: the allowed mpc image hashes list is empty" +(ExecutionError("Smart contract panicked: Invalid TEE Remote Attestation.: attestation verification failed: the allowed mpc image hashes list is empty" ``` ### Vote Commands diff --git a/docs/running-an-mpc-node-in-tdx-external-guide.md b/docs/running-an-mpc-node-in-tdx-external-guide.md index 3c455fc1f5..e6413e02df 100644 --- a/docs/running-an-mpc-node-in-tdx-external-guide.md +++ b/docs/running-an-mpc-node-in-tdx-external-guide.md @@ -2062,7 +2062,7 @@ The error after `err=` is the NEAR runtime error. Common ones: If the transaction reaches execution and the contract panics, the node logs only the generic retry line above; the actual message lives in the transaction receipt. Find the tx on `https://testnet.nearblocks.io/address/` and open the failed `submit_participant_info` call — the error appears under the action's status / logs. The contract wraps the attestation-side error like this: ``` -Invalid TEE Remote Attestation: TeeQuoteStatus is invalid: +Invalid TEE Remote Attestation: attestation verification failed: the submitted attestation failed verification, reason: Custom("...") ``` From fa4298021cb896260ce871a151b2febdce9a5d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 1 Jul 2026 13:13:42 +0200 Subject: [PATCH 02/26] test(contract): unit-cover VerifierNotConfigured and revert_dstack_store Adds in-process coverage the reviewer asked for on the async attestation flow: - submit_participant_info rejects a Dstack submission with VerifierNotConfigured when no verifier is voted in (fails before the yield) - TeeState::revert_dstack_store restores the displaced entry on an update and removes a newly-inserted one The VerificationAlreadyPending / pending-insert and InsufficientDeposit invariants aren't unit-testable: near_sdk's mock VM does not support promise_yield_create and does not simulate storage_usage() deltas. Those paths are exercised by the sandbox tee_verifier tests. --- crates/contract/src/tee/tee_state.rs | 69 +++++++++++++++++++ .../tests/inprocess/attestation_submission.rs | 22 +++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index c3fb05f0f8..88798dcfaa 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -1382,6 +1382,75 @@ mod tests { assert_eq!(stored.node_id, rotated_node); } + #[test] + fn revert_dstack_store__restores_the_displaced_entry_on_update() { + // Given: `alice` has an attestation, then updates it — the second insertion + // returns the displaced original wrapped in `UpdatedExistingParticipant`. + const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); + let mut tee_state = TeeState::default(); + let tls_public_key = bogus_ed25519_public_key(); + let original_node = NodeId { + account_id: "alice.near".parse().unwrap(), + tls_public_key: tls_public_key.clone(), + account_public_key: bogus_ed25519_public_key(), + }; + tee_state + .verify_and_store_mock( + original_node.clone(), + MockAttestation::Valid, + TEE_UPGRADE_DURATION, + ) + .expect("initial insertion should succeed"); + let updated_node = NodeId { + account_id: "alice.near".parse().unwrap(), + tls_public_key: tls_public_key.clone(), + account_public_key: bogus_ed25519_public_key(), + }; + let insertion = tee_state + .verify_and_store_mock(updated_node, MockAttestation::Valid, TEE_UPGRADE_DURATION) + .expect("update should succeed"); + assert_matches!( + insertion, + ParticipantInsertion::UpdatedExistingParticipant(_) + ); + + // When: the store is reverted. + tee_state.revert_dstack_store(&tls_public_key, insertion); + + // Then: the original (displaced) entry is back in place. + let stored = tee_state + .stored_attestations + .get(&tls_public_key) + .expect("original entry must be restored"); + assert_eq!(stored.node_id, original_node); + } + + #[test] + fn revert_dstack_store__removes_the_newly_inserted_entry() { + // Given: a brand-new attestation for `alice` (no prior entry displaced). + const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); + let mut tee_state = TeeState::default(); + let tls_public_key = bogus_ed25519_public_key(); + let node = NodeId { + account_id: "alice.near".parse().unwrap(), + tls_public_key: tls_public_key.clone(), + account_public_key: bogus_ed25519_public_key(), + }; + let insertion = tee_state + .verify_and_store_mock(node, MockAttestation::Valid, TEE_UPGRADE_DURATION) + .expect("insertion should succeed"); + assert_matches!(insertion, ParticipantInsertion::NewlyInsertedParticipant); + + // When: the store is reverted. + tee_state.revert_dstack_store(&tls_public_key, insertion); + + // Then: the entry is gone. + assert!( + tee_state.stored_attestations.get(&tls_public_key).is_none(), + "newly inserted entry must be removed on revert" + ); + } + #[test] fn verify_and_store_mock__should_reject_invalid_attestations() { let mut tee_state = TeeState::default(); diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index 0d3d0fac17..6563cbbb81 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -3,7 +3,7 @@ use super::common; use mpc_contract::{ MpcContract, - errors::Error, + errors::{Error, TeeError}, primitives::{ key_state::EpochId, participants::{ParticipantId, ParticipantInfo}, @@ -23,6 +23,7 @@ use near_account_id::AccountId; use near_sdk::{NearToken, test_utils::VMContextBuilder, testing_env}; use rstest::rstest; use std::time::Duration; +use test_utils::attestation::mock_dto_dstack_attestation; const SECOND: Duration = Duration::from_secs(1); const NANOS_IN_SECOND: u64 = SECOND.as_nanos() as u64; @@ -284,6 +285,25 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { assert_eq!(stored_before, stored_after); } +/// **Test that a `Dstack` submission is rejected when no verifier is configured.** The +/// async path has nowhere to offload DCAP verification, so it must fail up front (before +/// registering a yield) rather than leave a submission that can never resolve. +#[test] +fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() { + // Given: a running contract with no TEE verifier voted in. + let mut setup = TestSetupBuilder::new().build(); + let node = setup.get_participant_node_ids()[0].clone(); + + // When: that participant submits a Dstack attestation. + let result = setup.try_submit_attestation_for_node(&node, mock_dto_dstack_attestation()); + + // Then: it is rejected with `VerifierNotConfigured`. + assert_matches!( + &result, + Err(Error::TeeError(TeeError::VerifierNotConfigured)) + ); +} + /// **Test that `clean_tee_status()` is vote-only** — attestations for non-participants /// remain in `stored_attestations` after the call. Attestation pruning is handled by the /// separate `clean_invalid_attestations` endpoint. From f098b5c7e4a118ccd1da3d78aaacdd20ff267265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 2 Jul 2026 12:21:09 +0200 Subject: [PATCH 03/26] test(contract): cover OOG inside resolve_verification Adds a sandbox test that configures resolve_verification_tera_gas far below what the post-DCAP work needs, so the callback receipt runs out of gas and rolls back atomically without resuming the yield. Asserts the ~200-block timeout branch of on_attestation_verified still cleans up the pending entry and refunds the deposit, guarding the invariant that a partial resolve_verification receipt cannot leave a refunded-but-still-pending entry that wedges the account --- crates/contract/tests/sandbox/tee_verifier.rs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 511fd0a23d..b78a7c4f08 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -9,6 +9,8 @@ //! - verifier not configured → submission rejected, nothing stored. //! - `Rejected` → submission fails, deposit refunded, no stored attestation. //! - no-verdict (stub panics) → the ~200-block yield timeout cleans up. +//! - `resolve_verification` runs out of gas → its receipt rolls back atomically +//! and the same ~200-block timeout cleans up (no half-committed state). #![allow(non_snake_case)] use crate::sandbox::{ @@ -214,6 +216,72 @@ async fn submit_participant_info__should_clean_up_on_verifier_crash() -> Result< Ok(()) } +#[tokio::test] +async fn submit_participant_info__should_clean_up_when_resolve_verification_runs_out_of_gas() +-> Result<()> { + // Given: a contract configured with a `resolve_verification` gas budget far + // too small to run the post-DCAP work and resume the yield, so the callback + // receipt runs out of gas mid-execution and rolls back atomically. The stub + // answers (here, a rejection) so `resolve_verification` actually runs rather + // than hitting the no-verdict early return. + let init_config = dtos::InitConfig { + resolve_verification_tera_gas: Some(1), + ..Default::default() + }; + let SandboxTestSetup { + worker, + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .with_sandbox_test_methods() + .with_init_config(init_config) + .build() + .await; + deploy_and_trust_stub( + &worker, + &contract, + &mpc_signer_accounts, + StubResponse::Rejected("would-refund-if-resolve-had-gas".to_string()), + ) + .await?; + + // When: a participant submits. The verifier answers, but `resolve_verification` + // runs out of gas before `promise_yield_resume`, so its whole receipt — the + // pending-entry removal and the refund included — rolls back and the yield is + // never resumed. The chain then advances past the ~200-block timeout so the + // runtime fires `on_attestation_verified`'s timeout branch. + let submitter = &mpc_signer_accounts[0]; + let balance_before = submitter.view_account().await?.balance; + let _ = submit_participant_info_with_deposit( + submitter, + &contract, + &dstack_attestation(), + &tls_key(), + NearToken::from_near(1), + ) + .await?; + worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await?; + + // Then: an out-of-gas `resolve_verification` is recovered exactly like an + // unreachable verifier — nothing stored, the pending entry cleaned up by the + // timeout branch, and the deposit refunded. This is the guarantee that a + // partial `resolve_verification` receipt cannot leave a refunded-but-still- + // pending entry: the receipt is atomic, so the account is not wedged. + let stored = get_participant_attestation(&contract, &tls_key()).await?; + assert!( + stored.is_none(), + "nothing should be stored when resolve_verification runs out of gas" + ); + assert!( + !has_pending_attestation(&contract, submitter.id()).await?, + "the pending entry must be cleaned up by the yield timeout after an OOG resolve_verification" + ); + assert_deposit_refunded(submitter, balance_before).await?; + Ok(()) +} + /// Asserts the 1 NEAR storage deposit was returned: the net spend since /// `balance_before` is well under 1 NEAR (only gas), rather than the full /// deposit being retained by the contract. From b516d6860ed5dd66cb70173d6ce1c6986db220e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 2 Jul 2026 13:03:20 +0200 Subject: [PATCH 04/26] test(contract): address review on async attestation tests - assert_deposit_refunded: use raw subtraction instead of saturating_sub so an over-refund (balance_after > balance_before) panics rather than clamping to 0 and silently passing (CLAUDE.md forbids saturating arithmetic in tests) - document the has_pending_attestation sandbox view, matching its siblings - fix a stray period in the example panic string in tee-localnet.md (Invalid TEE Remote Attestation: ..., no period before the colon) --- crates/contract/src/sandbox_test_methods.rs | 5 +++++ crates/contract/tests/sandbox/tee_verifier.rs | 9 ++++++--- docs/localnet/tee-localnet.md | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/contract/src/sandbox_test_methods.rs b/crates/contract/src/sandbox_test_methods.rs index 63120a02ad..f20652873b 100644 --- a/crates/contract/src/sandbox_test_methods.rs +++ b/crates/contract/src/sandbox_test_methods.rs @@ -49,6 +49,11 @@ impl MpcContract { .expect("queue length must fit in u32 — bounded by MAX_PENDING_REQUEST_FAN_OUT") } + /// Whether an in-flight attestation entry exists for `account_id`. + /// + /// Used by the yield-resume sandbox tests to assert the pending entry is + /// cleaned up after a rejection, the yield timeout, or an out-of-gas + /// `resolve_verification`. pub fn has_pending_attestation(&self, account_id: AccountId) -> bool { self.pending_attestations.contains_key(&account_id) } diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index b78a7c4f08..784cbd5de6 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -287,10 +287,13 @@ async fn submit_participant_info__should_clean_up_when_resolve_verification_runs /// deposit being retained by the contract. async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) -> Result<()> { let balance_after = account.view_account().await?.balance; - let net_spent = balance_before.saturating_sub(balance_after); + // Raw subtraction (not `saturating_sub`): if the contract over-refunds so + // `balance_after > balance_before`, this underflows and panics rather than + // clamping to 0 and silently passing the `< 1 NEAR` check. + let net_spent = balance_before.as_yoctonear() - balance_after.as_yoctonear(); assert!( - net_spent < NearToken::from_near(1), - "deposit should be refunded (net spent {net_spent} should be < 1 NEAR, gas only)" + net_spent < NearToken::from_near(1).as_yoctonear(), + "deposit should be refunded (net spent {net_spent} yoctoNEAR should be < 1 NEAR, gas only)" ); Ok(()) } diff --git a/docs/localnet/tee-localnet.md b/docs/localnet/tee-localnet.md index 70ab1573b7..599d3c3eeb 100644 --- a/docs/localnet/tee-localnet.md +++ b/docs/localnet/tee-localnet.md @@ -276,7 +276,7 @@ near transaction view-status network-config mpc-localnet ``` ``` -(ExecutionError("Smart contract panicked: Invalid TEE Remote Attestation.: attestation verification failed: the allowed mpc image hashes list is empty" +(ExecutionError("Smart contract panicked: Invalid TEE Remote Attestation: attestation verification failed: the allowed mpc image hashes list is empty" ``` ### Vote Commands From 9c2963bcfdec98cc521b2c2af3c53eb6f399f312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 2 Jul 2026 15:15:03 +0200 Subject: [PATCH 05/26] test(contract): address self-review findings on async attestation tests Self-review (adversarially verified) surfaced 11 issues; applied the mechanical + correctness ones: - init_config was silently dropped on the init_running setup path, so the OOG test ran with default gas and re-tested the rejection branch. Plumb init_config through init_contract_running. - assert_deposit_refunded: bound to the gas envelope (~50 mNEAR) so a partial refund fails, instead of the loose < 1 NEAR check. - not-configured test asserts the specific VerifierNotConfigured error rather than only is_failure(). - StubResponse: pin Borsh discriminants in a unit test + keep-in-sync comments on both mirror declarations. - rename revert_dstack_store tests to the __should_ form; drop em dashes. - docs: correct the submission-path panic strings (no "Invalid TEE Remote Attestation" prefix), request_verify_foreign_tx -> verify_foreign_transaction, resolve_verification sample param, and drop the non-existent DstackAttestation::dcap_report reference. - deny.toml: ignore RUSTSEC-2026-0194/0195 (transitive quick-xml, fixed in >=0.41.0) to unblock cargo-deny. Add a verified_report() test-utils fixture (mints the real report via verify_dcap_quote) toward the deferred Verified-path coverage. The OOG test is #[ignore]d with TODO(#3730): forcing a real OOG needs the allowlist populated so execution reaches the gas-heavy RTMR3 replay; that governance setup is shared with the pending happy-path test. --- Cargo.lock | 1 + crates/contract/src/tee/tee_state.rs | 8 +- crates/contract/tests/sandbox/common.rs | 3 + .../tests/sandbox/participants_gas.rs | 3 +- crates/contract/tests/sandbox/tee_verifier.rs | 106 +++++++++++++----- crates/test-tee-verifier/src/lib.rs | 7 +- crates/test-utils/Cargo.toml | 3 +- crates/test-utils/src/attestation.rs | 16 +++ docs/design/attestation-verifier-contract.md | 41 +++---- docs/localnet/tee-localnet.md | 2 +- ...nning-an-mpc-node-in-tdx-external-guide.md | 3 +- 11 files changed, 138 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e7380a0033..861437f773 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11399,6 +11399,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.10.9", + "tee-verifier-interface", ] [[package]] diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 88798dcfaa..f2d5c746be 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -1383,9 +1383,9 @@ mod tests { } #[test] - fn revert_dstack_store__restores_the_displaced_entry_on_update() { - // Given: `alice` has an attestation, then updates it — the second insertion - // returns the displaced original wrapped in `UpdatedExistingParticipant`. + fn revert_dstack_store__should_restore_the_displaced_entry_on_update() { + // Given: `alice` has an attestation, then updates it (the second insertion + // returns the displaced original wrapped in `UpdatedExistingParticipant`). const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); let mut tee_state = TeeState::default(); let tls_public_key = bogus_ed25519_public_key(); @@ -1426,7 +1426,7 @@ mod tests { } #[test] - fn revert_dstack_store__removes_the_newly_inserted_entry() { + fn revert_dstack_store__should_remove_the_newly_inserted_entry() { // Given: a brand-new attestation for `alice` (no prior entry displaced). const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); let mut tee_state = TeeState::default(); diff --git a/crates/contract/tests/sandbox/common.rs b/crates/contract/tests/sandbox/common.rs index 60aa02036b..bb8b7549ff 100644 --- a/crates/contract/tests/sandbox/common.rs +++ b/crates/contract/tests/sandbox/common.rs @@ -145,6 +145,7 @@ pub async fn init_contract_running( next_domain_id: u64, keyset: Keyset, params: ThresholdParameters, + init_config: Option, ) -> ExecutionSuccess { let result = contract .call(method_names::INIT_RUNNING) @@ -153,6 +154,7 @@ pub async fn init_contract_running( "next_domain_id": next_domain_id, "keyset": keyset, "parameters": params, + "init_config": init_config, })) .gas(GAS_FOR_INIT) .transact() @@ -311,6 +313,7 @@ impl SandboxTestSetupBuilder { next_domain_id, keyset, threshold_parameters, + self.init_config, ) .await; } else { diff --git a/crates/contract/tests/sandbox/participants_gas.rs b/crates/contract/tests/sandbox/participants_gas.rs index bd747b99f2..4a56c67b31 100644 --- a/crates/contract/tests/sandbox/participants_gas.rs +++ b/crates/contract/tests/sandbox/participants_gas.rs @@ -289,7 +289,8 @@ async fn setup_test_env_with_state(n_participants: usize, running_state: bool) - let keyset = Keyset::new(EpochId::new(1), vec![key]); let domains = vec![domain]; let next_domain_id = domains.len() as u64 + 1; - init_contract_running(&contract, domains, next_domain_id, keyset, threshold_params).await; + init_contract_running(&contract, domains, next_domain_id, keyset, threshold_params, None) + .await; } else { init_contract(&contract, threshold_params, None).await; } diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 784cbd5de6..2ed83ab816 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -28,7 +28,7 @@ use anyhow::Result; use borsh::BorshSerialize; use near_mpc_contract_interface::types::{self as dtos, Attestation}; use near_workspaces::{Account, Contract, Worker, network::Sandbox, types::NearToken}; -use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key}; +use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key, verified_report}; /// Blocks to fast-forward past the ~200-block yield-resume timeout so the /// runtime fires `on_attestation_verified`'s timeout branch. @@ -38,10 +38,14 @@ const YIELD_TIMEOUT_BLOCKS: u64 = 250; /// depending on the stub crate) so the test only needs its Borsh encoding to /// initialize the deployed stub; the stub is a separate `#[near]` contract and /// linking its crate into this test binary would collide on ABI symbols. +/// +/// KEEP THE VARIANT ORDER IN SYNC with `test_tee_verifier::StubResponse`: Borsh +/// encodes an enum as a u8 discriminant equal to the declaration index, so a +/// reorder on either side silently misroutes the response. `stub_response_discriminants` +/// below pins the indices so a divergence fails loudly. #[expect(clippy::large_enum_variant)] #[derive(BorshSerialize)] enum StubResponse { - #[expect(dead_code)] Verified(tee_verifier_interface::VerifiedReport), Rejected(String), Panic, @@ -102,10 +106,17 @@ async fn submit_participant_info__should_reject_dstack_when_verifier_not_configu ) .await?; - // Then: it is rejected (no verifier configured) and nothing is stored. + // Then: it fails synchronously with the VerifierNotConfigured error (the + // early return in submit_dstack_attestation, before any yield is registered), + // and nothing is stored. Assert the specific message so an unrelated failure + // (gas, encoding) can't pass as success. + let err = result + .into_result() + .expect_err("Dstack submit must fail when no verifier is configured") + .to_string(); assert!( - result.is_failure(), - "Dstack submit must fail when no verifier is configured: {result:#?}" + err.contains("No TEE verifier is configured"), + "expected VerifierNotConfigured, got: {err}" ); let stored = get_participant_attestation(&contract, &tls_key()).await?; assert!(stored.is_none(), "no attestation should be stored"); @@ -216,14 +227,27 @@ async fn submit_participant_info__should_clean_up_on_verifier_crash() -> Result< Ok(()) } +// TODO(#3730): un-ignore once the fixture allowlist setup lands. To make +// `resolve_verification` actually run out of gas, execution must reach the +// expensive RTMR3 replay inside `verify_post_dcap_and_store` before exhausting +// the 1 TGas budget. That requires the post-DCAP checks to get *past* the +// allowlist gate first, i.e. the contract must have the fixture's MPC image hash +// (`image_digest()`), launcher compose hash (`launcher_compose_digest()`), and +// measurements voted in, and the submitter must use the fixture keys so the +// report-data binding matches. With an empty allowlist (as here) the check +// fails fast and cheap, so `resolve_verification` completes at 1 TGas and this +// re-tests the rejection path instead. Shares that setup with the (also pending) +// Verified happy-path test. +#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; see TODO(#3730)"] #[tokio::test] async fn submit_participant_info__should_clean_up_when_resolve_verification_runs_out_of_gas() -> Result<()> { // Given: a contract configured with a `resolve_verification` gas budget far - // too small to run the post-DCAP work and resume the yield, so the callback - // receipt runs out of gas mid-execution and rolls back atomically. The stub - // answers (here, a rejection) so `resolve_verification` actually runs rather - // than hitting the no-verdict early return. + // too small to run the post-DCAP work and resume the yield. The stub returns + // `Verified` so `resolve_verification` enters `verify_post_dcap_and_store` + // (the heavy RTMR3-replay path), which then exhausts the 1 TGas budget and + // rolls the whole receipt back. A `Rejected` response would not work here: + // its branch is light enough to complete even at 1 TGas. let init_config = dtos::InitConfig { resolve_verification_tera_gas: Some(1), ..Default::default() @@ -243,15 +267,14 @@ async fn submit_participant_info__should_clean_up_when_resolve_verification_runs &worker, &contract, &mpc_signer_accounts, - StubResponse::Rejected("would-refund-if-resolve-had-gas".to_string()), + StubResponse::Verified(verified_report()), ) .await?; // When: a participant submits. The verifier answers, but `resolve_verification` - // runs out of gas before `promise_yield_resume`, so its whole receipt — the - // pending-entry removal and the refund included — rolls back and the yield is - // never resumed. The chain then advances past the ~200-block timeout so the - // runtime fires `on_attestation_verified`'s timeout branch. + // runs out of gas before `promise_yield_resume`, so its whole receipt (the + // pending-entry removal and the refund included) rolls back and the yield is + // never resumed. let submitter = &mpc_signer_accounts[0]; let balance_before = submitter.view_account().await?.balance; let _ = submit_participant_info_with_deposit( @@ -262,13 +285,24 @@ async fn submit_participant_info__should_clean_up_when_resolve_verification_runs NearToken::from_near(1), ) .await?; + + // Distinguish this path from the rejection test: because + // `resolve_verification` rolled back rather than resuming, the pending entry + // is still present here. The rejection path would have removed it already. + assert!( + has_pending_attestation(&contract, submitter.id()).await?, + "pending entry must survive an OOG resolve_verification (cleanup is left to the timeout)" + ); + + // Advancing past the ~200-block window fires `on_attestation_verified`'s + // timeout branch, which is what actually cleans up in this path. worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await?; - // Then: an out-of-gas `resolve_verification` is recovered exactly like an - // unreachable verifier — nothing stored, the pending entry cleaned up by the - // timeout branch, and the deposit refunded. This is the guarantee that a - // partial `resolve_verification` receipt cannot leave a refunded-but-still- - // pending entry: the receipt is atomic, so the account is not wedged. + // Then: an out-of-gas `resolve_verification` is recovered like an unreachable + // verifier: nothing stored, the pending entry cleaned up by the timeout + // branch, and the deposit refunded. This is the guarantee that a partial + // `resolve_verification` receipt cannot leave a refunded-but-still-pending + // entry: the receipt is atomic, so the account is not wedged. let stored = get_participant_attestation(&contract, &tls_key()).await?; assert!( stored.is_none(), @@ -282,18 +316,40 @@ async fn submit_participant_info__should_clean_up_when_resolve_verification_runs Ok(()) } -/// Asserts the 1 NEAR storage deposit was returned: the net spend since -/// `balance_before` is well under 1 NEAR (only gas), rather than the full -/// deposit being retained by the contract. +/// Asserts the full 1 NEAR storage deposit was returned: the net spend since +/// `balance_before` is only gas, well under any fraction of the deposit. async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) -> Result<()> { let balance_after = account.view_account().await?.balance; // Raw subtraction (not `saturating_sub`): if the contract over-refunds so // `balance_after > balance_before`, this underflows and panics rather than - // clamping to 0 and silently passing the `< 1 NEAR` check. + // clamping to 0 and silently passing. let net_spent = balance_before.as_yoctonear() - balance_after.as_yoctonear(); + // Bound to the gas envelope, not the deposit: max gas (~0.03 NEAR at the + // sandbox price) sits far below this ceiling, while any partial retention of + // the 1 NEAR deposit (e.g. 0.5 NEAR) would exceed it and fail. + let gas_ceiling = NearToken::from_millinear(50).as_yoctonear(); assert!( - net_spent < NearToken::from_near(1).as_yoctonear(), - "deposit should be refunded (net spent {net_spent} yoctoNEAR should be < 1 NEAR, gas only)" + net_spent < gas_ceiling, + "deposit should be fully refunded (net spent {net_spent} yoctoNEAR should be gas-only, < {gas_ceiling})" ); Ok(()) } + +/// Pins the Borsh discriminant of each [`StubResponse`] variant to its declaration +/// index. The deployed `test_tee_verifier::StubResponse` deserializes what this +/// mirror serializes, so a reorder on either side must fail loudly here rather +/// than silently misroute a response. `Verified` is index 0 by position (a +/// `VerifiedReport` fixture is not needed to guard the reorder that matters). +#[test] +fn stub_response_discriminants() { + assert_eq!( + borsh::to_vec(&StubResponse::Rejected(String::new())).unwrap()[0], + 1, + "Rejected must be Borsh discriminant 1" + ); + assert_eq!( + borsh::to_vec(&StubResponse::Panic).unwrap()[0], + 2, + "Panic must be Borsh discriminant 2" + ); +} diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs index bd5168f1ef..91e2e149ab 100644 --- a/crates/test-tee-verifier/src/lib.rs +++ b/crates/test-tee-verifier/src/lib.rs @@ -25,6 +25,11 @@ getrandom::register_custom_getrandom!(randomness_unsupported); /// What the stub's `verify_quote` should do, chosen by the test at deploy time. #[expect(clippy::large_enum_variant)] +// KEEP THE VARIANT ORDER IN SYNC with the `StubResponse` mirror in +// `crates/contract/tests/sandbox/tee_verifier.rs`: the test serializes with that +// copy and this contract deserializes with this one, so the Borsh discriminants +// (declaration index) must match. That mirror's `stub_response_discriminants` +// test pins the indices. #[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), @@ -33,7 +38,7 @@ getrandom::register_custom_getrandom!(randomness_unsupported); pub enum StubResponse { /// Return `VerificationResult::Verified` with this exact report. Tests that /// want the post-DCAP checks to pass supply the report obtained from the - /// real fixture quote (e.g. via `DstackAttestation::dcap_report`). + /// real fixture quote. Verified(tee_verifier_interface::VerifiedReport), /// Return `VerificationResult::Rejected` with this reason. Rejected(String), diff --git a/crates/test-utils/Cargo.toml b/crates/test-utils/Cargo.toml index 340e26f674..909b3dea9d 100644 --- a/crates/test-utils/Cargo.toml +++ b/crates/test-utils/Cargo.toml @@ -7,7 +7,8 @@ edition = { workspace = true } [dependencies] cargo-near-build = { workspace = true } hex = { workspace = true } -mpc-attestation = { workspace = true, features = ["test-utils"] } +mpc-attestation = { workspace = true, features = ["test-utils", "local-verify"] } +tee-verifier-interface = { workspace = true } mpc-primitives = { workspace = true } near-mpc-contract-interface = { workspace = true } near-sdk = { workspace = true, features = ["non-contract-usage"] } diff --git a/crates/test-utils/src/attestation.rs b/crates/test-utils/src/attestation.rs index 3c4af6c72e..22c129f38e 100644 --- a/crates/test-utils/src/attestation.rs +++ b/crates/test-utils/src/attestation.rs @@ -107,6 +107,22 @@ pub fn mock_dstack_attestation() -> Attestation { Attestation::Dstack(DstackAttestation::new(quote, collateral, tcb_info)) } +/// The [`VerifiedReport`] the real `tee-verifier` would return for the fixture +/// quote. Minted here by running the DCAP step at [`VALID_ATTESTATION_TIMESTAMP`] +/// (when the fixture collateral is valid), so tests can feed it to the stub +/// verifier's `Verified` response and drive the contract's post-DCAP path. +pub fn verified_report() -> tee_verifier_interface::VerifiedReport { + let dstack = DstackAttestation::new( + quote(), + mpc_attestation::collateral::collateral_from_str(include_str!("../assets/collateral.json")) + .expect("collateral.json is valid collateral"), + serde_json::from_str(TEST_TCB_INFO_STRING).unwrap(), + ); + dstack + .verify_dcap_quote(VALID_ATTESTATION_TIMESTAMP) + .expect("fixture quote verifies at VALID_ATTESTATION_TIMESTAMP") +} + pub fn mock_dto_dstack_attestation() -> near_mpc_contract_interface::types::Attestation { let quote = HexVec::from(Vec::from(quote())); let collateral_json_string = include_str!("../assets/collateral.json"); diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 9f14734746..99f81572bb 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -59,7 +59,7 @@ flowchart LR ### Submission flow -`mpc-contract`'s [`submit_participant_info`][submit-participant-info] becomes asynchronous for Dstack attestations and uses the same yield-resume pattern already in place for `sign`, `request_app_private_key`, and `request_verify_foreign_tx`. The method registers a yielded promise via [`env::promise_yield_create`][promise-yield-create] (going through the contract's existing [`enqueue_yield_request`][enqueue-yield-request] helper), stashes the resulting `data_id` in `pending_attestations`, and fires a cross-contract call to `tee-verifier::verify_quote` whose own `.then` callback (`resolve_verification`) bridges the verifier's response into a `promise_yield_resume`. Because `verify_quote` returns a `VerificationResult` value rather than failing the receipt (see [§Why a rejection is a value](#why-a-rejection-is-a-value-not-a-failed-receipt)), `resolve_verification`'s `#[callback_result]` distinguishes three outcomes: `Verified` runs the post-DCAP checks (RTMR3 replay, app-compose validation, measurement allowlist matching, report-data binding) and resumes; `Rejected` refunds and resumes **immediately** with the reason; and only `Err(PromiseError::Failed)` — the verifier unreachable or crashed — returns early, leaving cleanup to the ~200-block timeout. The yield-callback `on_attestation_verified` stays trivial, the same shape as the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]: it returns the resumed outcome to the caller, and only on the timeout (`Err(PromiseError::Failed)`) does it remove the pending entry and schedule a refund. Keeping the heavy work in `resolve_verification` makes an OOG in the yield-callback implausible, and an OOG in `resolve_verification` rolls back its whole receipt so the timeout still fires the trivial callback for cleanup. The exact branch-by-branch behavior is in [§Handling failures](#handling-failures) below. +`mpc-contract`'s [`submit_participant_info`][submit-participant-info] becomes asynchronous for Dstack attestations and uses the same yield-resume pattern already in place for `sign`, `request_app_private_key`, and `verify_foreign_transaction`. The method registers a yielded promise via [`env::promise_yield_create`][promise-yield-create] (going through the contract's existing [`enqueue_yield_request`][enqueue-yield-request] helper), stashes the resulting `data_id` in `pending_attestations`, and fires a cross-contract call to `tee-verifier::verify_quote` whose own `.then` callback (`resolve_verification`) bridges the verifier's response into a `promise_yield_resume`. Because `verify_quote` returns a `VerificationResult` value rather than failing the receipt (see [§Why a rejection is a value](#why-a-rejection-is-a-value-not-a-failed-receipt)), `resolve_verification`'s `#[callback_result]` distinguishes three outcomes: `Verified` runs the post-DCAP checks (RTMR3 replay, app-compose validation, measurement allowlist matching, report-data binding) and resumes; `Rejected` refunds and resumes **immediately** with the reason; and only `Err(PromiseError::Failed)` — the verifier unreachable or crashed — returns early, leaving cleanup to the ~200-block timeout. The yield-callback `on_attestation_verified` stays trivial, the same shape as the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]: it returns the resumed outcome to the caller, and only on the timeout (`Err(PromiseError::Failed)`) does it remove the pending entry and schedule a refund. Keeping the heavy work in `resolve_verification` makes an OOG in the yield-callback implausible, and an OOG in `resolve_verification` rolls back its whole receipt so the timeout still fires the trivial callback for cleanup. The exact branch-by-branch behavior is in [§Handling failures](#handling-failures) below. The post-DCAP policy inputs are the same fields `mpc-contract` already holds today — the allowed-image-hash list, the per-account TLS / account public-key binding, and the stored-attestation map. No new policy state is introduced; the only state addition is the `pending_attestations` map described below, which is bookkeeping for the in-flight yield. @@ -82,14 +82,14 @@ sequenceDiagram alt Verified (post-DCAP runs, then resumes) Ver-->>MPC: VerificationResult::Verified(report) - MPC->>MPC: resolve_verification: finish_verify vs fresh allowlist + MPC->>MPC: resolve_verification: verify_post_dcap_and_store vs fresh allowlist MPC->>State: store on pass / refund on fail, remove PendingAttestation - MPC->>MPC: promise_yield_resume(data_id, FinalOutcome) + MPC->>MPC: promise_yield_resume(data_id, AttestationResult) MPC-->>Op: success or error, immediately else Rejected (resumes immediately) Ver-->>MPC: VerificationResult::Rejected(reason) MPC->>MPC: resolve_verification: refund, remove PendingAttestation - MPC->>MPC: promise_yield_resume(data_id, FinalOutcome::Err(reason)) + MPC->>MPC: promise_yield_resume(data_id, AttestationResult::Err(reason)) MPC-->>Op: error (carrying reason), immediately else No verdict — verifier unreachable / silent for ~200 blocks Note over MPC: resolve_verification (if it runs) only logs and returns.
Cleanup is left to the runtime yield-timeout. @@ -123,7 +123,7 @@ Walking every path the system can take: - `resolve_verification` never runs (verifier silent) → timeout fires, yield-callback cleans up. - Yield-callback itself OOGs → genuine orphan. Its body is one `LookupMap::remove` plus one Promise schedule, small enough that an out-of-gas failure is implausible with a sensible gas budget. -This isn't a novel pattern in this codebase: `sign`, `request_app_private_key`, and `request_verify_foreign_tx` already use the same split. The heavy work and the `promise_yield_resume` call live in [`pending_requests::resolve_yields_for`][pending-requests-mod] (invoked from `respond`, `respond_ckd`, and `respond_verify_foreign_tx`), and [`return_signature_and_clean_state_on_success`][sign-yield-callback] is a tiny yield-callback that just routes the outcome back to the caller and handles the no-response timeout. The attestation flow inherits the same orphan-proof argument: heavy work in a safely-recoverable receipt, cleanup in a callback small enough that gas budgeting makes its failure implausible. +This isn't a novel pattern in this codebase: `sign`, `request_app_private_key`, and `verify_foreign_transaction` already use the same split. The heavy work and the `promise_yield_resume` call live in [`pending_requests::resolve_yields_for`][pending-requests-mod] (invoked from `respond`, `respond_ckd`, and `respond_verify_foreign_tx`), and [`return_signature_and_clean_state_on_success`][sign-yield-callback] is a tiny yield-callback that just routes the outcome back to the caller and handles the no-response timeout. The attestation flow inherits the same orphan-proof argument: heavy work in a safely-recoverable receipt, cleanup in a callback small enough that gas budgeting makes its failure implausible. ### Contract state changes @@ -140,7 +140,7 @@ Each [`PendingAttestation`](#mpc-contractsubmit_participant_info) holds: - **The submitter's `Attestation::Dstack` payload** — the RTMR3 event log, app-compose, and report-data the post-DCAP checks consume. - **The submitter's TLS public key** — the callback hashes it with the submitter's account public key and compares to the quote's `report_data` field, proving the enclave produced the quote for this specific submitter. - **The attached deposit** — covers storage staking on success, refunded to the signer of the original `submit_participant_info` transaction on failure. `env::attached_deposit()` is not visible from the callback receipt, so the value is stashed at submit time and the recipient `AccountId` is the same one used to key the entry (set by `Self::assert_caller_is_signer()`). -- **`data_id: CryptoHash`** — the yield handle returned by [`env::promise_yield_create`][promise-yield-create]. The intermediate `.then` callback (`resolve_verification`) reads this back to call [`env::promise_yield_resume`][promise-yield-resume] with a `FinalOutcome` after the post-DCAP checks have run. +- **`data_id: CryptoHash`** — the yield handle returned by [`env::promise_yield_create`][promise-yield-create]. The intermediate `.then` callback (`resolve_verification`) reads this back to call [`env::promise_yield_resume`][promise-yield-resume] with an `AttestationResult` after the post-DCAP checks have run. Entries are removed by `resolve_verification` on every branch where the verifier returned an answer — `Verified` (post-DCAP success or failure) and `Rejected`; only when the verifier gave no verdict (`Err(PromiseError::Failed)` — unreachable or crashed) does it return early and leave the entry in place. `on_attestation_verified` then removes the entry on its `Err(PromiseError::Failed)` branch, which covers that unreachable case and the no-response timeout. @@ -164,8 +164,8 @@ sequenceDiagram Ver-->>MPC: VerificationResult::Verified(report) MPC->>MPC: resolve_verification MPC->>MPC: read allowlist (sees H) - MPC->>MPC: finish_verify against fresh allowlist - MPC->>MPC: promise_yield_resume(data_id, FinalOutcome) + MPC->>MPC: verify_post_dcap_and_store against fresh allowlist + MPC->>MPC: promise_yield_resume(data_id, AttestationResult) MPC->>MPC: on_attestation_verified (trivial: return value) ``` @@ -398,7 +398,7 @@ impl MpcContract { match attestation { // Synchronous: no DCAP, verified and stored in this call. Attestation::Mock(mock) => { - self.tee_state.add_mock_participant(node_id, mock, ...)?; + self.tee_state.verify_and_store_mock(node_id, mock, ...)?; Ok(()) } // Dstack: yield-resume. @@ -475,7 +475,7 @@ impl MpcContract { /// inserts into `stored_attestations` on success; on `Rejected` it skips /// straight to the refund. Either way it removes the pending entry, /// schedules a refund where the outcome is an error, and calls - /// `promise_yield_resume(data_id, FinalOutcome)` as the LAST step of the + /// `promise_yield_resume(data_id, AttestationResult)` as the LAST step of the /// receipt — so a rejected quote is resolved *immediately*, not at the /// timeout. Only `Err(PromiseError::Failed)` (no verdict — verifier /// unreachable or crashed) is logged and returned early WITHOUT resuming or @@ -495,9 +495,10 @@ impl MpcContract { #[private] pub fn resolve_verification( &mut self, - account_id: AccountId, + node_id: NodeId, #[callback_result] result: Result, ) { + let account_id = node_id.account_id.clone(); let final_outcome = match result { // No verdict: the verifier was unreachable, panicked, or ran out of // gas. Do nothing — the runtime's yield-timeout will fire @@ -514,7 +515,7 @@ impl MpcContract { // the timeout. Ok(VerificationResult::Rejected(reason)) => { log!("verifier rejected quote for {account_id}: {reason}"); - FinalOutcome::Err(format!("verifier: {reason}")) + AttestationResult::Err(format!("verifier: {reason}")) } Ok(VerificationResult::Verified(report)) => { let pending = self.pending_attestations.get(&account_id).expect( @@ -523,17 +524,17 @@ impl MpcContract { // Post-DCAP checks operate on the verified report plus state held // here. The allowlist is read fresh — governance votes mid-flight // take effect. - match finish_verify(pending, &report, self.allowlist_fresh()) { + match verify_post_dcap_and_store(pending, &report, self.allowlist_fresh()) { Ok(()) => { self.tee_state.stored_attestations.insert( pending.tls_pk.clone(), VerifiedAttestation::from((pending.clone(), report)), ); - FinalOutcome::Ok + AttestationResult::Ok } Err(reason) => { log!("post-DCAP check failed for {account_id}: {reason}"); - FinalOutcome::Err(format!("post-DCAP: {reason}")) + AttestationResult::Err(format!("post-DCAP: {reason}")) } } } @@ -543,7 +544,7 @@ impl MpcContract { .pending_attestations .remove(&account_id) .expect("PendingAttestation must exist while resolve_verification holds the yield"); - if matches!(final_outcome, FinalOutcome::Err(_)) { + if matches!(final_outcome, AttestationResult::Err(_)) { refund_deposit(&account_id, pending.attached_deposit); } // `promise_yield_resume` must be the LAST host call in this receipt: @@ -569,11 +570,11 @@ impl MpcContract { pub fn on_attestation_verified( &mut self, account_id: AccountId, - #[callback_result] result: Result, + #[callback_result] result: Result, ) -> PromiseOrValue<()> { let reason = match result { - Ok(FinalOutcome::Ok) => return PromiseOrValue::Value(()), - Ok(FinalOutcome::Err(reason)) => reason, + Ok(AttestationResult::Ok) => return PromiseOrValue::Value(()), + Ok(AttestationResult::Err(reason)) => reason, Err(_promise_err) => { if let Some(pending) = self.pending_attestations.remove(&account_id) { refund_deposit(&account_id, pending.attached_deposit); @@ -595,7 +596,7 @@ impl MpcContract { } #[derive(BorshSerialize, BorshDeserialize)] -pub enum FinalOutcome { +pub enum AttestationResult { Ok, Err(String), } diff --git a/docs/localnet/tee-localnet.md b/docs/localnet/tee-localnet.md index 599d3c3eeb..07c32cba98 100644 --- a/docs/localnet/tee-localnet.md +++ b/docs/localnet/tee-localnet.md @@ -276,7 +276,7 @@ near transaction view-status network-config mpc-localnet ``` ``` -(ExecutionError("Smart contract panicked: Invalid TEE Remote Attestation: attestation verification failed: the allowed mpc image hashes list is empty" +(ExecutionError("Smart contract panicked: the submitted attestation failed verification, reason: Custom(\"the allowed mpc image hashes list is empty\")" ``` ### Vote Commands diff --git a/docs/running-an-mpc-node-in-tdx-external-guide.md b/docs/running-an-mpc-node-in-tdx-external-guide.md index e6413e02df..60c969e0ec 100644 --- a/docs/running-an-mpc-node-in-tdx-external-guide.md +++ b/docs/running-an-mpc-node-in-tdx-external-guide.md @@ -2062,8 +2062,7 @@ The error after `err=` is the NEAR runtime error. Common ones: If the transaction reaches execution and the contract panics, the node logs only the generic retry line above; the actual message lives in the transaction receipt. Find the tx on `https://testnet.nearblocks.io/address/` and open the failed `submit_participant_info` call — the error appears under the action's status / logs. The contract wraps the attestation-side error like this: ``` -Invalid TEE Remote Attestation: attestation verification failed: - the submitted attestation failed verification, reason: Custom("...") +the submitted attestation failed verification, reason: Custom("...") ``` The `reason` is the same `VerificationError` the client-side WARN reports (see section 1) — for example `Custom("the allowed mpc image hashes list is empty")`. Errors that **only** surface on-chain (because they're checked against the contract's allowed-measurements list, the contract's deposit logic, or the contract's caller assertion): From 7ec5ecfdc517ded7de30ccecf4eacdf355ab8458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 3 Jul 2026 11:30:10 +0200 Subject: [PATCH 06/26] test(contract): fix TODO-format lint in ignored-test reason The #[ignore] reason string contained a bare 'TODO(#3730)' token, which the check-todo-format CI gate rejects (it requires TODO(#N): with a trailing colon). Reword to 'tracked in #3730'; the canonical TODO(#3730): comment above the test carries the reference --- crates/contract/tests/sandbox/tee_verifier.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 2ed83ab816..16e57bf52f 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -238,7 +238,7 @@ async fn submit_participant_info__should_clean_up_on_verifier_crash() -> Result< // fails fast and cheap, so `resolve_verification` completes at 1 TGas and this // re-tests the rejection path instead. Shares that setup with the (also pending) // Verified happy-path test. -#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; see TODO(#3730)"] +#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3730"] #[tokio::test] async fn submit_participant_info__should_clean_up_when_resolve_verification_runs_out_of_gas() -> Result<()> { From 6f37e2306f63e1866716ee4febad216674af9980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 3 Jul 2026 11:43:51 +0200 Subject: [PATCH 07/26] docs(test-tee-verifier): use intra-doc links instead of bare backticks Convert linkable code references in the stub's doc comments to proper [`...`] intra-doc links (TestTeeVerifier::verify_quote, the StubResponse variants, VerificationResult::{Verified,Rejected}). Leave unlinkable references as prose: cross-crate non-deps (dcap_qvl, the real tee-verifier method), the mpc-contract crate name, and refs inside plain // comments that can't host intra-doc links. --- crates/test-tee-verifier/src/lib.rs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs index 91e2e149ab..eaafeebc2f 100644 --- a/crates/test-tee-verifier/src/lib.rs +++ b/crates/test-tee-verifier/src/lib.rs @@ -1,15 +1,12 @@ //! Test-only stub of the `tee-verifier` contract. //! -//! `verify_quote` ignores its inputs and returns a response fixed at init time, -//! instead of running real `dcap_qvl::verify`. This lets `mpc-contract` sandbox -//! tests drive every branch of the async attestation flow deterministically: -//! a `Verified` report (which the test supplies so it matches the fixture's -//! post-DCAP expectations), a `Rejected` verdict, or a panic (the no-verdict / +//! [`TestTeeVerifier::verify_quote`] ignores its inputs and returns a response +//! fixed at init time, instead of running real `dcap_qvl::verify`. This lets +//! `mpc-contract` sandbox tests drive every branch of the async attestation flow +//! deterministically: a [`StubResponse::Verified`] report (which the test +//! supplies so it matches the fixture's post-DCAP expectations), a +//! [`StubResponse::Rejected`] verdict, or a panic (the no-verdict / //! verifier-unreachable path). -//! -//! It speaks the same `tee-verifier-interface` Borsh DTOs and uses the same -//! `#[result_serializer(borsh)]` as the real verifier, so `mpc-contract` cannot -//! tell the two apart. use borsh::{BorshDeserialize, BorshSerialize}; use near_sdk::{env, near}; @@ -23,7 +20,8 @@ fn randomness_unsupported(_buf: &mut [u8]) -> Result<(), getrandom::Error> { #[cfg(target_arch = "wasm32")] getrandom::register_custom_getrandom!(randomness_unsupported); -/// What the stub's `verify_quote` should do, chosen by the test at deploy time. +/// What the stub's [`TestTeeVerifier::verify_quote`] should do, chosen by the +/// test at deploy time. #[expect(clippy::large_enum_variant)] // KEEP THE VARIANT ORDER IN SYNC with the `StubResponse` mirror in // `crates/contract/tests/sandbox/tee_verifier.rs`: the test serializes with that @@ -36,14 +34,14 @@ getrandom::register_custom_getrandom!(randomness_unsupported); derive(borsh::BorshSchema) )] pub enum StubResponse { - /// Return `VerificationResult::Verified` with this exact report. Tests that + /// Return [`VerificationResult::Verified`] with this exact report. Tests that /// want the post-DCAP checks to pass supply the report obtained from the /// real fixture quote. Verified(tee_verifier_interface::VerifiedReport), - /// Return `VerificationResult::Rejected` with this reason. + /// Return [`VerificationResult::Rejected`] with this reason. Rejected(String), /// Panic, simulating an unreachable / crashing verifier (the no-verdict - /// path that `mpc-contract` resolves via the yield timeout). + /// path that mpc-contract resolves via the yield timeout). Panic, } @@ -68,8 +66,9 @@ impl TestTeeVerifier { Self { response } } - /// Stub mirror of `tee_verifier::verify_quote`: ignores `quote`/`collateral` - /// and returns the canned response. Panics on `StubResponse::Panic`. + /// Stub mirror of the real `tee-verifier` contract's verify-quote method: + /// ignores the quote and collateral and returns the canned response. Panics + /// on [`StubResponse::Panic`]. #[result_serializer(borsh)] pub fn verify_quote( &self, From e86d9862d7270f9db9b2e728112a40e024fbe62c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 3 Jul 2026 11:59:59 +0200 Subject: [PATCH 08/26] test(contract): share StubResponse via a types crate, drop the mirror StubResponse was declared twice (stub contract + test mirror) kept aligned by a KEEP-IN-SYNC comment and a discriminant-pinning test, because importing the #[near] stub crate as a dep breaks cargo test --all-features (duplicate contract-ABI symbol + abi-feature unification; see PR #3664). Extract it into a new plain-lib crate test-tee-verifier-types that both the stub and the contract's test binary depend on. A non-#[near] crate emits no contract-ABI symbol, so it can be a dev-dep of mpc-contract without the collision, giving a real single source of truth: the mirror, the sync comment, and the stub_response_discriminants test are removed. Verified in nix: cargo test --no-run --all-features -p mpc-contract links cleanly (the step that regressed in #3664), and the sandbox tee_verifier suite passes driven through the shared type. --- Cargo.lock | 10 +++++ Cargo.toml | 2 + crates/contract/Cargo.toml | 1 + crates/contract/tests/sandbox/tee_verifier.rs | 38 +------------------ crates/test-tee-verifier-types/Cargo.toml | 23 +++++++++++ crates/test-tee-verifier-types/src/lib.rs | 30 +++++++++++++++ crates/test-tee-verifier/Cargo.toml | 7 +++- crates/test-tee-verifier/src/lib.rs | 28 +------------- 8 files changed, 74 insertions(+), 65 deletions(-) create mode 100644 crates/test-tee-verifier-types/Cargo.toml create mode 100644 crates/test-tee-verifier-types/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 861437f773..6209d4ceb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5884,6 +5884,7 @@ dependencies = [ "sha2 0.10.9", "signature", "tee-verifier-interface", + "test-tee-verifier-types", "test-utils", "thiserror 2.0.18", "threshold-signatures", @@ -11384,6 +11385,15 @@ dependencies = [ "getrandom 0.2.17", "near-sdk", "tee-verifier-interface", + "test-tee-verifier-types", +] + +[[package]] +name = "test-tee-verifier-types" +version = "3.13.0" +dependencies = [ + "borsh", + "tee-verifier-interface", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f03fbec84b..81edf6dda3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ members = [ "crates/test-parallel-contract", "crates/test-port-allocator", "crates/test-tee-verifier", + "crates/test-tee-verifier-types", "crates/test-utils", "crates/threshold-signatures", "crates/tls", @@ -77,6 +78,7 @@ tee-authority = { path = "crates/tee-authority" } tee-verifier-conversions = { path = "crates/tee-verifier-conversions" } tee-verifier-interface = { path = "crates/tee-verifier-interface" } test-port-allocator = { path = "crates/test-port-allocator" } +test-tee-verifier-types = { path = "crates/test-tee-verifier-types" } test-utils = { path = "crates/test-utils" } threshold-signatures = { path = "crates/threshold-signatures" } diff --git a/crates/contract/Cargo.toml b/crates/contract/Cargo.toml index 8c2929c197..86088004e6 100644 --- a/crates/contract/Cargo.toml +++ b/crates/contract/Cargo.toml @@ -140,6 +140,7 @@ rand_core = { workspace = true } rstest = { workspace = true } sha2 = { workspace = true } signature = { workspace = true } +test-tee-verifier-types = { workspace = true } test-utils = { workspace = true } threshold-signatures = { workspace = true } tokio = { workspace = true } diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 16e57bf52f..116ebeb6cf 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -25,32 +25,15 @@ use crate::sandbox::{ }, }; use anyhow::Result; -use borsh::BorshSerialize; use near_mpc_contract_interface::types::{self as dtos, Attestation}; use near_workspaces::{Account, Contract, Worker, network::Sandbox, types::NearToken}; +use test_tee_verifier_types::StubResponse; use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key, verified_report}; /// Blocks to fast-forward past the ~200-block yield-resume timeout so the /// runtime fires `on_attestation_verified`'s timeout branch. const YIELD_TIMEOUT_BLOCKS: u64 = 250; -/// Mirror of `test_tee_verifier::StubResponse`. Re-declared here (rather than -/// depending on the stub crate) so the test only needs its Borsh encoding to -/// initialize the deployed stub; the stub is a separate `#[near]` contract and -/// linking its crate into this test binary would collide on ABI symbols. -/// -/// KEEP THE VARIANT ORDER IN SYNC with `test_tee_verifier::StubResponse`: Borsh -/// encodes an enum as a u8 discriminant equal to the declaration index, so a -/// reorder on either side silently misroutes the response. `stub_response_discriminants` -/// below pins the indices so a divergence fails loudly. -#[expect(clippy::large_enum_variant)] -#[derive(BorshSerialize)] -enum StubResponse { - Verified(tee_verifier_interface::VerifiedReport), - Rejected(String), - Panic, -} - /// Deploys the stub verifier with the given response, initializes it, and votes /// it in as `mpc-contract`'s trusted verifier (all participants vote so the /// change crosses threshold). @@ -334,22 +317,3 @@ async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) - ); Ok(()) } - -/// Pins the Borsh discriminant of each [`StubResponse`] variant to its declaration -/// index. The deployed `test_tee_verifier::StubResponse` deserializes what this -/// mirror serializes, so a reorder on either side must fail loudly here rather -/// than silently misroute a response. `Verified` is index 0 by position (a -/// `VerifiedReport` fixture is not needed to guard the reorder that matters). -#[test] -fn stub_response_discriminants() { - assert_eq!( - borsh::to_vec(&StubResponse::Rejected(String::new())).unwrap()[0], - 1, - "Rejected must be Borsh discriminant 1" - ); - assert_eq!( - borsh::to_vec(&StubResponse::Panic).unwrap()[0], - 2, - "Panic must be Borsh discriminant 2" - ); -} diff --git a/crates/test-tee-verifier-types/Cargo.toml b/crates/test-tee-verifier-types/Cargo.toml new file mode 100644 index 0000000000..baa76ead72 --- /dev/null +++ b/crates/test-tee-verifier-types/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "test-tee-verifier-types" +version = { workspace = true } +license = { workspace = true } +edition = { workspace = true } + +# Wire types shared between the `test-tee-verifier` stub contract and the +# `mpc-contract` sandbox tests that drive it. A plain lib (no `#[near]`) so both +# a contract crate and a test binary can depend on it without the duplicate-ABI +# symbol / `--all-features` collision that importing the stub crate itself would +# cause (see docs / the mpc-contract sandbox tests). + +[features] +# Mirrors the stub's `abi` feature: derives the borsh schema on the wire types so +# the stub's ABI generation can include them. +abi = ["borsh/unstable__schema", "tee-verifier-interface/borsh-schema"] + +[dependencies] +borsh = { workspace = true } +tee-verifier-interface = { workspace = true } + +[lints] +workspace = true diff --git a/crates/test-tee-verifier-types/src/lib.rs b/crates/test-tee-verifier-types/src/lib.rs new file mode 100644 index 0000000000..634d0d1398 --- /dev/null +++ b/crates/test-tee-verifier-types/src/lib.rs @@ -0,0 +1,30 @@ +//! Wire types shared between the `test-tee-verifier` stub contract and the +//! `mpc-contract` sandbox tests that drive it. +//! +//! Kept in a plain (non-`#[near]`) crate so both a contract crate and a test +//! binary can depend on the same definition: importing the stub contract itself +//! would emit a duplicate contract-ABI symbol and unify its `abi` feature under +//! `cargo test --all-features`. + +use borsh::{BorshDeserialize, BorshSerialize}; + +/// What the stub verifier's verify-quote method should return, chosen by the +/// test at deploy time. +#[expect(clippy::large_enum_variant)] +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +#[cfg_attr( + all(feature = "abi", not(target_arch = "wasm32")), + derive(borsh::BorshSchema) +)] +pub enum StubResponse { + /// Return [`tee_verifier_interface::VerificationResult::Verified`] with this + /// exact report. Tests that want the post-DCAP checks to pass supply the + /// report obtained from the real fixture quote. + Verified(tee_verifier_interface::VerifiedReport), + /// Return [`tee_verifier_interface::VerificationResult::Rejected`] with this + /// reason. + Rejected(String), + /// Panic, simulating an unreachable / crashing verifier (the no-verdict path + /// that mpc-contract resolves via the yield timeout). + Panic, +} diff --git a/crates/test-tee-verifier/Cargo.toml b/crates/test-tee-verifier/Cargo.toml index f9612d5569..9a7a7beff8 100644 --- a/crates/test-tee-verifier/Cargo.toml +++ b/crates/test-tee-verifier/Cargo.toml @@ -17,12 +17,17 @@ crate-type = ["cdylib", "lib"] [features] # Enabled by `cargo near build` / `--all-features` for ABI generation, mirroring # the real `tee-verifier`: pulls in the borsh schema for the wire DTOs. -abi = ["borsh/unstable__schema", "tee-verifier-interface/borsh-schema"] +abi = [ + "borsh/unstable__schema", + "tee-verifier-interface/borsh-schema", + "test-tee-verifier-types/abi", +] [dependencies] borsh = { workspace = true } near-sdk = { workspace = true } tee-verifier-interface = { workspace = true } +test-tee-verifier-types = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] getrandom = { workspace = true, features = ["custom"] } diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs index eaafeebc2f..d36214b0e3 100644 --- a/crates/test-tee-verifier/src/lib.rs +++ b/crates/test-tee-verifier/src/lib.rs @@ -8,11 +8,10 @@ //! [`StubResponse::Rejected`] verdict, or a panic (the no-verdict / //! verifier-unreachable path). -use borsh::{BorshDeserialize, BorshSerialize}; use near_sdk::{env, near}; use tee_verifier_interface::{Collateral, QuoteBytes, VerificationResult, VerifierError}; +use test_tee_verifier_types::StubResponse; -// Match the real verifier's getrandom handling on wasm so the crate links. #[cfg(target_arch = "wasm32")] fn randomness_unsupported(_buf: &mut [u8]) -> Result<(), getrandom::Error> { Err(getrandom::Error::UNSUPPORTED) @@ -20,31 +19,6 @@ fn randomness_unsupported(_buf: &mut [u8]) -> Result<(), getrandom::Error> { #[cfg(target_arch = "wasm32")] getrandom::register_custom_getrandom!(randomness_unsupported); -/// What the stub's [`TestTeeVerifier::verify_quote`] should do, chosen by the -/// test at deploy time. -#[expect(clippy::large_enum_variant)] -// KEEP THE VARIANT ORDER IN SYNC with the `StubResponse` mirror in -// `crates/contract/tests/sandbox/tee_verifier.rs`: the test serializes with that -// copy and this contract deserializes with this one, so the Borsh discriminants -// (declaration index) must match. That mirror's `stub_response_discriminants` -// test pins the indices. -#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] -#[cfg_attr( - all(feature = "abi", not(target_arch = "wasm32")), - derive(borsh::BorshSchema) -)] -pub enum StubResponse { - /// Return [`VerificationResult::Verified`] with this exact report. Tests that - /// want the post-DCAP checks to pass supply the report obtained from the - /// real fixture quote. - Verified(tee_verifier_interface::VerifiedReport), - /// Return [`VerificationResult::Rejected`] with this reason. - Rejected(String), - /// Panic, simulating an unreachable / crashing verifier (the no-verdict - /// path that mpc-contract resolves via the yield timeout). - Panic, -} - #[derive(Debug)] #[near(contract_state)] pub struct TestTeeVerifier { From 5d54a7e069414ef01f57c71b32861c45a73789c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 3 Jul 2026 16:51:36 +0200 Subject: [PATCH 09/26] test(contract): tighten async attestation tests - revert_dstack_store unit tests: reuse create_node_id, compare the whole NodeAttestation (add PartialEq/Eq to NodeAttestation, VerifiedAttestation, MockAttestation, ValidatedDstackAttestation) instead of a field + a _ match - add a verified_report() test-utils fixture (mints the real report via verify_dcap_quote) and rework the OOG test onto the Verified path; it stays #[ignore]d pending the allowlist fixture setup (TODO(#3730)) - compress the sandbox tee_verifier tests: submit_dstack + setup_with_stub + assert_submission_cleaned_up helpers remove the repeated preamble/asserts; inline the trivial dstack_attestation()/tls_key() wrappers - switch the sandbox tests to unwrap() style (repo-wide majority) instead of Result<()>/? - assert the exact VerifierNotConfigured message via the error Display - gate/name has_pending_attestation via method_names::HAS_PENDING_ATTESTATION - extract TEST_COLLATERAL_STRING / SUBMIT_DEPOSIT consts; doc + intra-doc-link cleanups --- crates/contract/src/sandbox_test_methods.rs | 5 - crates/contract/src/tee/tee_state.rs | 39 +- .../tests/inprocess/attestation_submission.rs | 4 +- crates/contract/tests/sandbox/tee_verifier.rs | 356 +++++++----------- .../tests/sandbox/utils/mpc_contract.rs | 2 +- crates/mpc-attestation/src/attestation.rs | 8 +- .../src/method_names.rs | 4 + crates/test-tee-verifier/Cargo.toml | 9 - crates/test-tee-verifier/src/lib.rs | 14 +- crates/test-utils/src/attestation.rs | 20 +- 10 files changed, 181 insertions(+), 280 deletions(-) diff --git a/crates/contract/src/sandbox_test_methods.rs b/crates/contract/src/sandbox_test_methods.rs index f20652873b..63120a02ad 100644 --- a/crates/contract/src/sandbox_test_methods.rs +++ b/crates/contract/src/sandbox_test_methods.rs @@ -49,11 +49,6 @@ impl MpcContract { .expect("queue length must fit in u32 — bounded by MAX_PENDING_REQUEST_FAN_OUT") } - /// Whether an in-flight attestation entry exists for `account_id`. - /// - /// Used by the yield-resume sandbox tests to assert the pending entry is - /// cleaned up after a rejection, the yield timeout, or an out-of-gas - /// `resolve_verification`. pub fn has_pending_attestation(&self, account_id: AccountId) -> bool { self.pending_attestations.contains_key(&account_id) } diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index f2d5c746be..fa28110947 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -63,7 +63,7 @@ pub enum TeeValidationResult { }, } -#[derive(Debug, BorshSerialize, BorshDeserialize)] +#[derive(Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) @@ -1388,12 +1388,9 @@ mod tests { // returns the displaced original wrapped in `UpdatedExistingParticipant`). const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); let mut tee_state = TeeState::default(); + let account_id = "alice.near".parse().unwrap(); let tls_public_key = bogus_ed25519_public_key(); - let original_node = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - }; + let original_node = create_node_id(&account_id, &tls_public_key); tee_state .verify_and_store_mock( original_node.clone(), @@ -1401,28 +1398,29 @@ mod tests { TEE_UPGRADE_DURATION, ) .expect("initial insertion should succeed"); - let updated_node = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - }; + let updated_node = create_node_id(&account_id, &tls_public_key); let insertion = tee_state .verify_and_store_mock(updated_node, MockAttestation::Valid, TEE_UPGRADE_DURATION) .expect("update should succeed"); - assert_matches!( - insertion, - ParticipantInsertion::UpdatedExistingParticipant(_) - ); + + let original_entry = NodeAttestation { + node_id: original_node, + verified_attestation: VerifiedAttestation::Mock(MockAttestation::Valid), + }; + let ParticipantInsertion::UpdatedExistingParticipant(displaced) = &insertion else { + panic!("expected an update, got {insertion:?}"); + }; + assert_eq!(*displaced, original_entry); // When: the store is reverted. tee_state.revert_dstack_store(&tls_public_key, insertion); - // Then: the original (displaced) entry is back in place. + // Then: the whole original entry is back in place. let stored = tee_state .stored_attestations .get(&tls_public_key) .expect("original entry must be restored"); - assert_eq!(stored.node_id, original_node); + assert_eq!(*stored, original_entry); } #[test] @@ -1430,12 +1428,9 @@ mod tests { // Given: a brand-new attestation for `alice` (no prior entry displaced). const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); let mut tee_state = TeeState::default(); + let account_id = "alice.near".parse().unwrap(); let tls_public_key = bogus_ed25519_public_key(); - let node = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - }; + let node = create_node_id(&account_id, &tls_public_key); let insertion = tee_state .verify_and_store_mock(node, MockAttestation::Valid, TEE_UPGRADE_DURATION) .expect("insertion should succeed"); diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index 6563cbbb81..a974642015 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -285,9 +285,7 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { assert_eq!(stored_before, stored_after); } -/// **Test that a `Dstack` submission is rejected when no verifier is configured.** The -/// async path has nowhere to offload DCAP verification, so it must fail up front (before -/// registering a yield) rather than leave a submission that can never resolve. +/// Test that a `Dstack` submission is rejected when no verifier is configured. #[test] fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() { // Given: a running contract with no TEE verifier voted in. diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 116ebeb6cf..a968a8fc7b 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -1,16 +1,16 @@ -//! Sandbox tests for the async `submit_participant_info` flow that offloads DCAP -//! verification to a separate `tee-verifier` contract. +//! Sandbox tests for the async [`submit_participant_info`] flow that offloads +//! DCAP verification to a separate tee-verifier contract. //! -//! These deploy the `test-tee-verifier` stub (which returns a test-chosen -//! `verify_quote` answer instead of running real `dcap-qvl`) and point -//! `mpc-contract` at it via `vote_tee_verifier_change`, then exercise each -//! resolution branch of the yield-resume flow: +//! Each test deploys the `test-tee-verifier` stub, whose verify-quote returns a +//! response the test picks instead of running real `dcap-qvl`, votes it in as the +//! trusted verifier via [`vote_tee_verifier_change`], and then covers one branch +//! of the yield-resume flow: //! //! - verifier not configured → submission rejected, nothing stored. -//! - `Rejected` → submission fails, deposit refunded, no stored attestation. +//! - [`StubResponse::Rejected`] → submission fails, deposit refunded, nothing stored. //! - no-verdict (stub panics) → the ~200-block yield timeout cleans up. -//! - `resolve_verification` runs out of gas → its receipt rolls back atomically -//! and the same ~200-block timeout cleans up (no half-committed state). +//! - out-of-gas resolve → the receipt rolls back atomically and the same timeout +//! cleans up (no half-committed state). #![allow(non_snake_case)] use crate::sandbox::{ @@ -24,16 +24,20 @@ use crate::sandbox::{ }, }, }; -use anyhow::Result; -use near_mpc_contract_interface::types::{self as dtos, Attestation}; +use mpc_contract::errors::TeeError; +use near_mpc_contract_interface::types as dtos; use near_workspaces::{Account, Contract, Worker, network::Sandbox, types::NearToken}; use test_tee_verifier_types::StubResponse; use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key, verified_report}; /// Blocks to fast-forward past the ~200-block yield-resume timeout so the -/// runtime fires `on_attestation_verified`'s timeout branch. +/// runtime fires the yield-callback's timeout branch. const YIELD_TIMEOUT_BLOCKS: u64 = 250; +/// Deposit attached to a Dstack submission: covers storage on success, fully +/// refunded on failure. +const SUBMIT_DEPOSIT: NearToken = NearToken::from_near(1); + /// Deploys the stub verifier with the given response, initializes it, and votes /// it in as `mpc-contract`'s trusted verifier (all participants vote so the /// change crosses threshold). @@ -42,35 +46,84 @@ async fn deploy_and_trust_stub( contract: &Contract, participants: &[Account], response: StubResponse, -) -> Result { - let stub = worker.dev_deploy(stub_tee_verifier_contract()).await?; +) { + let stub = worker + .dev_deploy(stub_tee_verifier_contract()) + .await + .unwrap(); stub.call("new") .args_borsh(response) .transact() - .await? - .into_result()?; + .await + .unwrap() + .into_result() + .unwrap(); - // The contract only consumes `candidate_account_id`; the hash is a voter - // commitment, so any agreed value works for the test. + // Unchecked against the stub; voters just need to agree on the same hash. let expected_code_hash = [7u8; 32]; for account in participants { - vote_tee_verifier_change(account, contract, stub.id(), expected_code_hash).await?; + vote_tee_verifier_change(account, contract, stub.id(), expected_code_hash) + .await + .unwrap(); + } +} + +async fn setup_with_stub( + response: StubResponse, + init_config: Option, +) -> (Worker, Contract, Account, NearToken) { + let mut builder = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .with_sandbox_test_methods(); + if let Some(init_config) = init_config { + builder = builder.with_init_config(init_config); } - Ok(stub) + let SandboxTestSetup { + worker, + mpc_signer_accounts, + contract, + .. + } = builder.build().await; + deploy_and_trust_stub(&worker, &contract, &mpc_signer_accounts, response).await; + + let submitter = mpc_signer_accounts[0].clone(); + let balance_before = submitter.view_account().await.unwrap().balance; + (worker, contract, submitter, balance_before) } -fn dstack_attestation() -> Attestation { - mock_dto_dstack_attestation() +async fn submit_dstack(submitter: &Account, contract: &Contract) { + let _ = submit_participant_info_with_deposit( + submitter, + contract, + &mock_dto_dstack_attestation(), + &p2p_tls_key().into(), + SUBMIT_DEPOSIT, + ) + .await + .unwrap(); } -fn tls_key() -> dtos::Ed25519PublicKey { - p2p_tls_key().into() +/// Asserts a failed submission left no stored attestation, no pending entry, and +/// refunded the deposit. +async fn assert_submission_cleaned_up( + contract: &Contract, + submitter: &Account, + balance_before: NearToken, +) { + let stored = get_participant_attestation(contract, &p2p_tls_key().into()) + .await + .unwrap(); + assert!(stored.is_none(), "nothing should be stored on failure"); + assert!( + !has_pending_attestation(contract, submitter.id()).await.unwrap(), + "the pending entry must be cleaned up" + ); + assert_deposit_refunded(submitter, balance_before).await; } #[tokio::test] -async fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() -> Result<()> -{ - // Given: a running contract with no verifier voted in. +async fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() { + // Given: no verifier voted in. let SandboxTestSetup { mpc_signer_accounts, contract, @@ -80,229 +133,101 @@ async fn submit_participant_info__should_reject_dstack_when_verifier_not_configu .build() .await; - // When: a participant submits a Dstack attestation. + // When: a Dstack attestation is submitted. let result = submit_participant_info( &mpc_signer_accounts[0], &contract, - &dstack_attestation(), - &tls_key(), + &mock_dto_dstack_attestation(), + &p2p_tls_key().into(), ) - .await?; + .await + .unwrap(); - // Then: it fails synchronously with the VerifierNotConfigured error (the - // early return in submit_dstack_attestation, before any yield is registered), - // and nothing is stored. Assert the specific message so an unrelated failure - // (gas, encoding) can't pass as success. + // Then: it fails synchronously (before any yield), so the error is on the tx + // result. let err = result .into_result() .expect_err("Dstack submit must fail when no verifier is configured") .to_string(); + let expected_panic = format!( + "Smart contract panicked: {}", + TeeError::VerifierNotConfigured + ); assert!( - err.contains("No TEE verifier is configured"), - "expected VerifierNotConfigured, got: {err}" + err.contains(&expected_panic), + "expected {expected_panic:?}, got: {err}" ); - let stored = get_participant_attestation(&contract, &tls_key()).await?; + let stored = get_participant_attestation(&contract, &p2p_tls_key().into()) + .await + .unwrap(); assert!(stored.is_none(), "no attestation should be stored"); - Ok(()) } #[tokio::test] -async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_rejection() --> Result<()> { - // Given: a contract whose trusted verifier always rejects. - let SandboxTestSetup { - worker, - mpc_signer_accounts, - contract, - .. - } = SandboxTestSetup::builder() - .with_protocols(ALL_PROTOCOLS) - .with_sandbox_test_methods() - .build() - .await; - deploy_and_trust_stub( - &worker, - &contract, - &mpc_signer_accounts, - StubResponse::Rejected("test rejection".to_string()), - ) - .await?; +async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_rejection() { + // Given: a verifier that always rejects. + let (_worker, contract, submitter, balance_before) = + setup_with_stub(StubResponse::Rejected("test rejection".to_string()), None).await; - // When: a participant submits a Dstack attestation with a 1 NEAR deposit. - let submitter = &mpc_signer_accounts[0]; - let balance_before = submitter.view_account().await?.balance; - let _ = submit_participant_info_with_deposit( - submitter, - &contract, - &dstack_attestation(), - &tls_key(), - NearToken::from_near(1), - ) - .await?; + // When: a Dstack attestation is submitted. + submit_dstack(&submitter, &contract).await; - // Then: nothing is stored, the pending entry is cleaned up, and the deposit - // is refunded. The rejection resolves in the verifier's response receipt (a - // later receipt than the original call), so the outcome is observable in - // state rather than on the original transaction's result. - let stored = get_participant_attestation(&contract, &tls_key()).await?; - assert!(stored.is_none(), "a rejected quote must not be stored"); - assert!( - !has_pending_attestation(&contract, submitter.id()).await?, - "the pending entry must be cleaned up on rejection" - ); - assert_deposit_refunded(submitter, balance_before).await?; - Ok(()) + // Then: the submission is cleaned up. The rejection resolves in the verifier's + // response receipt, so the outcome is observable in state, not the tx result. + assert_submission_cleaned_up(&contract, &submitter, balance_before).await; } #[tokio::test] -async fn submit_participant_info__should_clean_up_on_verifier_crash() -> Result<()> { - // Given: a contract whose trusted verifier panics (no verdict). - let SandboxTestSetup { - worker, - mpc_signer_accounts, - contract, - .. - } = SandboxTestSetup::builder() - .with_protocols(ALL_PROTOCOLS) - .with_sandbox_test_methods() - .build() - .await; - deploy_and_trust_stub( - &worker, - &contract, - &mpc_signer_accounts, - StubResponse::Panic, - ) - .await?; - - // When: a participant submits, the verifier crashes (no resume lands), and - // the chain advances past the ~200-block yield timeout so the runtime fires - // `on_attestation_verified`'s timeout branch. - let submitter = &mpc_signer_accounts[0]; - let balance_before = submitter.view_account().await?.balance; - // Unlike the rejection test, the outer-tx result isn't asserted here: the - // failure only resolves when the yield times out, which `near-workspaces` - // does not surface on the original `transact()`, so we assert state instead. - let _ = submit_participant_info_with_deposit( - submitter, - &contract, - &dstack_attestation(), - &tls_key(), - NearToken::from_near(1), - ) - .await?; - worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await?; - - // Then: nothing is stored, and the timeout cleanup actually committed: the - // pending entry is gone and the deposit refunded. (Guards the regression - // where the cleanup was rolled back by a panic in the same receipt, leaking - // the entry and locking the account out of resubmitting.) - let stored = get_participant_attestation(&contract, &tls_key()).await?; - assert!( - stored.is_none(), - "nothing should be stored when the verifier crashes" - ); - assert!( - !has_pending_attestation(&contract, submitter.id()).await?, - "the pending entry must be cleaned up after the yield timeout" - ); - assert_deposit_refunded(submitter, balance_before).await?; - Ok(()) +async fn submit_participant_info__should_clean_up_on_verifier_crash() { + // Given: a verifier that panics, so no resume lands. + let (worker, contract, submitter, balance_before) = + setup_with_stub(StubResponse::Panic, None).await; + + // When: a submission times out (no verdict within the yield window). + submit_dstack(&submitter, &contract).await; + worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await.unwrap(); + + // Then: the timeout cleans up. Guards the regression where cleanup was rolled + // back by a panic in the same receipt, leaking the entry and wedging the account. + assert_submission_cleaned_up(&contract, &submitter, balance_before).await; } -// TODO(#3730): un-ignore once the fixture allowlist setup lands. To make -// `resolve_verification` actually run out of gas, execution must reach the -// expensive RTMR3 replay inside `verify_post_dcap_and_store` before exhausting -// the 1 TGas budget. That requires the post-DCAP checks to get *past* the -// allowlist gate first, i.e. the contract must have the fixture's MPC image hash -// (`image_digest()`), launcher compose hash (`launcher_compose_digest()`), and -// measurements voted in, and the submitter must use the fixture keys so the -// report-data binding matches. With an empty allowlist (as here) the check -// fails fast and cheap, so `resolve_verification` completes at 1 TGas and this -// re-tests the rejection path instead. Shares that setup with the (also pending) -// Verified happy-path test. +// TODO(#3730): un-ignore once the fixture allowlist setup lands. To OOG, +// `resolve_verification` must reach the heavy RTMR3 replay, which needs the +// post-DCAP allowlist checks to pass first (fixture image/launcher hashes and +// measurements voted in, submitter using the fixture keys). With an empty +// allowlist the check fails fast and `resolve_verification` completes at 1 TGas, +// so this would re-test the rejection path. #[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3730"] #[tokio::test] -async fn submit_participant_info__should_clean_up_when_resolve_verification_runs_out_of_gas() --> Result<()> { - // Given: a contract configured with a `resolve_verification` gas budget far - // too small to run the post-DCAP work and resume the yield. The stub returns - // `Verified` so `resolve_verification` enters `verify_post_dcap_and_store` - // (the heavy RTMR3-replay path), which then exhausts the 1 TGas budget and - // rolls the whole receipt back. A `Rejected` response would not work here: - // its branch is light enough to complete even at 1 TGas. +async fn submit_participant_info__should_clean_up_when_resolve_verification_runs_out_of_gas() { + // Given: a Verified stub and a resolve gas budget too small for the post-DCAP + // work, so that branch OOGs and rolls back. (Rejected is too light to OOG.) let init_config = dtos::InitConfig { resolve_verification_tera_gas: Some(1), ..Default::default() }; - let SandboxTestSetup { - worker, - mpc_signer_accounts, - contract, - .. - } = SandboxTestSetup::builder() - .with_protocols(ALL_PROTOCOLS) - .with_sandbox_test_methods() - .with_init_config(init_config) - .build() - .await; - deploy_and_trust_stub( - &worker, - &contract, - &mpc_signer_accounts, - StubResponse::Verified(verified_report()), - ) - .await?; + let (worker, contract, submitter, balance_before) = + setup_with_stub(StubResponse::Verified(verified_report()), Some(init_config)).await; - // When: a participant submits. The verifier answers, but `resolve_verification` - // runs out of gas before `promise_yield_resume`, so its whole receipt (the - // pending-entry removal and the refund included) rolls back and the yield is - // never resumed. - let submitter = &mpc_signer_accounts[0]; - let balance_before = submitter.view_account().await?.balance; - let _ = submit_participant_info_with_deposit( - submitter, - &contract, - &dstack_attestation(), - &tls_key(), - NearToken::from_near(1), - ) - .await?; + // When: a submission is made; resolve rolls back rather than resuming. + submit_dstack(&submitter, &contract).await; - // Distinguish this path from the rejection test: because - // `resolve_verification` rolled back rather than resuming, the pending entry - // is still present here. The rejection path would have removed it already. + // Then: unlike the rejection path, the entry is still pending before the + // timeout; the timeout then cleans up, proving an atomic rollback of a partial + // resolve receipt cannot wedge the account. assert!( - has_pending_attestation(&contract, submitter.id()).await?, + has_pending_attestation(&contract, submitter.id()).await.unwrap(), "pending entry must survive an OOG resolve_verification (cleanup is left to the timeout)" ); - - // Advancing past the ~200-block window fires `on_attestation_verified`'s - // timeout branch, which is what actually cleans up in this path. - worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await?; - - // Then: an out-of-gas `resolve_verification` is recovered like an unreachable - // verifier: nothing stored, the pending entry cleaned up by the timeout - // branch, and the deposit refunded. This is the guarantee that a partial - // `resolve_verification` receipt cannot leave a refunded-but-still-pending - // entry: the receipt is atomic, so the account is not wedged. - let stored = get_participant_attestation(&contract, &tls_key()).await?; - assert!( - stored.is_none(), - "nothing should be stored when resolve_verification runs out of gas" - ); - assert!( - !has_pending_attestation(&contract, submitter.id()).await?, - "the pending entry must be cleaned up by the yield timeout after an OOG resolve_verification" - ); - assert_deposit_refunded(submitter, balance_before).await?; - Ok(()) + worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await.unwrap(); + assert_submission_cleaned_up(&contract, &submitter, balance_before).await; } -/// Asserts the full 1 NEAR storage deposit was returned: the net spend since -/// `balance_before` is only gas, well under any fraction of the deposit. -async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) -> Result<()> { - let balance_after = account.view_account().await?.balance; +/// Asserts the full 1 NEAR storage deposit was returned: the net spend is only +/// gas, well under any fraction of the deposit. +async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) { + let balance_after = account.view_account().await.unwrap().balance; // Raw subtraction (not `saturating_sub`): if the contract over-refunds so // `balance_after > balance_before`, this underflows and panics rather than // clamping to 0 and silently passing. @@ -315,5 +240,4 @@ async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) - net_spent < gas_ceiling, "deposit should be fully refunded (net spent {net_spent} yoctoNEAR should be gas-only, < {gas_ceiling})" ); - Ok(()) } diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index ef6f5e484e..c36fcaf369 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -80,7 +80,7 @@ pub async fn has_pending_attestation( account_id: &AccountId, ) -> anyhow::Result { Ok(contract - .view("has_pending_attestation") + .view(method_names::HAS_PENDING_ATTESTATION) .args_json(serde_json::json!({ "account_id": account_id })) .await? .json()?) diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 752496fdb2..51b95f3b6c 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -37,7 +37,7 @@ pub enum Attestation { Mock(MockAttestation), } -#[derive(Clone, Debug, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) @@ -94,7 +94,9 @@ impl AcceptedAttestation { } #[expect(clippy::large_enum_variant)] -#[derive(Debug, Default, Clone, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] +#[derive( + Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, BorshDeserialize, BorshSerialize, +)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) @@ -198,7 +200,7 @@ impl MockAttestation { } } -#[derive(Clone, Debug, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index 7885f3d9b1..760b3e2333 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -98,6 +98,10 @@ pub const OS_MEASUREMENT_VOTES: &str = "os_measurement_votes"; pub const ALLOWED_OS_MEASUREMENTS: &str = "allowed_os_measurements"; pub const MIGRATION_INFO: &str = "migration_info"; +// Sandbox-test-only methods (gated behind the contract's `sandbox-test-methods` +// feature; never in the production wasm). +pub const HAS_PENDING_ATTESTATION: &str = "has_pending_attestation"; + // Deprecated methods #[deprecated(note = "https://github.com/near/mpc/issues/3079")] pub const REGISTER_FOREIGN_CHAIN_CONFIG: &str = "register_foreign_chain_config"; diff --git a/crates/test-tee-verifier/Cargo.toml b/crates/test-tee-verifier/Cargo.toml index 9a7a7beff8..1bfdf1d749 100644 --- a/crates/test-tee-verifier/Cargo.toml +++ b/crates/test-tee-verifier/Cargo.toml @@ -4,19 +4,10 @@ version = { workspace = true } license = { workspace = true } edition = { workspace = true } -# A test-only stub of the `tee-verifier` contract: `verify_quote` returns a -# response the test chose at init, instead of running real `dcap-qvl`. Lets the -# `mpc-contract` sandbox tests drive every branch of the async attestation flow -# (Verified / Rejected / post-DCAP failure / no-verdict) deterministically. -# Speaks the same `tee-verifier-interface` Borsh DTOs as the real verifier, so -# `mpc-contract` cannot tell them apart. - [lib] crate-type = ["cdylib", "lib"] [features] -# Enabled by `cargo near build` / `--all-features` for ABI generation, mirroring -# the real `tee-verifier`: pulls in the borsh schema for the wire DTOs. abi = [ "borsh/unstable__schema", "tee-verifier-interface/borsh-schema", diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs index d36214b0e3..2654507109 100644 --- a/crates/test-tee-verifier/src/lib.rs +++ b/crates/test-tee-verifier/src/lib.rs @@ -1,12 +1,7 @@ //! Test-only stub of the `tee-verifier` contract. //! -//! [`TestTeeVerifier::verify_quote`] ignores its inputs and returns a response -//! fixed at init time, instead of running real `dcap_qvl::verify`. This lets -//! `mpc-contract` sandbox tests drive every branch of the async attestation flow -//! deterministically: a [`StubResponse::Verified`] report (which the test -//! supplies so it matches the fixture's post-DCAP expectations), a -//! [`StubResponse::Rejected`] verdict, or a panic (the no-verdict / -//! verifier-unreachable path). +//! [`TestTeeVerifier::verify_quote`] returns a [`StubResponse`] fixed at init +//! time instead of running real `dcap_qvl::verify`. use near_sdk::{env, near}; use tee_verifier_interface::{Collateral, QuoteBytes, VerificationResult, VerifierError}; @@ -40,9 +35,8 @@ impl TestTeeVerifier { Self { response } } - /// Stub mirror of the real `tee-verifier` contract's verify-quote method: - /// ignores the quote and collateral and returns the canned response. Panics - /// on [`StubResponse::Panic`]. + /// Ignores its inputs and returns the configured response, panicking on + /// [`StubResponse::Panic`]. #[result_serializer(borsh)] pub fn verify_quote( &self, diff --git a/crates/test-utils/src/attestation.rs b/crates/test-utils/src/attestation.rs index 22c129f38e..105186314b 100644 --- a/crates/test-utils/src/attestation.rs +++ b/crates/test-utils/src/attestation.rs @@ -7,8 +7,10 @@ use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash, NodeIma use near_mpc_contract_interface::types::HexVec; use serde_json::Value; use sha2::{Digest, Sha256}; +use tee_verifier_interface::VerifiedReport; pub const TEST_TCB_INFO_STRING: &str = include_str!("../assets/tcb_info.json"); +pub const TEST_COLLATERAL_STRING: &str = include_str!("../assets/collateral.json"); pub const TEST_APP_COMPOSE_STRING: &str = include_str!("../assets/app_compose.json"); pub const TEST_APP_COMPOSE_WITH_SERVICES_STRING: &str = include_str!("../assets/app_compose_with_services.json"); @@ -58,8 +60,7 @@ pub fn image_digest() -> NodeImageHash { } pub fn collateral() -> Value { - let quote_collateral_json_string = include_str!("../assets/collateral.json"); - quote_collateral_json_string + TEST_COLLATERAL_STRING .parse() .expect("Quote collateral file is a valid json.") } @@ -98,8 +99,7 @@ pub fn near_account_key() -> near_sdk::PublicKey { pub fn mock_dstack_attestation() -> Attestation { let quote = quote(); - let collateral_json_string = include_str!("../assets/collateral.json"); - let collateral = mpc_attestation::collateral::collateral_from_str(collateral_json_string) + let collateral = mpc_attestation::collateral::collateral_from_str(TEST_COLLATERAL_STRING) .expect("collateral.json is valid collateral"); let tcb_info: TcbInfo = serde_json::from_str(TEST_TCB_INFO_STRING).unwrap(); @@ -108,13 +108,12 @@ pub fn mock_dstack_attestation() -> Attestation { } /// The [`VerifiedReport`] the real `tee-verifier` would return for the fixture -/// quote. Minted here by running the DCAP step at [`VALID_ATTESTATION_TIMESTAMP`] -/// (when the fixture collateral is valid), so tests can feed it to the stub -/// verifier's `Verified` response and drive the contract's post-DCAP path. -pub fn verified_report() -> tee_verifier_interface::VerifiedReport { +/// quote, produced by running the DCAP step at [`VALID_ATTESTATION_TIMESTAMP`] +/// (when the fixture collateral is valid). +pub fn verified_report() -> VerifiedReport { let dstack = DstackAttestation::new( quote(), - mpc_attestation::collateral::collateral_from_str(include_str!("../assets/collateral.json")) + mpc_attestation::collateral::collateral_from_str(TEST_COLLATERAL_STRING) .expect("collateral.json is valid collateral"), serde_json::from_str(TEST_TCB_INFO_STRING).unwrap(), ); @@ -125,8 +124,7 @@ pub fn verified_report() -> tee_verifier_interface::VerifiedReport { pub fn mock_dto_dstack_attestation() -> near_mpc_contract_interface::types::Attestation { let quote = HexVec::from(Vec::from(quote())); - let collateral_json_string = include_str!("../assets/collateral.json"); - let collateral = serde_json::from_str(collateral_json_string).unwrap(); + let collateral = serde_json::from_str(TEST_COLLATERAL_STRING).unwrap(); let tcb_info: near_mpc_contract_interface::types::TcbInfo = serde_json::from_str(TEST_TCB_INFO_STRING).unwrap(); From 3eb5ec7768ba8d36cced4c1afccdea060f143482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 13 Jul 2026 11:53:23 +0200 Subject: [PATCH 10/26] test(contract): port async attestation tests to the no-yield design The tests were written against the old yield-resume resolve_verification (pending_attestations + on_attestation_verified yield-callback + ~200-block timeout). #3766 replaced that with a plain promise chain (verify_quote -> .then(resolve_verification)) whose failures refund and fire a separate fail_attestation_submission receipt, with no pending state and no timeout. Rewrite the sandbox tests to match: - tee_verifier.rs: drop YIELD_TIMEOUT_BLOCKS / fast_forward and the pending-entry assertions; observe failures via the chain's receipt outcomes (ExecutionFinalResult::failures) instead of a queryable pending state. The verifier-crash test now expects an immediate VerifierUnavailable rather than a timeout cleanup. Keep the Verified happy-path and the OOG-resolve test #[ignore]d (they need fixture-allowlist + signer-key setup to reach a successful store) and point them at #3738 rather than the wrong #3730. - Remove has_pending_attestation (it read the removed pending_attestations map) from sandbox_test_methods.rs, its sandbox helper, and the HAS_PENDING_ATTESTATION method-name constant. - docs/design/attestation-verifier-contract.md: rewrite the submission-flow, handling-failures, state, API, and testing sections to the promise-chain design. Also reflow one pre-existing rustfmt violation in participants_gas.rs. --- crates/contract/src/sandbox_test_methods.rs | 6 +- .../tests/sandbox/participants_gas.rs | 11 +- crates/contract/tests/sandbox/tee_verifier.rs | 200 +++++--- .../tests/sandbox/utils/mpc_contract.rs | 11 - .../src/method_names.rs | 4 - docs/design/attestation-verifier-contract.md | 461 ++++++++---------- 6 files changed, 344 insertions(+), 349 deletions(-) diff --git a/crates/contract/src/sandbox_test_methods.rs b/crates/contract/src/sandbox_test_methods.rs index 63120a02ad..28997cd7af 100644 --- a/crates/contract/src/sandbox_test_methods.rs +++ b/crates/contract/src/sandbox_test_methods.rs @@ -11,7 +11,7 @@ use crate::MpcContract; use crate::primitives::ckd::CKDRequest; use crate::primitives::signature::SignatureRequest; -use near_sdk::{AccountId, near}; +use near_sdk::near; // Import the generated extension trait from near use crate::MpcContractExt; @@ -48,8 +48,4 @@ impl MpcContract { u32::try_from(len) .expect("queue length must fit in u32 — bounded by MAX_PENDING_REQUEST_FAN_OUT") } - - pub fn has_pending_attestation(&self, account_id: AccountId) -> bool { - self.pending_attestations.contains_key(&account_id) - } } diff --git a/crates/contract/tests/sandbox/participants_gas.rs b/crates/contract/tests/sandbox/participants_gas.rs index 4a56c67b31..f07e1a743a 100644 --- a/crates/contract/tests/sandbox/participants_gas.rs +++ b/crates/contract/tests/sandbox/participants_gas.rs @@ -289,8 +289,15 @@ async fn setup_test_env_with_state(n_participants: usize, running_state: bool) - let keyset = Keyset::new(EpochId::new(1), vec![key]); let domains = vec![domain]; let next_domain_id = domains.len() as u64 + 1; - init_contract_running(&contract, domains, next_domain_id, keyset, threshold_params, None) - .await; + init_contract_running( + &contract, + domains, + next_domain_id, + keyset, + threshold_params, + None, + ) + .await; } else { init_contract(&contract, threshold_params, None).await; } diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index a968a8fc7b..9d9c2dc5d5 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -4,13 +4,24 @@ //! Each test deploys the `test-tee-verifier` stub, whose verify-quote returns a //! response the test picks instead of running real `dcap-qvl`, votes it in as the //! trusted verifier via [`vote_tee_verifier_change`], and then covers one branch -//! of the yield-resume flow: +//! of the promise-chain flow. //! -//! - verifier not configured → submission rejected, nothing stored. -//! - [`StubResponse::Rejected`] → submission fails, deposit refunded, nothing stored. -//! - no-verdict (stub panics) → the ~200-block yield timeout cleans up. -//! - out-of-gas resolve → the receipt rolls back atomically and the same timeout -//! cleans up (no half-committed state). +//! A Dstack submission spawns `verify_quote` on the trusted verifier with +//! [`MpcContract::resolve_verification`] chained as its callback. There is no +//! yield-resume and no timeout: [`resolve_verification`] settles every outcome +//! synchronously within the same chain. +//! +//! - verifier not configured → the submit tx fails synchronously with +//! [`TeeError::VerifierNotConfigured`], nothing stored. +//! - [`StubResponse::Rejected`] → [`resolve_verification`] refunds the deposit and +//! fires `fail_attestation_submission`, which panics in a separate receipt to +//! fail the submitter's transaction; nothing stored. +//! - stub panics (verifier unreachable) → the callback observes a failed promise, +//! resolves to [`TeeError::VerifierUnavailable`], and fails the same way. +//! +//! On failure the top-level submit call still returns its chained promise, so the +//! failure surfaces on the chain's receipt outcomes +//! ([`ExecutionFinalResult::failures`]), not on the top-level tx result. #![allow(non_snake_case)] use crate::sandbox::{ @@ -19,21 +30,19 @@ use crate::sandbox::{ consts::ALL_PROTOCOLS, contract_build::stub_tee_verifier_contract, mpc_contract::{ - get_participant_attestation, has_pending_attestation, submit_participant_info, + get_participant_attestation, submit_participant_info, submit_participant_info_with_deposit, vote_tee_verifier_change, }, }, }; use mpc_contract::errors::TeeError; use near_mpc_contract_interface::types as dtos; -use near_workspaces::{Account, Contract, Worker, network::Sandbox, types::NearToken}; +use near_workspaces::{ + Account, Contract, Worker, network::Sandbox, result::ExecutionFinalResult, types::NearToken, +}; use test_tee_verifier_types::StubResponse; use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key, verified_report}; -/// Blocks to fast-forward past the ~200-block yield-resume timeout so the -/// runtime fires the yield-callback's timeout branch. -const YIELD_TIMEOUT_BLOCKS: u64 = 250; - /// Deposit attached to a Dstack submission: covers storage on success, fully /// refunded on failure. const SUBMIT_DEPOSIT: NearToken = NearToken::from_near(1); @@ -72,9 +81,7 @@ async fn setup_with_stub( response: StubResponse, init_config: Option, ) -> (Worker, Contract, Account, NearToken) { - let mut builder = SandboxTestSetup::builder() - .with_protocols(ALL_PROTOCOLS) - .with_sandbox_test_methods(); + let mut builder = SandboxTestSetup::builder().with_protocols(ALL_PROTOCOLS); if let Some(init_config) = init_config { builder = builder.with_init_config(init_config); } @@ -91,8 +98,8 @@ async fn setup_with_stub( (worker, contract, submitter, balance_before) } -async fn submit_dstack(submitter: &Account, contract: &Contract) { - let _ = submit_participant_info_with_deposit( +async fn submit_dstack(submitter: &Account, contract: &Contract) -> ExecutionFinalResult { + submit_participant_info_with_deposit( submitter, contract, &mock_dto_dstack_attestation(), @@ -100,24 +107,36 @@ async fn submit_dstack(submitter: &Account, contract: &Contract) { SUBMIT_DEPOSIT, ) .await - .unwrap(); + .unwrap() } -/// Asserts a failed submission left no stored attestation, no pending entry, and -/// refunded the deposit. -async fn assert_submission_cleaned_up( +/// Asserts a Dstack submission failed on the chain and left no committed state: +/// the failure surfaces on a receipt (`fail_attestation_submission` panics in its +/// own receipt), carries `expected_error`, nothing is stored, and the deposit is +/// refunded. +async fn assert_submission_failed_cleanly( + result: &ExecutionFinalResult, contract: &Contract, submitter: &Account, balance_before: NearToken, + expected_error: &TeeError, ) { + let failures = result.failures(); + assert!( + !failures.is_empty(), + "expected the promise chain to fail on a receipt, got: {result:#?}" + ); + let rendered = format!("{failures:?}"); + let expected = expected_error.to_string(); + assert!( + rendered.contains(&expected), + "expected a receipt failure containing {expected:?}, got: {rendered}" + ); + let stored = get_participant_attestation(contract, &p2p_tls_key().into()) .await .unwrap(); assert!(stored.is_none(), "nothing should be stored on failure"); - assert!( - !has_pending_attestation(contract, submitter.id()).await.unwrap(), - "the pending entry must be cleaned up" - ); assert_deposit_refunded(submitter, balance_before).await; } @@ -143,8 +162,8 @@ async fn submit_participant_info__should_reject_dstack_when_verifier_not_configu .await .unwrap(); - // Then: it fails synchronously (before any yield), so the error is on the tx - // result. + // Then: it fails synchronously (before any cross-contract call), so the error + // is on the top-level tx result, not a later receipt. let err = result .into_result() .expect_err("Dstack submit must fail when no verifier is configured") @@ -170,58 +189,121 @@ async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_re setup_with_stub(StubResponse::Rejected("test rejection".to_string()), None).await; // When: a Dstack attestation is submitted. - submit_dstack(&submitter, &contract).await; + let result = submit_dstack(&submitter, &contract).await; - // Then: the submission is cleaned up. The rejection resolves in the verifier's - // response receipt, so the outcome is observable in state, not the tx result. - assert_submission_cleaned_up(&contract, &submitter, balance_before).await; + // Then: resolve_verification refunds and fails the submission in a separate + // receipt; the failure is on the chain, not the top-level tx result. The + // stub wraps the reason in `VerifierError::DcapVerification`, whose Display + // prefixes "dcap verification failed: ". + assert_submission_failed_cleanly( + &result, + &contract, + &submitter, + balance_before, + &TeeError::QuoteRejected { + reason: "dcap verification failed: test rejection".to_string(), + }, + ) + .await; } #[tokio::test] -async fn submit_participant_info__should_clean_up_on_verifier_crash() { - // Given: a verifier that panics, so no resume lands. - let (worker, contract, submitter, balance_before) = +async fn submit_participant_info__should_fail_and_store_nothing_on_verifier_crash() { + // Given: a verifier that panics, so the verify_quote promise fails. + let (_worker, contract, submitter, balance_before) = setup_with_stub(StubResponse::Panic, None).await; - // When: a submission times out (no verdict within the yield window). - submit_dstack(&submitter, &contract).await; - worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await.unwrap(); + // When: a Dstack attestation is submitted. + let result = submit_dstack(&submitter, &contract).await; - // Then: the timeout cleans up. Guards the regression where cleanup was rolled - // back by a panic in the same receipt, leaking the entry and wedging the account. - assert_submission_cleaned_up(&contract, &submitter, balance_before).await; + // Then: the callback sees a failed promise, resolves to VerifierUnavailable, + // refunds, and fails the submission in a separate receipt. No timeout: the + // outcome settles synchronously within the same chain. + assert_submission_failed_cleanly( + &result, + &contract, + &submitter, + balance_before, + &TeeError::VerifierUnavailable, + ) + .await; } -// TODO(#3730): un-ignore once the fixture allowlist setup lands. To OOG, -// `resolve_verification` must reach the heavy RTMR3 replay, which needs the -// post-DCAP allowlist checks to pass first (fixture image/launcher hashes and -// measurements voted in, submitter using the fixture keys). With an empty -// allowlist the check fails fast and `resolve_verification` completes at 1 TGas, -// so this would re-test the rejection path. -#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3730"] +// TODO(#3738): un-ignore once the fixture allowlist setup lands. A Verified +// verdict routes through `verify_post_dcap_and_store`, whose allowlist checks +// (fixture image/launcher hashes and measurements voted in, submitter using the +// fixture keys) must pass before the attestation is stored. With an empty +// allowlist the post-DCAP check fails and the submission is rejected instead of +// stored, so the happy path cannot be exercised here yet. +#[ignore = "needs fixture allowlist setup to pass the post-DCAP checks; tracked in #3738"] #[tokio::test] -async fn submit_participant_info__should_clean_up_when_resolve_verification_runs_out_of_gas() { +async fn submit_participant_info__should_store_attestation_on_verified_quote() { + // Given: a verifier that returns the report the real verifier would produce + // for the fixture quote. + let (_worker, contract, submitter, balance_before) = + setup_with_stub(StubResponse::Verified(verified_report()), None).await; + + // When: a Dstack attestation is submitted. + let result = submit_dstack(&submitter, &contract).await; + + // Then: the chain succeeds and the attestation is stored; storage is charged + // and the excess deposit refunded (net spend is storage + gas, well under the + // full deposit). + assert!( + result.failures().is_empty(), + "the verified submission chain must succeed, got: {result:#?}" + ); + let stored = get_participant_attestation(&contract, &p2p_tls_key().into()) + .await + .unwrap(); + assert!(stored.is_some(), "a verified attestation must be stored"); + let balance_after = submitter.view_account().await.unwrap().balance; + assert!( + balance_after < balance_before, + "storage must be charged from the attached deposit" + ); +} + +// TODO(#3738): un-ignore once the fixture allowlist setup lands. To OOG, +// `resolve_verification` must reach the heavy RTMR3 replay in the post-DCAP +// checks, which needs the allowlist populated and the submitter using the fixture +// keys. With an empty allowlist the post-DCAP check fails fast and +// `resolve_verification` completes well under 1 TGas, re-testing the rejection +// path instead. Under the promise-chain model an OOG rolls the whole callback +// receipt back atomically: nothing is stored, the runtime refunds the attached +// deposit to the predecessor, and `fail_attestation_submission` never fires, so +// the chain still surfaces a failed receipt. No timeout is involved. +#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3738"] +#[tokio::test] +async fn submit_participant_info__should_fail_and_store_nothing_when_resolve_verification_runs_out_of_gas() + { // Given: a Verified stub and a resolve gas budget too small for the post-DCAP - // work, so that branch OOGs and rolls back. (Rejected is too light to OOG.) + // work, so that callback OOGs and rolls back atomically. let init_config = dtos::InitConfig { resolve_verification_tera_gas: Some(1), ..Default::default() }; - let (worker, contract, submitter, balance_before) = + let (_worker, contract, submitter, balance_before) = setup_with_stub(StubResponse::Verified(verified_report()), Some(init_config)).await; - // When: a submission is made; resolve rolls back rather than resuming. - submit_dstack(&submitter, &contract).await; + // When: a Dstack attestation is submitted. + let result = submit_dstack(&submitter, &contract).await; - // Then: unlike the rejection path, the entry is still pending before the - // timeout; the timeout then cleans up, proving an atomic rollback of a partial - // resolve receipt cannot wedge the account. + // Then: the callback receipt fails wholesale, nothing is stored, and the + // runtime refunds the attached deposit. Proves an OOG in resolve cannot commit + // partial state. + assert!( + !result.failures().is_empty(), + "an OOG resolve_verification must fail the chain, got: {result:#?}" + ); + let stored = get_participant_attestation(&contract, &p2p_tls_key().into()) + .await + .unwrap(); assert!( - has_pending_attestation(&contract, submitter.id()).await.unwrap(), - "pending entry must survive an OOG resolve_verification (cleanup is left to the timeout)" + stored.is_none(), + "nothing should be stored on an OOG resolve" ); - worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await.unwrap(); - assert_submission_cleaned_up(&contract, &submitter, balance_before).await; + assert_deposit_refunded(&submitter, balance_before).await; } /// Asserts the full 1 NEAR storage deposit was returned: the net spend is only diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index c36fcaf369..c481aae041 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -75,17 +75,6 @@ pub async fn submit_participant_info_with_deposit( .await?) } -pub async fn has_pending_attestation( - contract: &Contract, - account_id: &AccountId, -) -> anyhow::Result { - Ok(contract - .view(method_names::HAS_PENDING_ATTESTATION) - .args_json(serde_json::json!({ "account_id": account_id })) - .await? - .json()?) -} - pub async fn vote_tee_verifier_change( account: &Account, contract: &Contract, diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index 760b3e2333..7885f3d9b1 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -98,10 +98,6 @@ pub const OS_MEASUREMENT_VOTES: &str = "os_measurement_votes"; pub const ALLOWED_OS_MEASUREMENTS: &str = "allowed_os_measurements"; pub const MIGRATION_INFO: &str = "migration_info"; -// Sandbox-test-only methods (gated behind the contract's `sandbox-test-methods` -// feature; never in the production wasm). -pub const HAS_PENDING_ATTESTATION: &str = "has_pending_attestation"; - // Deprecated methods #[deprecated(note = "https://github.com/near/mpc/issues/3079")] pub const REGISTER_FOREIGN_CHAIN_CONFIG: &str = "register_foreign_chain_config"; diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 99f81572bb..f8aa29f3ac 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -59,92 +59,93 @@ flowchart LR ### Submission flow -`mpc-contract`'s [`submit_participant_info`][submit-participant-info] becomes asynchronous for Dstack attestations and uses the same yield-resume pattern already in place for `sign`, `request_app_private_key`, and `verify_foreign_transaction`. The method registers a yielded promise via [`env::promise_yield_create`][promise-yield-create] (going through the contract's existing [`enqueue_yield_request`][enqueue-yield-request] helper), stashes the resulting `data_id` in `pending_attestations`, and fires a cross-contract call to `tee-verifier::verify_quote` whose own `.then` callback (`resolve_verification`) bridges the verifier's response into a `promise_yield_resume`. Because `verify_quote` returns a `VerificationResult` value rather than failing the receipt (see [§Why a rejection is a value](#why-a-rejection-is-a-value-not-a-failed-receipt)), `resolve_verification`'s `#[callback_result]` distinguishes three outcomes: `Verified` runs the post-DCAP checks (RTMR3 replay, app-compose validation, measurement allowlist matching, report-data binding) and resumes; `Rejected` refunds and resumes **immediately** with the reason; and only `Err(PromiseError::Failed)` — the verifier unreachable or crashed — returns early, leaving cleanup to the ~200-block timeout. The yield-callback `on_attestation_verified` stays trivial, the same shape as the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]: it returns the resumed outcome to the caller, and only on the timeout (`Err(PromiseError::Failed)`) does it remove the pending entry and schedule a refund. Keeping the heavy work in `resolve_verification` makes an OOG in the yield-callback implausible, and an OOG in `resolve_verification` rolls back its whole receipt so the timeout still fires the trivial callback for cleanup. The exact branch-by-branch behavior is in [§Handling failures](#handling-failures) below. +`mpc-contract`'s [`submit_participant_info`][submit-participant-info] becomes asynchronous for Dstack attestations, but without yield-resume: it settles the submission entirely inside a single cross-contract promise chain. The method returns `Result, Error>`. A `Mock` attestation is verified and stored synchronously and returns `Ok(PromiseOrValue::Value(()))`. A `Dstack` attestation is handed to `submit_dstack_attestation`, which builds a `Promise` that calls `tee-verifier::verify_quote` and chains `resolve_verification` as its `.then` callback; the method returns `Ok(PromiseOrValue::Promise(...))`. The attached deposit is forwarded onto the callback via `.with_attached_deposit(env::attached_deposit())` rather than stashed in contract state, so `resolve_verification` can charge storage or refund from it directly. -The post-DCAP policy inputs are the same fields `mpc-contract` already holds today — the allowed-image-hash list, the per-account TLS / account public-key binding, and the stored-attestation map. No new policy state is introduced; the only state addition is the `pending_attestations` map described below, which is bookkeeping for the in-flight yield. +Because `verify_quote` returns a `VerificationResult` value rather than failing the receipt (see [§Why a rejection is a value](#why-a-rejection-is-a-value-not-a-failed-receipt)), `resolve_verification`'s `#[callback_result]` distinguishes three outcomes: `Ok(Verified)` runs the post-DCAP checks (RTMR3 replay, app-compose validation, measurement allowlist matching, report-data binding) and, on success, stores the attestation and charges storage; `Ok(Rejected)` returns a `QuoteRejected` error carrying the reason; and `Err(PromiseError::Failed)` — the verifier unreachable, panicked, or out of gas — returns `VerifierUnavailable`. On any error branch `resolve_verification` refunds the whole attached deposit and fires a *separate* `fail_attestation_submission` receipt whose panic fails the submitter's transaction. There is no yield, no `data_id`, no `pending_attestations` entry, and no ~200-block timeout: a failure settles immediately within the same promise chain. The exact branch-by-branch behavior is in [§Handling failures](#handling-failures) below. -The periodic re-validation path ([`re_verify`](../../crates/contract/src/tee/tee_state.rs)) does not call `dcap_qvl::verify` — it re-checks post-DCAP allowlist invariants against already-stored attestations — and is therefore unaffected by this design. It stays synchronous. +The post-DCAP policy inputs are the same fields `mpc-contract` already holds today — the allowed-image-hash list, the per-account TLS / account public-key binding, and the stored-attestation map. No new policy state is introduced, and the chain carries no bookkeeping map: everything `resolve_verification` needs travels as a `VerificationContext` borsh argument on the callback. + +The periodic re-validation path ([`re_verify`](../../crates/mpc-attestation/src/attestation.rs)) does not call `dcap_qvl::verify` — it re-checks post-DCAP allowlist invariants against already-stored attestations — and is therefore unaffected by this design. It stays synchronous. ```mermaid sequenceDiagram participant Op as Operator participant MPC as mpc-contract - participant State as State participant Ver as tee-verifier participant DCAP as dcap-qvl Op->>MPC: submit_participant_info(Dstack, tls_pk) - MPC->>MPC: promise_yield_create → data_id - MPC->>State: insert PendingAttestation { data_id, ... } - MPC->>Ver: Promise: verify_quote (chained .then resolve_verification) + MPC->>Ver: Promise: verify_quote(quote, collateral) + Note over MPC: .then resolve_verification(VerificationContext),
attached deposit forwarded to the callback Ver->>DCAP: verify(quote, collateral, now) - alt Verified (post-DCAP runs, then resumes) + alt Verified + store ok Ver-->>MPC: VerificationResult::Verified(report) MPC->>MPC: resolve_verification: verify_post_dcap_and_store vs fresh allowlist - MPC->>State: store on pass / refund on fail, remove PendingAttestation - MPC->>MPC: promise_yield_resume(data_id, AttestationResult) - MPC-->>Op: success or error, immediately - else Rejected (resumes immediately) - Ver-->>MPC: VerificationResult::Rejected(reason) - MPC->>MPC: resolve_verification: refund, remove PendingAttestation - MPC->>MPC: promise_yield_resume(data_id, AttestationResult::Err(reason)) - MPC-->>Op: error (carrying reason), immediately - else No verdict — verifier unreachable / silent for ~200 blocks - Note over MPC: resolve_verification (if it runs) only logs and returns.
Cleanup is left to the runtime yield-timeout. - MPC->>MPC: on_attestation_verified fires with Err(PromiseError::Failed) - MPC->>State: remove PendingAttestation, refund - MPC-->>Op: error + MPC->>MPC: charge_attestation_storage (refund excess) + MPC-->>Op: PromiseOrValue::Value(()) — success + else Verified + post-DCAP fail, or Rejected, or verifier unreachable + Ver-->>MPC: Verified(report) / Rejected(reason) / (no answer) + MPC->>MPC: resolve_verification produces Err (QuoteRejected / VerifierUnavailable) + MPC->>Op: refund whole attached deposit (this receipt) + MPC->>MPC: fail_attestation_submission receipt panics + MPC-->>Op: transaction fails (carrying the reason) end ``` #### Caller-side impact -The only caller of `submit_participant_info` in production is `mpc-node`'s `periodic_attestation_submission` task, which resubmits on a 1-hour cadence and on attestation-removal events. It already polls contract state to confirm the attestation is actually stored, with exponential backoff (100 ms → 60 s, capped at 12 h). That polling-based success criterion is what makes the sync→async change transparent. Under yield-resume the returned `Promise` also resolves with the actual outcome — success, a verifier-rejection error, or a post-DCAP-failure error (each as soon as the verifier answers), or a timeout error after ~200 blocks if it never does — so any future caller that wants to await the result synchronously can, without changing the contract. +The only caller of `submit_participant_info` in production is `mpc-node`'s `periodic_attestation_submission` task, which resubmits on a 1-hour cadence and on attestation-removal events. It already polls contract state to confirm the attestation is actually stored, with exponential backoff (100 ms → 60 s, capped at 12 h). That polling-based success criterion is what makes the sync→async change transparent. The returned `Promise` now resolves through the chain with the actual outcome — success, a verifier-rejection error, a post-DCAP-failure error, or a `VerifierUnavailable` error if the verifier never answers — so any future caller that wants to await the result synchronously can, without changing the contract. There is no ~200-block timeout error to account for: every path settles as soon as the verifier's receipt finishes. #### Handling failures -The first thing `submit_participant_info` does is insert a `PendingAttestation` entry, and that entry has to come back out once verification finishes — successfully or not. If a failure leaves the entry behind, the submitter's account is wedged: every future `submit_participant_info` call panics on the "already pending" guard, and the deposit stays locked because the refund is part of the cleanup the contract never got around to. +The submission produces no in-flight state to clean up: nothing is inserted into contract storage at submit time, so there is no pending entry that a failure could leave wedged and no "already pending" guard to trip on a resubmit. What a failure must still get right is the money — the attached deposit — and the caller-facing outcome. Both are handled in the single `.then` callback, `resolve_verification`. + +`resolve_verification` is a `#[private]` `#[payable]` method. It is `#[payable]` because the deposit rides forward onto it via `.with_attached_deposit`, so `env::attached_deposit()` inside the callback returns the amount the submitter attached. It observes the verifier's answer through `#[callback_result]` and reduces it to a `Result<(), Error>`: + +- `Ok(VerificationResult::Verified(report))` → `verify_post_dcap_and_store(&context, &report)`, which returns `Ok(())` on a clean store or an `Err` if a post-DCAP check or the storage charge fails. +- `Ok(VerificationResult::Rejected(reason))` → `Err(QuoteRejected { reason })`. +- `Err(promise_err)` → `Err(VerifierUnavailable)` — the verifier was unreachable, panicked, or ran out of gas. -That makes *where* the cleanup runs the central question, because NEAR offers two natural homes for "do something when the verifier responds" and they have very different failure modes. +On `Ok(())` the callback returns `PromiseOrValue::Value(())`; the attestation is stored and storage has been charged, with any excess deposit refunded inside `charge_attestation_storage`. -A **`.then` callback** is a normal cross-contract callback chained onto the verifier's promise. The runtime runs it in a fresh receipt once the verifier's receipt finishes; if it panics or runs out of gas, that receipt rolls back atomically and the chain ends. Because the receipt is independent of whatever yield is parked in parallel, its failure has no special effect on the submitter's call — the submitter just keeps waiting on the yield. +On `Err(err)` the callback does two things, in order, and the order is the whole point: -A **yield-callback** is different. When `submit_participant_info` calls `promise_yield_create`, it asks the runtime to *park* the submitter's call so the contract can return its result later. The runtime fires the named callback exactly once per `data_id` — either when something calls `promise_yield_resume(data_id, payload)`, or after ~200 blocks of silence with `Err(PromiseError::Failed)`. That single firing's return value is what the submitter eventually receives. There is no second invocation: an OOG inside the yield-callback rolls back its whole receipt and drops whatever cleanup it was meant to do, with no automatic retry. +1. `refund_to(&account_id, env::attached_deposit())` — refund the *entire* attached deposit in this receipt. +2. Schedule a *separate* `fail_attestation_submission` receipt via `Promise::new(current_account).function_call(...).as_return()`, and return it as `PromiseOrValue::Promise`. -The asymmetry decides the design. The work the verifier's *answer* unlocks — post-DCAP checks, the `stored_attestations` insert, the refund on rejection or post-DCAP failure, the pending-entry removal, the `promise_yield_resume` call — lives in the `.then` bridge `resolve_verification`. If it aborts mid-flight, the entire receipt rolls back atomically (including the resume), so the yield stays parked and the runtime's 200-block timeout still fires the yield-callback for cleanup — same recovery as "verifier never responded." `resolve_verification` resolves immediately on either answer it can act on: `Verified` (run post-DCAP, then resume) and `Rejected` (refund and resume with the reason). Only `Err(PromiseError::Failed)` — no verdict, the verifier was unreachable or crashed — is deliberately *not* resolved here: `resolve_verification` logs and returns early, routing that case to the timeout cleanup. The yield-callback `on_attestation_verified` is intentionally tiny: on resume, return the value to the caller; on its `Err(PromiseError::Failed)` branch — verifier unreachable or silent timeout — remove the pending entry and schedule a refund. +The refund and the failure live in different receipts deliberately. `fail_attestation_submission` is a tiny `#[private]` method that logs the reason and then `env::panic_str(&reason)` — its panic is what fails the submitter's transaction and surfaces the error. If that panic instead happened inside `resolve_verification` (the `#[handle_result]`-return-an-`Err` shape), it would roll back the whole callback receipt, discarding the refund transfer and any created promises along with it. Splitting them lets the refund commit in the first receipt while the second receipt fails the caller's transaction afterward. + +`verify_post_dcap_and_store` has its own commit-order subtlety. Unlike the synchronous `Mock` path — where returning an `Err` rolls back the entire method receipt and un-does any partial store — this callback receipt *commits regardless* of the `Err` it hands back to `resolve_verification`. So the store cannot be left to implicit rollback. `verify_post_dcap_and_store` snapshots `env::storage_usage()`, calls `verify_and_store_dstack`, then `charge_attestation_storage`; if the charge fails (`InsufficientDeposit`), it explicitly calls `tee_state.revert_dstack_store(tls_pk, insertion)` before returning the error, so the caller never gets storage for free plus a full refund. Walking every path the system can take: -- `resolve_verification` resumes (verifier returned `Verified` (post-DCAP pass or fail) or `Rejected`) → it cleaned up before resuming, caller receives the outcome. -- `resolve_verification` returns early (verifier unreachable — `Err(PromiseError::Failed)`) → timeout fires, yield-callback cleans up. -- `resolve_verification` aborts (OOG / panic mid-receipt) → timeout fires, yield-callback cleans up. -- `resolve_verification` never runs (verifier silent) → timeout fires, yield-callback cleans up. -- Yield-callback itself OOGs → genuine orphan. Its body is one `LookupMap::remove` plus one Promise schedule, small enough that an out-of-gas failure is implausible with a sensible gas budget. +- Verifier returned `Verified`, post-DCAP checks pass, storage charge succeeds → attestation stored, excess refunded, `Value(())`. Caller polls and sees the entry. +- Verifier returned `Verified` but a post-DCAP check fails → `verify_and_store_dstack` errors (nothing was stored), `resolve_verification` refunds and fires the fail receipt. +- Verifier returned `Verified`, post-DCAP passes, but the storage charge fails → `verify_post_dcap_and_store` reverts the store explicitly, returns the error, `resolve_verification` refunds and fires the fail receipt. +- Verifier returned `Rejected` → `QuoteRejected`, refund + fail receipt. +- Verifier unreachable / panicked / out of gas (`Err(PromiseError::Failed)`) → `VerifierUnavailable`, refund + fail receipt. This is handled right here, immediately; it is not deferred to any timeout. +- `resolve_verification` itself runs out of gas or panics mid-receipt → the whole callback receipt rolls back atomically, no partial commits, and the submitter's transaction fails. Because nothing was inserted at submit time, there is no orphaned state to reclaim. -This isn't a novel pattern in this codebase: `sign`, `request_app_private_key`, and `verify_foreign_transaction` already use the same split. The heavy work and the `promise_yield_resume` call live in [`pending_requests::resolve_yields_for`][pending-requests-mod] (invoked from `respond`, `respond_ckd`, and `respond_verify_foreign_tx`), and [`return_signature_and_clean_state_on_success`][sign-yield-callback] is a tiny yield-callback that just routes the outcome back to the caller and handles the no-response timeout. The attestation flow inherits the same orphan-proof argument: heavy work in a safely-recoverable receipt, cleanup in a callback small enough that gas budgeting makes its failure implausible. +The verifier still returns its verdict as a *value* rather than a failed receipt, so `#[callback_result]` can tell a definitive `Rejected` apart from `Err(PromiseError::Failed)` (no answer). Under the no-yield design both still lead to an immediate fail-and-refund in `resolve_verification`; the distinction only changes the error type and message the caller sees (`QuoteRejected` with the reason vs `VerifierUnavailable`), not whether cleanup is immediate. ### Contract state changes -The callback runs in a later block than `submit_participant_info`, as an independent contract invocation. Anything the callback still needs must be stashed in contract storage, in a new field: +`resolve_verification` runs in a later block than `submit_participant_info`, as an independent contract invocation, so anything it needs from the original call must travel with it. That is done not through contract storage but through a borsh callback argument: ```rust -pending_attestations: LookupMap +pub struct VerificationContext { + pub(crate) node_id: NodeId, + pub(crate) attestation: DstackAttestation, +} ``` -This map mirrors the other pending-request maps in `mpc-contract` ([`pending_signature_requests`][pending-requests-mod], `pending_ckd_requests`, `pending_verify_foreign_tx_requests`), but stores a single `PendingAttestation` per `AccountId` rather than a `Vec`: attestation submissions are 1-per-account. - -Each [`PendingAttestation`](#mpc-contractsubmit_participant_info) holds: +`VerificationContext` carries the submitter's `NodeId` (account id, TLS public key, and account public key — the binding the post-DCAP report-data check reproves) and the full `DstackAttestation` payload (RTMR3 event log, app-compose, report-data) that the post-DCAP checks consume. It is passed to `resolve_verification` as a `#[serializer(borsh)]` argument and is never written to contract state. The attached deposit is *not* part of it — it rides forward on the promise via `.with_attached_deposit`, so `env::attached_deposit()` in the callback yields the submitter's deposit directly. -- **The submitter's `Attestation::Dstack` payload** — the RTMR3 event log, app-compose, and report-data the post-DCAP checks consume. -- **The submitter's TLS public key** — the callback hashes it with the submitter's account public key and compares to the quote's `report_data` field, proving the enclave produced the quote for this specific submitter. -- **The attached deposit** — covers storage staking on success, refunded to the signer of the original `submit_participant_info` transaction on failure. `env::attached_deposit()` is not visible from the callback receipt, so the value is stashed at submit time and the recipient `AccountId` is the same one used to key the entry (set by `Self::assert_caller_is_signer()`). -- **`data_id: CryptoHash`** — the yield handle returned by [`env::promise_yield_create`][promise-yield-create]. The intermediate `.then` callback (`resolve_verification`) reads this back to call [`env::promise_yield_resume`][promise-yield-resume] with an `AttestationResult` after the post-DCAP checks have run. +This design adds **no** new attestation-related state. There is no `pending_attestations` map, no `PendingAttestation` struct, no `AttestationResult` enum, and no stashed `data_id` or deposit. The only new fields on the contract are `tee_verifier_account_id` and `tee_verifier_votes`, both from [§Voting on the trusted verifier](#voting-on-the-trusted-verifier-in-mpc-contract). -Entries are removed by `resolve_verification` on every branch where the verifier returned an answer — `Verified` (post-DCAP success or failure) and `Rejected`; only when the verifier gave no verdict (`Err(PromiseError::Failed)` — unreachable or crashed) does it return early and leave the entry in place. `on_attestation_verified` then removes the entry on its `Err(PromiseError::Failed)` branch, which covers that unreachable case and the no-response timeout. - -Notably absent from `PendingAttestation`: the **post-DCAP policy state** — allowed MPC image hashes, allowed launcher compose hashes, and accepted measurements. `resolve_verification` re-reads all of them from contract state, so any governance vote that adds or removes an entry mid-flight applies to verifications it overlaps. Snapshotting at request time would freeze each submission against stale policy — wrong default for a security control, where removing a compromised hash should take effect immediately. +Notably, the **post-DCAP policy state** — allowed MPC image hashes, allowed launcher compose hashes, and accepted measurements — is not snapshotted either. `verify_post_dcap_and_store` reads all of it fresh from contract state when the callback runs, so any governance vote that adds or removes an entry mid-flight applies to verifications it overlaps. Snapshotting at request time would freeze each submission against stale policy — wrong default for a security control, where removing a compromised hash should take effect immediately. ```mermaid sequenceDiagram @@ -154,8 +155,6 @@ sequenceDiagram participant Ver as tee-verifier Op->>MPC: submit_participant_info(Dstack, tls_pk) - MPC->>MPC: promise_yield_create → data_id - MPC->>MPC: insert PendingAttestation { data_id, ... } MPC->>Ver: Promise: verify_quote(...) (.then resolve_verification) Gov->>MPC: vote_add_image_hash(H) @@ -163,10 +162,8 @@ sequenceDiagram Ver-->>MPC: VerificationResult::Verified(report) MPC->>MPC: resolve_verification - MPC->>MPC: read allowlist (sees H) - MPC->>MPC: verify_post_dcap_and_store against fresh allowlist - MPC->>MPC: promise_yield_resume(data_id, AttestationResult) - MPC->>MPC: on_attestation_verified (trivial: return value) + MPC->>MPC: verify_post_dcap_and_store reads allowlist fresh (sees H) + MPC->>MPC: store on pass / refund + fail receipt on error ``` ## Crate layout @@ -281,13 +278,13 @@ pub enum VerificationResult { #### Why a rejection is a value, not a failed receipt -Encoding the rejection the obvious way — `Result` via `#[handle_result]`, so a rejection fails the receipt — loses the reason. NEAR's promise result is either `Successful(bytes)` or `Failed`, and **`Failed` carries no payload**: the caller's `#[callback_result]` sees a bare `Err(PromiseError::Failed)`, *identical* to the verifier being unreachable or out of gas. But `mpc-contract` must treat "the verifier rejected this quote" (definitive — refund and finish now) differently from "the verifier did not answer" (transient — wait for the yield timeout, the node resubmits). Returning the outcome as a *value* keeps them distinct, so `#[callback_result]` observes: +Encoding the rejection the obvious way — `Result` via `#[handle_result]`, so a rejection fails the receipt — loses the reason. NEAR's promise result is either `Successful(bytes)` or `Failed`, and **`Failed` carries no payload**: the caller's `#[callback_result]` sees a bare `Err(PromiseError::Failed)`, *identical* to the verifier being unreachable or out of gas. `mpc-contract` still wants to tell "the verifier rejected this quote" apart from "the verifier did not answer", so it can report the right error to the caller. Returning the outcome as a *value* keeps them distinct, so `#[callback_result]` observes: - `Ok(VerificationResult::Verified(report))` — quote valid; run post-DCAP checks. -- `Ok(VerificationResult::Rejected(reason))` — rejected; refund and resume **immediately**, with the reason. -- `Err(PromiseError::Failed)` — unreachable / panicked / timed out; the yield timeout cleans up. +- `Ok(VerificationResult::Rejected(reason))` — rejected; `resolve_verification` returns `QuoteRejected { reason }`. +- `Err(PromiseError::Failed)` — unreachable / panicked / out of gas; `resolve_verification` returns `VerifierUnavailable`. -This is the NEP-141 / NEP-171 shape — the meaningful outcome rides in the success payload, a failed receipt is reserved for "something broke" — and it preserves `mpc-contract`'s existing invariant that a rejection and a non-answer are never the same event. (near-sdk also refuses to serialize a bare `Result` return, so `VerificationResult` is a dedicated sum type.) +This is the NEP-141 / NEP-171 shape — the meaningful outcome rides in the success payload, a failed receipt is reserved for "something broke". Under the no-yield design both error branches lead to the same immediate refund-and-fail; keeping them distinct only changes the error type and message the caller receives. (near-sdk also refuses to serialize a bare `Result` return, so `VerificationResult` is a dedicated sum type.) ### Voting on the trusted verifier in `mpc-contract` @@ -301,7 +298,7 @@ The proposal payload is the pair `(candidate_account_id, expected_code_hash)`. ` #[near(serializers = [borsh])] pub struct VerifierChangeProposal { pub candidate_account_id: AccountId, - pub expected_code_hash: CryptoHash, + pub expected_code_hash: TeeVerifierCodeHash, } impl ProposalHashEncoding for VerifierChangeProposal { @@ -322,7 +319,7 @@ impl MpcContract { pub fn vote_tee_verifier_change( &mut self, candidate_account_id: AccountId, - expected_code_hash: CryptoHash, + expected_code_hash: TeeVerifierCodeHash, ); /// Withdraw the caller's current vote on any pending verifier-change @@ -348,8 +345,9 @@ pub struct MpcContract { /// Pending votes for changing `tee_verifier_account_id`. Each voter is an /// active MPC participant; each proposal is hashed from - /// `(candidate_account_id, expected_code_hash)`. - tee_verifier_votes: Votes, + /// `(candidate_account_id, expected_code_hash)`. `TeeVerifierVotes` is a thin + /// newtype wrapping the generic `Votes`. + tee_verifier_votes: TeeVerifierVotes, } ``` @@ -373,7 +371,7 @@ sequenceDiagram Note over MPC: tee_verifier_account_id = new (routing only,
no eviction) VerOld-->>MPC: VerificationResult::Verified(report) - MPC->>MPC: resolve_verification (post-DCAP + insert, as usual) + MPC->>MPC: resolve_verification (post-DCAP + store, as usual) Note over MPC: stored entry ages out within the
expiration window via re_verify Op->>MPC: submit_participant_info(Dstack, tls_pk) (next hourly resubmit) @@ -383,251 +381,186 @@ sequenceDiagram ### `mpc-contract::submit_participant_info` -The method splits across three receipts joined by yield-resume — see [§Submission flow](#submission-flow) above for the architecture. It returns `Result<(), Error>`, like the existing yield producers (`sign` / `request_app_private_key` / `verify_foreign_transaction`): `Mock` attestations are verified synchronously and return `Ok(())`; `Dstack` attestations register a yield via [`env::promise_yield_create`][promise-yield-create] and end on `enqueue_yield_request` so that its `env::promise_return` is the method's result. The runtime resolves the yield either when `resolve_verification` calls [`env::promise_yield_resume`][promise-yield-resume] or after ~200 blocks of silence. The post-DCAP checks and the `stored_attestations` insert live in `resolve_verification`, not in the yield-callback — that's what keeps `on_attestation_verified` trivial enough to match the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]. Draft implementation: +The method resolves a Dstack submission through a two-receipt promise chain: `verify_quote` on the verifier, then `resolve_verification` as its callback — see [§Submission flow](#submission-flow) above for the architecture. It returns `Result, Error>`. A `Mock` attestation is verified and stored synchronously and returns `Ok(PromiseOrValue::Value(()))`. A `Dstack` attestation is delegated to `submit_dstack_attestation`, which builds the chain and returns `Ok(PromiseOrValue::Promise(...))`. The attached deposit is forwarded onto `resolve_verification` via `.with_attached_deposit`, so the callback can charge storage or refund without any state being stashed at submit time. There is no `pending_attestations` insert and no "one in-flight per account" guard. Draft implementation: ```rust impl MpcContract { + #[payable] + #[handle_result] pub fn submit_participant_info( &mut self, attestation: Attestation, - tls_pk: Ed25519PublicKey, - ) -> Result<(), Error> { + tls_public_key: Ed25519PublicKey, + ) -> Result, Error> { // Existing convention: caller must be the signer of this transaction, // not a relayer or proxy. let account_id = Self::assert_caller_is_signer(); + let node_id = NodeId { account_id, tls_public_key, /* account_public_key */ }; + match attestation { - // Synchronous: no DCAP, verified and stored in this call. + // Synchronous: no DCAP, verified and stored in this call. A + // returned Err here rolls back the whole receipt. Attestation::Mock(mock) => { + let initial_storage = env::storage_usage(); self.tee_state.verify_and_store_mock(node_id, mock, ...)?; - Ok(()) - } - // Dstack: yield-resume. - Attestation::Dstack(dstack) => { - // One in-flight verification per AccountId. A duplicate submit - // before the previous one finishes (verifier response or - // runtime timeout) is rejected outright — same shape as - // duplicate sign requests. - if self.pending_attestations.contains_key(&account_id) { - return Err(TeeError::VerificationAlreadyPending.into()); - } - // Refuse until a verifier is voted in: there is no account to - // call `verify_quote` on. - let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { - return Err(TeeError::VerifierNotConfigured.into()); - }; - - let attached_deposit = env::attached_deposit(); - - // Cross-contract call to the verifier, built first so the - // `enqueue_yield_request` below stays the final host call. Its - // `.then` callback (`resolve_verification`) is the bridge that - // turns the verifier's response into a `promise_yield_resume` on - // the yield this method registers next. Quote/collateral are - // serialized by reference so `dstack` can move into the pending - // entry without cloning the (large) payload. - Promise::new(verifier_account_id) - .function_call( - "verify_quote".into(), - borsh::to_vec(&(&dstack.quote, &dstack.collateral)).unwrap(), - NearToken::from_yoctonear(0), - Gas::from_tgas(VERIFIER_GAS_TGAS), - ) - .then( - Self::ext(env::current_account_id()) - .with_static_gas(Gas::from_tgas(RESOLVE_GAS_TGAS)) - .resolve_verification(node_id.clone()), - ) - .detach(); - - // Reuses the existing `enqueue_yield_request` helper that wraps - // `env::promise_yield_create`. The helper allocates `data_id`, - // registers `on_attestation_verified` as the yield-callback, and - // surfaces `data_id` via the `insert` closure so we can stash it - // together with the rest of the `PendingAttestation` fields. It - // calls `env::promise_return` last, making the yield the method's - // result — so we just return `Ok(())` (no `value_return` that - // would override it). - self.enqueue_yield_request( - "on_attestation_verified", - borsh::to_vec(&account_id).unwrap(), - Gas::from_tgas(YIELD_CALLBACK_GAS_TGAS), - |this, data_id| { - this.pending_attestations.insert( - account_id.clone(), - PendingAttestation { - dstack, - tls_pk, - attached_deposit, - data_id, - }, - ); - }, - ); - Ok(()) + self.charge_attestation_storage(&node_id.account_id, initial_storage)?; + Ok(PromiseOrValue::Value(())) } + // Dstack: async via the verifier promise chain. + Attestation::Dstack(attestation) => Ok(PromiseOrValue::Promise( + self.submit_dstack_attestation(node_id, attestation)?, + )), } } - /// `.then` bridge between the verifier's cross-contract call and the - /// yield this submission registered. Owns every outcome where the verifier - /// *answered* (`Ok(VerificationResult::{Verified,Rejected})`): on - /// `Verified` it runs the post-DCAP checks against fresh policy state and - /// inserts into `stored_attestations` on success; on `Rejected` it skips - /// straight to the refund. Either way it removes the pending entry, - /// schedules a refund where the outcome is an error, and calls - /// `promise_yield_resume(data_id, AttestationResult)` as the LAST step of the - /// receipt — so a rejected quote is resolved *immediately*, not at the - /// timeout. Only `Err(PromiseError::Failed)` (no verdict — verifier - /// unreachable or crashed) is logged and returned early WITHOUT resuming or - /// removing the pending entry; `on_attestation_verified` owns that cleanup - /// on its `Err(PromiseError::Failed)` branch, so we must not race the - /// timeout for it. State mutations in this receipt are visible to the - /// yield-callback that fires next; if any line below `promise_yield_resume` - /// panicked or OOG'd, the entire receipt would roll back atomically (no - /// partial state commits) and the runtime's ~200-block yield-timeout would - /// still fire `on_attestation_verified` with `Err(PromiseError::Failed)` - /// for cleanup. + /// Builds the verifier promise chain. Fails the submit transaction + /// synchronously with `VerifierNotConfigured` if no verifier has been + /// voted in — there is no account to call `verify_quote` on. Otherwise it + /// calls `verify_quote` on the trusted verifier and chains + /// `resolve_verification` as its `.then` callback, forwarding the attached + /// deposit onto that callback. Quote/collateral are serialized by + /// reference so `attestation` can move into the `VerificationContext`. + fn submit_dstack_attestation( + &mut self, + node_id: NodeId, + attestation: DstackAttestation, + ) -> Result { + let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { + return Err(TeeError::VerifierNotConfigured.into()); + }; + + Ok(Promise::new(verifier_account_id) + .function_call( + "verify_quote".into(), + borsh::to_vec(&(&attestation.quote, &attestation.collateral)).unwrap(), + NearToken::from_yoctonear(0), + Gas::from_tgas(self.config.verifier_tera_gas), + ) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(Gas::from_tgas(self.config.resolve_verification_tera_gas)) + .with_attached_deposit(env::attached_deposit()) + .resolve_verification(VerificationContext { node_id, attestation }), + )) + } + + /// Verify-quote callback. `#[payable]` because the submitter's deposit + /// rides forward via `.with_attached_deposit`, so `env::attached_deposit()` + /// here is the amount they attached. `#[callback_result]` distinguishes the + /// three verifier outcomes: + /// + /// - `Ok(Verified)` → run post-DCAP checks and store. + /// - `Ok(Rejected)` → `QuoteRejected { reason }`. + /// - `Err(_)` → `VerifierUnavailable` (unreachable / panicked / OOG). /// - /// Same architectural shape as [`pending_requests::resolve_yields_for`][pending-requests-mod] - /// in the sign-request flow: the response-side function owns the state - /// mutation and the `promise_yield_resume` call; the yield-callback is - /// kept trivial. + /// On success returns `Value(())` (attestation stored, storage charged, + /// excess refunded). On any error it refunds the WHOLE attached deposit in + /// this receipt, then fires a SEPARATE `fail_attestation_submission` + /// receipt whose panic fails the caller's transaction — the split is what + /// lets the refund commit, since a panic in this receipt would roll it back + /// (and drop the created promises) along with the refund. #[private] + #[payable] pub fn resolve_verification( &mut self, - node_id: NodeId, - #[callback_result] result: Result, - ) { - let account_id = node_id.account_id.clone(); - let final_outcome = match result { - // No verdict: the verifier was unreachable, panicked, or ran out of - // gas. Do nothing — the runtime's yield-timeout will fire - // `on_attestation_verified` with `Err(PromiseError::Failed)` and - // clean up the pending entry there. We must not call - // `promise_yield_resume` here, or we'd race the timeout for - // ownership of the cleanup path. - Err(promise_err) => { - log!("verifier did not answer for {account_id}: {promise_err:?}"); - return; + #[serializer(borsh)] context: VerificationContext, + #[serializer(borsh)] + #[callback_result] + result: Result, + ) -> PromiseOrValue<()> { + let account_id = context.node_id.account_id.clone(); + + let attestation_result = match result { + Ok(VerificationResult::Verified(report)) => { + self.verify_post_dcap_and_store(&context, &report) } - // The verifier ran and rejected the quote. A definitive verdict: - // refund and resume now, with the reason, rather than waiting for - // the timeout. Ok(VerificationResult::Rejected(reason)) => { log!("verifier rejected quote for {account_id}: {reason}"); - AttestationResult::Err(format!("verifier: {reason}")) + Err(TeeError::QuoteRejected { reason: reason.to_string() }.into()) } - Ok(VerificationResult::Verified(report)) => { - let pending = self.pending_attestations.get(&account_id).expect( - "PendingAttestation must exist while resolve_verification holds the yield", - ); - // Post-DCAP checks operate on the verified report plus state held - // here. The allowlist is read fresh — governance votes mid-flight - // take effect. - match verify_post_dcap_and_store(pending, &report, self.allowlist_fresh()) { - Ok(()) => { - self.tee_state.stored_attestations.insert( - pending.tls_pk.clone(), - VerifiedAttestation::from((pending.clone(), report)), - ); - AttestationResult::Ok - } - Err(reason) => { - log!("post-DCAP check failed for {account_id}: {reason}"); - AttestationResult::Err(format!("post-DCAP: {reason}")) - } - } + Err(promise_err) => { + log!("verifier did not answer for {account_id}: {promise_err:?}"); + Err(TeeError::VerifierUnavailable.into()) } }; - let pending = self - .pending_attestations - .remove(&account_id) - .expect("PendingAttestation must exist while resolve_verification holds the yield"); - if matches!(final_outcome, AttestationResult::Err(_)) { - refund_deposit(&account_id, pending.attached_deposit); + match attestation_result { + Ok(()) => PromiseOrValue::Value(()), + Err(err) => { + refund_to(&account_id, env::attached_deposit()); + let promise = Promise::new(env::current_account_id()).function_call( + "fail_attestation_submission".into(), + borsh::to_vec(&err.to_string()).unwrap(), + NearToken::from_yoctonear(0), + Gas::from_tgas(self.config.fail_attestation_submission_tera_gas), + ); + PromiseOrValue::Promise(promise.as_return()) + } } - // `promise_yield_resume` must be the LAST host call in this receipt: - // anything after it could panic and roll back the state mutations above. - env::promise_yield_resume(&pending.data_id, borsh::to_vec(&final_outcome).unwrap()); } - /// Yield-callback. Same shape as the sign-request callback - /// [`return_signature_and_clean_state_on_success`][sign-yield-callback]: the - /// `Verified` and `Rejected` outcomes (every case where the verifier - /// answered) were already finalized by `resolve_verification` (which removed - /// the pending entry and scheduled any refund before calling - /// `promise_yield_resume`), so this body just returns the outcome to the - /// caller. - /// - /// The only branch that does real work is `Err(PromiseError::Failed)`, fired - /// by the runtime ~200 blocks after submit if no `promise_yield_resume` has - /// landed: the verifier was unreachable / never responded so - /// `resolve_verification` deliberately returned early, or it ran but rolled - /// back (OOM / panic). On that branch the pending entry is still present, so - /// it removes the entry and schedules a deposit refund. - #[private] - pub fn on_attestation_verified( + /// Runs the post-DCAP checks and stores the attestation for a `Verified` + /// response. The callback receipt commits regardless of the `Err` returned, + /// so a failed storage charge cannot rely on implicit rollback: it reverts + /// the store explicitly, or the caller would get storage for free plus a + /// full refund. + fn verify_post_dcap_and_store( &mut self, - account_id: AccountId, - #[callback_result] result: Result, - ) -> PromiseOrValue<()> { - let reason = match result { - Ok(AttestationResult::Ok) => return PromiseOrValue::Value(()), - Ok(AttestationResult::Err(reason)) => reason, - Err(_promise_err) => { - if let Some(pending) = self.pending_attestations.remove(&account_id) { - refund_deposit(&account_id, pending.attached_deposit); - log!("yield timeout for {account_id}: refunded and cleaned up"); - } - "verifier did not respond within yield-resume window".to_string() + context: &VerificationContext, + report: &VerifiedReport, + ) -> Result<(), Error> { + let account_id = &context.node_id.account_id; + let initial_storage = env::storage_usage(); + let insertion = self.tee_state.verify_and_store_dstack( + context.node_id.clone(), + &context.attestation, + report, + /* tee_upgrade_deadline_duration */ + )?; + + match self.charge_attestation_storage(account_id, initial_storage) { + Ok(()) => Ok(()), + Err(err) => { + self.tee_state + .revert_dstack_store(&context.node_id.tls_public_key, insertion); + Err(err) } - }; - // Fail the submitter's transaction from a SEPARATE receipt: a panic here - // would roll back the cleanup above. - let promise = Promise::new(env::current_account_id()).function_call( - "fail_attestation_submission".into(), - borsh::to_vec(&reason).unwrap(), - NearToken::from_near(0), - Gas::from_tgas(FAIL_GAS_TGAS), - ); - PromiseOrValue::Promise(promise.as_return()) + } } -} -#[derive(BorshSerialize, BorshDeserialize)] -pub enum AttestationResult { - Ok, - Err(String), + /// Separate receipt whose panic fails the caller's transaction after the + /// refund in `resolve_verification` has committed. + #[private] + pub fn fail_attestation_submission(#[serializer(borsh)] reason: String) { + log!("fail_attestation_submission: {reason}"); + env::panic_str(&reason); + } } ``` -`VERIFIER_GAS_TGAS`, `RESOLVE_GAS_TGAS`, and `YIELD_CALLBACK_GAS_TGAS` are placeholders until benchmarked. The verifier-side cost is dominated by ECDSA verifications and X.509-chain walking inside `dcap_qvl::verify::verify`. The bulk of the contract-side post-DCAP work — RTMR3 replay, app-compose validation, allowlist matching, report-data binding, plus the `stored_attestations.insert` — runs inside `resolve_verification`, so `RESOLVE_GAS_TGAS` gets the largest budget. `YIELD_CALLBACK_GAS_TGAS` can be conservatively small (on the order of 10 TGas with comfortable headroom): the yield-callback only does a `LookupMap::remove` and schedules a `Promise` on the timeout branch, and just returns a value on the resume branch. +`charge_attestation_storage` reads `env::attached_deposit()` itself: if the attached amount is less than the measured storage cost it returns `InsufficientDeposit`; otherwise it refunds the excess to the account via `refund_to`. `refund_to` is the generic refund helper (a detached `transfer` promise, no-op on zero). -The contract gains the following state fields: +`verifier_tera_gas`, `resolve_verification_tera_gas`, and `fail_attestation_submission_tera_gas` are unbenchmarked estimates until measured. The verifier-side cost (`verifier_tera_gas`) is dominated by ECDSA verifications and X.509-chain walking inside `dcap_qvl::verify::verify`, so it gets the largest budget. `resolve_verification_tera_gas` covers the contract-side post-DCAP work — RTMR3 replay, app-compose validation, allowlist matching, report-data binding — plus the `verify_and_store_dstack` insert and the storage charge. `fail_attestation_submission_tera_gas` can be tiny (a couple of TGas): the method only logs and panics. -```rust -pub struct MpcContract { - // ... existing fields, including tee_verifier_account_id and - // tee_verifier_votes from §Voting on the trusted verifier ... - pending_attestations: LookupMap, -} +### Contract state changes summary -pub struct PendingAttestation { - pub dstack: DstackAttestation, - pub tls_pk: Ed25519PublicKey, - pub attached_deposit: NearToken, - pub data_id: CryptoHash, -} -``` +No new attestation state fields. The chain carries a `VerificationContext { node_id, attestation }` as a borsh callback argument; nothing new is written to storage. The only new fields on the contract are `tee_verifier_account_id` and `tee_verifier_votes` from [§Voting on the trusted verifier](#voting-on-the-trusted-verifier-in-mpc-contract). ## Testing -The yield-resume split adds four resolution branches the synchronous version never had. Three do their work in `resolve_verification`, each from a distinct verifier answer: `Verified` + post-DCAP pass (store + resume `Ok`), `Verified` + post-DCAP fail (refund + resume `Err`), and `Rejected` (refund + resume `Err`, immediately — the path that recovers the synchronous-rejection behavior the split would otherwise lose). The fourth lives in `on_attestation_verified`, on its `Err(PromiseError::Failed)` branch, reached when the verifier gave no verdict — unreachable, panicked, or no resume landed within ~200 blocks (verifier silent, or a `resolve_verification` receipt that rolled back). That no-verdict case re-enters `resolve_verification`, which logs and returns early without resuming, so its cleanup happens in `on_attestation_verified`. Each branch needs test coverage, and exercising them requires the verifier to return specific answers on demand — a `Verified` or `Rejected` value for the three `resolve_verification` branches, and for the no-verdict path either an unreachable account or the test driver advancing the chain past the yield-resume window without resuming. +The no-yield chain adds a handful of resolution branches the synchronous version never had, all inside `resolve_verification` and the helper it delegates to: + +- **Verifier not configured** — `Dstack` submit while `tee_verifier_account_id` is `None` fails *synchronously* with `VerifierNotConfigured`; the submit transaction itself errors, no promise is scheduled. +- **Verified + store happy path** — `verify_quote` returns `Verified`, post-DCAP passes, storage charged, excess refunded, `Value(())`. The attestation is present in state afterward. +- **Verified + post-DCAP fail** — `verify_and_store_dstack` errors; `resolve_verification` refunds the whole deposit and fires the `fail_attestation_submission` receipt; nothing is stored. +- **Verified + insufficient deposit** — post-DCAP passes but `charge_attestation_storage` returns `InsufficientDeposit`; `verify_post_dcap_and_store` reverts the store explicitly, so state is unchanged; refund + fail receipt. +- **Rejected → fail + refund** — `verify_quote` returns `Rejected`; `resolve_verification` returns `QuoteRejected` carrying the reason; refund + fail receipt. +- **Verifier unreachable → `VerifierUnavailable`** — the callback observes `Err(PromiseError::Failed)`; refund + fail receipt. +- **OOG in `resolve_verification` rolls back atomically** — an out-of-gas or panic mid-callback rolls back the whole receipt (no partial store, no partial refund) and fails the caller's transaction; because nothing was inserted at submit time, there is no orphaned state to reclaim. The verifier-rotation design changes the test surface in three ways. First, the expiration window itself: an entry whose `expiry_timestamp_seconds` is in the past must be rejected by `re_verify` even when every post-DCAP allowlist invariant still holds, and an entry within the (shortened) window must still pass — this is the existing expiry check, now exercised against the lowered `DEFAULT_EXPIRATION_DURATION_SECONDS`. Second, rotation routing: after `vote_tee_verifier_change` crosses threshold, the next `submit_participant_info` must call `verify_quote` on the new `tee_verifier_account_id`, and existing stored entries must remain present (no purge) until they expire. Third, the in-flight case: a verification scheduled against the old verifier that resolves after the vote crosses threshold must still be stored as a normal entry — it is not treated specially and ages out via the same expiration window as any other entry. -To make that practical, we introduce a stub `tee-verifier` crate: same `tee-verifier-interface` DTOs as the real verifier, but `verify_quote` returns whatever `VerificationResult` (`Verified` or `Rejected`) the test asks for — and a stub that panics, or an undeployed account, covers the no-verdict path. Sandbox tests deploy the stub like any other verifier candidate — lock its account, then call `vote_tee_verifier_change` from the test setup to point `mpc-contract` at the stub. This runs the same code path as production; nothing in `mpc-contract` knows or cares whether it's talking to the real verifier or the stub. +To make that practical, we introduce a stub `tee-verifier` crate: same `tee-verifier-interface` DTOs as the real verifier, but `verify_quote` returns whatever `VerificationResult` (`Verified` or `Rejected`) the test asks for — and a stub that panics, or an undeployed account, covers the `VerifierUnavailable` path. Sandbox tests deploy the stub like any other verifier candidate — lock its account, then call `vote_tee_verifier_change` from the test setup to point `mpc-contract` at the stub. This runs the same code path as production; nothing in `mpc-contract` knows or cares whether it's talking to the real verifier or the stub. E2E tests in `crates/e2e-tests` deploy either the real `tee-verifier` (when the test wants real `dcap-qvl` against a fixture quote) or the stub (for everything else). The change is one extra `deploy` call in the setup helper. @@ -636,17 +569,9 @@ E2E tests in `crates/e2e-tests` deploy either the real `tee-verifier` (when the [nep-509]: https://github.com/near/NEPs/blob/master/neps/nep-0509.md [re-verify]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/mpc-attestation/src/attestation.rs#L93 [periodic-attestation-submission]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/tee/remote_attestation.rs#L140 -[attestation-resubmission-interval]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/run.rs#L43 -[attestation-attempts-metric]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/metrics.rs#L364 [verify-tee]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1543 [clean-invalid-attestations]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1646 -[clean-foreign-chain-data]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1669 [monitor-attestation-removal]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/tee/remote_attestation.rs#L209 [submit-participant-info]: https://github.com/near/mpc/blob/efe49230bb66854c55bba080e7610e42f9221506/crates/contract/src/lib.rs#L754-L782 [launcher-pattern]: https://github.com/near/mpc/blob/efe49230bb66854c55bba080e7610e42f9221506/docs/tee-lifecycle.md#upgrade -[slack-launcher-discussion]: https://nearone.slack.com/archives/C0B12RKBSAV/p1777897902903889 -[promise-yield-create]: https://docs.rs/near-sdk/5.26.1/near_sdk/env/fn.promise_yield_create.html -[promise-yield-resume]: https://docs.rs/near-sdk/5.26.1/near_sdk/env/fn.promise_yield_resume.html -[enqueue-yield-request]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L301-L323 -[pending-requests-mod]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/pending_requests.rs -[sign-yield-callback]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1999-L2023 +[slack-launcher-discussion]: https://nearone.slack.com/archives/C0B12RKBSAV/p1777897902903889 \ No newline at end of file From c7c141a230aaa0f5d2ade3f8bfafeea8a28cfbf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 13 Jul 2026 12:54:01 +0200 Subject: [PATCH 11/26] docs: add trailing newline to attestation-verifier-contract.md editorconfig-checker requires a final newline (insert_final_newline); the doc rewrite left the file without one, failing Fast CI checks. --- docs/design/attestation-verifier-contract.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index f8aa29f3ac..3ddb42ca58 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -574,4 +574,4 @@ E2E tests in `crates/e2e-tests` deploy either the real `tee-verifier` (when the [monitor-attestation-removal]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/tee/remote_attestation.rs#L209 [submit-participant-info]: https://github.com/near/mpc/blob/efe49230bb66854c55bba080e7610e42f9221506/crates/contract/src/lib.rs#L754-L782 [launcher-pattern]: https://github.com/near/mpc/blob/efe49230bb66854c55bba080e7610e42f9221506/docs/tee-lifecycle.md#upgrade -[slack-launcher-discussion]: https://nearone.slack.com/archives/C0B12RKBSAV/p1777897902903889 \ No newline at end of file +[slack-launcher-discussion]: https://nearone.slack.com/archives/C0B12RKBSAV/p1777897902903889 From dd57e033d5ca98f1ccbbdf21c66d010f0c4ad060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 13 Jul 2026 13:57:05 +0200 Subject: [PATCH 12/26] test(contract): address pre-review findings on async attestation tests - test-utils/Cargo.toml: sort tee-verifier-interface into dependency order (fixes the cargo-sort Fast CI failure) - test-tee-verifier/Cargo.toml: ignore borsh in cargo-shear (used only via the abi feature, like the sibling tee-verifier crate), avoiding a --deny-warnings failure - test-tee-verifier-types: reword the StubResponse::Panic doc comment to the no-yield flow (it described the removed yield timeout) - tee_verifier.rs: bound the ignored happy-path test's net spend on both sides so a wrongly-retained deposit fails; note why the failure assertion substring-matches; retarget the ignored tests at the fixture follow-up (#3787) --- crates/contract/tests/sandbox/tee_verifier.rs | 26 ++++++++++++++----- crates/test-tee-verifier-types/src/lib.rs | 4 +-- crates/test-tee-verifier/Cargo.toml | 3 +++ crates/test-utils/Cargo.toml | 2 +- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 9d9c2dc5d5..9ac0c400e0 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -126,6 +126,8 @@ async fn assert_submission_failed_cleanly( !failures.is_empty(), "expected the promise chain to fail on a receipt, got: {result:#?}" ); + // Substring-match: near-workspaces keeps `ExecutionOutcome.status` + // `pub(crate)`, so the error is only reachable via the Debug dump. let rendered = format!("{failures:?}"); let expected = expected_error.to_string(); assert!( @@ -229,13 +231,13 @@ async fn submit_participant_info__should_fail_and_store_nothing_on_verifier_cras .await; } -// TODO(#3738): un-ignore once the fixture allowlist setup lands. A Verified +// TODO(#3787): un-ignore once the fixture allowlist setup lands. A Verified // verdict routes through `verify_post_dcap_and_store`, whose allowlist checks // (fixture image/launcher hashes and measurements voted in, submitter using the // fixture keys) must pass before the attestation is stored. With an empty // allowlist the post-DCAP check fails and the submission is rejected instead of // stored, so the happy path cannot be exercised here yet. -#[ignore = "needs fixture allowlist setup to pass the post-DCAP checks; tracked in #3738"] +#[ignore = "needs fixture allowlist setup to pass the post-DCAP checks; tracked in #3787"] #[tokio::test] async fn submit_participant_info__should_store_attestation_on_verified_quote() { // Given: a verifier that returns the report the real verifier would produce @@ -247,8 +249,8 @@ async fn submit_participant_info__should_store_attestation_on_verified_quote() { let result = submit_dstack(&submitter, &contract).await; // Then: the chain succeeds and the attestation is stored; storage is charged - // and the excess deposit refunded (net spend is storage + gas, well under the - // full deposit). + // and the excess deposit refunded, so net spend is storage + gas, well under + // the full deposit. assert!( result.failures().is_empty(), "the verified submission chain must succeed, got: {result:#?}" @@ -257,14 +259,24 @@ async fn submit_participant_info__should_store_attestation_on_verified_quote() { .await .unwrap(); assert!(stored.is_some(), "a verified attestation must be stored"); + + // Bound net spend both sides: storage was charged (> 0), but the excess was + // refunded (< floor). The upper bound catches a wrongly-retained deposit. let balance_after = submitter.view_account().await.unwrap().balance; + let net_spent = balance_before.as_yoctonear() - balance_after.as_yoctonear(); + let refund_floor = NearToken::from_millinear(100).as_yoctonear(); assert!( - balance_after < balance_before, + net_spent > 0, "storage must be charged from the attached deposit" ); + assert!( + net_spent < refund_floor, + "excess deposit must be refunded (net spent {net_spent} yoctoNEAR should be \ + storage + gas, < {refund_floor}); a retained {SUBMIT_DEPOSIT} deposit would exceed this" + ); } -// TODO(#3738): un-ignore once the fixture allowlist setup lands. To OOG, +// TODO(#3787): un-ignore once the fixture allowlist setup lands. To OOG, // `resolve_verification` must reach the heavy RTMR3 replay in the post-DCAP // checks, which needs the allowlist populated and the submitter using the fixture // keys. With an empty allowlist the post-DCAP check fails fast and @@ -273,7 +285,7 @@ async fn submit_participant_info__should_store_attestation_on_verified_quote() { // receipt back atomically: nothing is stored, the runtime refunds the attached // deposit to the predecessor, and `fail_attestation_submission` never fires, so // the chain still surfaces a failed receipt. No timeout is involved. -#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3738"] +#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3787"] #[tokio::test] async fn submit_participant_info__should_fail_and_store_nothing_when_resolve_verification_runs_out_of_gas() { diff --git a/crates/test-tee-verifier-types/src/lib.rs b/crates/test-tee-verifier-types/src/lib.rs index 634d0d1398..06e5344df8 100644 --- a/crates/test-tee-verifier-types/src/lib.rs +++ b/crates/test-tee-verifier-types/src/lib.rs @@ -24,7 +24,7 @@ pub enum StubResponse { /// Return [`tee_verifier_interface::VerificationResult::Rejected`] with this /// reason. Rejected(String), - /// Panic, simulating an unreachable / crashing verifier (the no-verdict path - /// that mpc-contract resolves via the yield timeout). + /// Panic, simulating an unreachable or crashing verifier: the verify-quote + /// receipt fails, which mpc-contract reports as the verifier being unavailable. Panic, } diff --git a/crates/test-tee-verifier/Cargo.toml b/crates/test-tee-verifier/Cargo.toml index 1bfdf1d749..9eb2ca223c 100644 --- a/crates/test-tee-verifier/Cargo.toml +++ b/crates/test-tee-verifier/Cargo.toml @@ -4,6 +4,9 @@ version = { workspace = true } license = { workspace = true } edition = { workspace = true } +[package.metadata.cargo-shear] +ignored = ["borsh"] + [lib] crate-type = ["cdylib", "lib"] diff --git a/crates/test-utils/Cargo.toml b/crates/test-utils/Cargo.toml index 909b3dea9d..94ee48200c 100644 --- a/crates/test-utils/Cargo.toml +++ b/crates/test-utils/Cargo.toml @@ -8,13 +8,13 @@ edition = { workspace = true } cargo-near-build = { workspace = true } hex = { workspace = true } mpc-attestation = { workspace = true, features = ["test-utils", "local-verify"] } -tee-verifier-interface = { workspace = true } mpc-primitives = { workspace = true } near-mpc-contract-interface = { workspace = true } near-sdk = { workspace = true, features = ["non-contract-usage"] } serde_json = { workspace = true } serde_yaml = { workspace = true } sha2 = { workspace = true } +tee-verifier-interface = { workspace = true } [lints] workspace = true From f1a16befce5be14c5bd0a9814616554b14687cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 14 Jul 2026 18:33:03 +0200 Subject: [PATCH 13/26] test(contract): de-duplicate async attestation tests Extract helpers and reuse existing ones to cut boilerplate, with no change to test coverage: - tee_state.rs: reuse create_node_id; add node_id_for for filler-TLS-key nodes; add attestation_expiring_at, store_valid_attestations, and authenticate_as; collapse per-test TEE_UPGRADE_DURATION into one const; reuse gen_participants; merge crate::primitives imports; drop a test whose assertion is subsumed by preserve_node_id_integrity and internal_storage_distinguishes_participants_by_tls_key - attestation_submission.rs: reuse get_participant_node_ids; collapse Running-state matches into assert_matches!; add with_tee_upgrade_grace_period_seconds builder method; remove dead pre-build testing_env! blocks that TestSetupBuilder::build overwrites; fix typos --- .../tests/inprocess/attestation_submission.rs | 116 +++++------------- 1 file changed, 29 insertions(+), 87 deletions(-) diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index a974642015..457eb69158 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -33,7 +33,7 @@ const ATTESTATION_STORAGE_DEPOSIT: NearToken = const DEFAULT_PARTICIPANT_COUNT: usize = 3; const DEFAULT_THRESHOLD_SIZE: u64 = 2; -const DEFAUTL_CONTRACT_PROTOCOL_STATE: ContractProtocolState = ContractProtocolState::Running; +const DEFAULT_CONTRACT_PROTOCOL_STATE: ContractProtocolState = ContractProtocolState::Running; enum ContractProtocolState { Running, @@ -58,7 +58,7 @@ impl TestSetupBuilder { } } - fn with_partcipant_count(mut self, participant_count: usize) -> Self { + fn with_participant_count(mut self, participant_count: usize) -> Self { self.participant_count = Some(participant_count); self } @@ -73,6 +73,13 @@ impl TestSetupBuilder { self } + fn with_tee_upgrade_grace_period_seconds(self, seconds: u64) -> Self { + self.with_init_config(InitConfig { + tee_upgrade_deadline_duration_seconds: Some(seconds), + ..Default::default() + }) + } + fn with_contract_protocol_state( mut self, contract_protocol_state: ContractProtocolState, @@ -86,7 +93,7 @@ impl TestSetupBuilder { let threshold = self.threshold.unwrap_or(DEFAULT_THRESHOLD_SIZE); let contract_protocol_state = self .contract_protocol_state - .unwrap_or(DEFAUTL_CONTRACT_PROTOCOL_STATE); + .unwrap_or(DEFAULT_CONTRACT_PROTOCOL_STATE); let participants = gen_participants(participant_count); let participants_list = participants.participants().clone(); @@ -225,14 +232,8 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { const PARTICIPANT_COUNT: usize = 2; const THRESHOLD: u64 = 2; - testing_env!( - VMContextBuilder::new() - .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) - .build() - ); - let mut setup = TestSetupBuilder::new() - .with_partcipant_count(PARTICIPANT_COUNT) + .with_participant_count(PARTICIPANT_COUNT) .with_threshold(THRESHOLD) .build(); @@ -311,28 +312,15 @@ fn clean_tee_status__should_not_touch_attestations() { const PARTICIPANT_COUNT: usize = 2; // After resharing removed one participant const THRESHOLD: u64 = 2; - testing_env!( - VMContextBuilder::new() - .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) - .build() - ); - // Create contract in Running state with 2 current participants let mut setup = TestSetupBuilder::new() - .with_partcipant_count(PARTICIPANT_COUNT) + .with_participant_count(PARTICIPANT_COUNT) .with_threshold(THRESHOLD) .build(); // Submit TEE info for current 2 participants (all have valid attestations) let valid_attestation = Attestation::Mock(MockAttestation::Valid); - let participant_nodes: Vec = setup - .participants_list - .iter() - .take(PARTICIPANT_COUNT) - .map(|(account_id, _, participant_info)| { - create_node_id(account_id, &participant_info.tls_public_key) - }) - .collect(); + let participant_nodes = setup.get_participant_node_ids(); for node_id in &participant_nodes { setup.submit_attestation_for_node(node_id, valid_attestation.clone()); } @@ -348,12 +336,11 @@ fn clean_tee_status__should_not_touch_attestations() { INITIAL_TEE_ACCOUNTS ); - let running_state = match setup.contract.state() { - ProtocolContractState::Running(r) => r, - _ => panic!("Should be in Running state"), - }; - let participant_count = running_state.parameters.participants.participants.len(); - assert_eq!(participant_count, PARTICIPANT_COUNT); + assert_matches!( + setup.contract.state(), + ProtocolContractState::Running(r) + if r.parameters.participants.participants.len() == PARTICIPANT_COUNT + ); // When: clean_tee_status runs. setup.contract.clean_tee_status().unwrap(); @@ -365,17 +352,10 @@ fn clean_tee_status__should_not_touch_attestations() { ); // State should remain Running with same participant count - let final_running_state = match setup.contract.state() { - ProtocolContractState::Running(r) => r, - _ => panic!("Should still be Running after cleanup"), - }; - assert_eq!( - final_running_state - .parameters - .participants - .participants - .len(), - PARTICIPANT_COUNT + assert_matches!( + setup.contract.state(), + ProtocolContractState::Running(r) + if r.parameters.participants.participants.len() == PARTICIPANT_COUNT ); } @@ -390,15 +370,8 @@ fn clean_invalid_attestations__should_remove_expired_entries() { const EXPIRY_SECONDS: u64 = 1_000; const NOW_NS: u64 = 5_000 * NANOS_IN_SECOND; - testing_env!( - VMContextBuilder::new() - .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) - .block_timestamp(0) - .build() - ); - let mut setup = TestSetupBuilder::new() - .with_partcipant_count(PARTICIPANT_COUNT) + .with_participant_count(PARTICIPANT_COUNT) .with_threshold(THRESHOLD) .build(); @@ -412,10 +385,7 @@ fn clean_invalid_attestations__should_remove_expired_entries() { // init_running seeds one mock `Valid` attestation per participant. Overwrite the // first participant's entry with an expiring one, and add a brand-new entry for an // outsider account. - let participant_node = { - let (account_id, _, info) = &setup.participants_list[0]; - create_node_id(account_id, &info.tls_public_key) - }; + let participant_node = setup.get_participant_node_ids()[0].clone(); setup.submit_attestation_for_node(&participant_node, expiring_attestation.clone()); let stale_node = node_id_for(&"stale.near".parse().unwrap()); @@ -444,12 +414,6 @@ fn clean_invalid_attestations__should_remove_expired_entries() { #[test] fn clean_invalid_attestations__should_reject_when_not_running() { // Given: contract sitting in Initializing state. - testing_env!( - VMContextBuilder::new() - .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) - .block_timestamp(0) - .build() - ); let mut setup = TestSetupBuilder::new() .with_contract_protocol_state(ContractProtocolState::Initializing) @@ -491,13 +455,8 @@ fn only_latest_hash_after_grace_period() { const SECOND_ENTRY_TIME_NS: u64 = 4 * NANOS_IN_SECOND; // 1s const GRACE_PERIOD_NS: u64 = 10 * NANOS_IN_SECOND; // 10s - let init_config = near_mpc_contract_interface::types::InitConfig { - tee_upgrade_deadline_duration_seconds: Some(GRACE_PERIOD_NS / NANOS_IN_SECOND), - ..Default::default() - }; - let mut setup = TestSetupBuilder::new() - .with_init_config(init_config) + .with_tee_upgrade_grace_period_seconds(GRACE_PERIOD_NS / NANOS_IN_SECOND) .build(); let old_hash = [1; 32]; @@ -536,12 +495,8 @@ fn latest_inserted_image_hash_takes_precedence_on_equal_time_stamps() { const INITIAL_TIME: u64 = 1; const GRACE_PERIOD: u64 = 10; - let init_config = near_mpc_contract_interface::types::InitConfig { - tee_upgrade_deadline_duration_seconds: Some(GRACE_PERIOD), - ..Default::default() - }; let mut setup = TestSetupBuilder::new() - .with_init_config(init_config) + .with_tee_upgrade_grace_period_seconds(GRACE_PERIOD) .build(); let hash_1 = [1; 32]; @@ -579,13 +534,8 @@ fn hash_grace_period_depends_on_successor_entry_time_not_latest() { const THIRD_ENTRY_TIME_NS: u64 = 7 * NANOS_IN_SECOND; const GRACE_PERIOD_TIME_NS: u64 = 10 * NANOS_IN_SECOND; - let init_config = near_mpc_contract_interface::types::InitConfig { - tee_upgrade_deadline_duration_seconds: Some(GRACE_PERIOD_TIME_NS / NANOS_IN_SECOND), - ..Default::default() - }; - let mut test_setup = TestSetupBuilder::new() - .with_init_config(init_config) + .with_tee_upgrade_grace_period_seconds(GRACE_PERIOD_TIME_NS / NANOS_IN_SECOND) .build(); let first_code_hash = [1; 32]; @@ -659,12 +609,8 @@ fn latest_image_never_expires_if_its_not_superseded() { const START_TIME_SECONDS: u64 = 1; const GRACE_PERIOD_SECONDS: u64 = 10; - let init_config = near_mpc_contract_interface::types::InitConfig { - tee_upgrade_deadline_duration_seconds: Some(GRACE_PERIOD_SECONDS), - ..Default::default() - }; let mut test_setup = TestSetupBuilder::new() - .with_init_config(init_config) + .with_tee_upgrade_grace_period_seconds(GRACE_PERIOD_SECONDS) .build(); let only_image_code_hash = [123; 32]; @@ -718,12 +664,8 @@ fn nodes_can_start_with_old_valid_hashes_during_grace_period() { const GRACE_PERIOD_NANOS: u64 = GRACE_PERIOD_SECONDS * NANOS_IN_SECOND; const HASH_DEPLOYMENT_INTERVAL_NANOS: u64 = 3 * NANOS_IN_SECOND; - let init_config = near_mpc_contract_interface::types::InitConfig { - tee_upgrade_deadline_duration_seconds: Some(GRACE_PERIOD_SECONDS), - ..Default::default() - }; let mut test_setup = TestSetupBuilder::new() - .with_init_config(init_config) + .with_tee_upgrade_grace_period_seconds(GRACE_PERIOD_SECONDS) .build(); let hash_v1 = [1; 32]; // Original version From 5ff268a33748d2e6b70753d8ac73b9b09c6e633d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 14 Jul 2026 19:08:37 +0200 Subject: [PATCH 14/26] test(contract): cover post-DCAP-fail, dstack store rejection, and deposit guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill async-attestation coverage gaps that opened after the base PR settled on the no-yield design: - sandbox: a Verified verdict that fails the post-DCAP checks (empty allowlist) still refunds and fails the submission in a separate receipt, storing nothing — the resolve_verification failure mode not blocked by the fixture-allowlist work - unit: verify_and_store_dstack rejects and stores nothing when the post-DCAP checks fail - inprocess: submit_participant_info rejects a deposit below the storage cost with InsufficientDeposit - sandbox: extend the mock-success test to assert the excess deposit is refunded --- crates/contract/src/tee/tee_state.rs | 24 +++++++++++- .../tests/inprocess/attestation_submission.rs | 37 ++++++++++++++++++- crates/contract/tests/sandbox/tee.rs | 22 ++++++++--- crates/contract/tests/sandbox/tee_verifier.rs | 32 ++++++++++++++++ 4 files changed, 108 insertions(+), 7 deletions(-) diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index fa28110947..509a7b0c4c 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -581,12 +581,13 @@ mod tests { }; use crate::tee::test_utils::set_block_timestamp; use assert_matches::assert_matches; - use mpc_attestation::attestation::MockAttestation; + use mpc_attestation::attestation::{Attestation, MockAttestation}; use mpc_primitives::hash::{LauncherImageHash, NodeImageHash}; use near_account_id::AccountId; use near_sdk::test_utils::VMContextBuilder; use near_sdk::testing_env; use std::time::Duration; + use test_utils::attestation::{mock_dstack_attestation, verified_report}; /// Helper to set up the testing environment with a specific signer fn set_signer(account_id: &AccountId, public_key: &near_sdk::PublicKey) { @@ -1476,6 +1477,27 @@ mod tests { ) } + #[test] + fn verify_and_store_dstack__should_reject_and_store_nothing_when_post_dcap_checks_fail() { + // Given: an empty allowlist, so any Dstack attestation fails the post-DCAP checks. + let mut tee_state = TeeState::default(); + let Attestation::Dstack(dstack) = mock_dstack_attestation() else { + panic!("fixture is a Dstack attestation"); + }; + let node_id = node_id_for(&"alice.near".parse().unwrap()); + + // When: it is verified and stored. + let result = + tee_state.verify_and_store_dstack(node_id, &dstack, &verified_report(), Duration::MAX); + + // Then: it is rejected and nothing is stored. + assert_matches!( + result, + Err(AttestationSubmissionError::InvalidAttestation(_)) + ); + assert!(tee_state.stored_attestations.is_empty()); + } + /// Stale CodeHashesVotes entries from removed participants must not count toward /// quorum after resharing. /// diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index 457eb69158..05bd974b53 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -3,7 +3,7 @@ use super::common; use mpc_contract::{ MpcContract, - errors::{Error, TeeError}, + errors::{Error, InvalidParameters, TeeError}, primitives::{ key_state::EpochId, participants::{ParticipantId, ParticipantInfo}, @@ -286,6 +286,41 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { assert_eq!(stored_before, stored_after); } +/// Rejects a submission whose attached deposit is below the storage cost, so a caller +/// cannot store an attestation without paying for it. +#[test] +fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() { + // Given: a participant whose submission context attaches only 1 yoctoNEAR. + let mut setup = TestSetupBuilder::new().build(); + let node = setup.get_participant_node_ids()[0].clone(); + testing_env!( + VMContextBuilder::new() + .signer_account_id(node.account_id.clone()) + .predecessor_account_id(node.account_id.clone()) + .attached_deposit(NearToken::from_yoctonear(1)) + .build() + ); + + // When: that participant submits a valid mock attestation. + let result = setup + .contract + .submit_participant_info( + Attestation::Mock(MockAttestation::Valid), + node.tls_public_key.clone(), + ) + .map(|_| ()); + + // Then: the storage charge rejects it, with the required cost exceeding the attached deposit. + // (The mock path stores before charging and relies on the runtime rolling the receipt back on + // this Err; that rollback is a chain-level guarantee not modeled by the in-process VM, so we + // assert only the error here.) + assert_matches!( + &result, + Err(Error::InvalidParameters(InvalidParameters::InsufficientDeposit { attached, required })) + if required > attached + ); +} + /// Test that a `Dstack` submission is rejected when no verifier is configured. #[test] fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() { diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index d28ab3541d..f807713e61 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -267,17 +267,29 @@ async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result .with_protocols(ALL_PROTOCOLS) .build() .await; - let mock_attestation = Attestation::Mock(MockAttestation::Valid); - let tls_key = p2p_tls_key().into(); + let submitter = &mpc_signer_accounts[0]; + let balance_before = submitter.view_account().await?.balance; + let success = submit_participant_info( - &mpc_signer_accounts[0], + submitter, &contract, - &mock_attestation, - &tls_key, + &Attestation::Mock(MockAttestation::Valid), + &p2p_tls_key().into(), ) .await? .is_success(); assert!(success); + + // The submission attaches 1 NEAR but the contract charges only the measured storage cost + // and refunds the rest, so net spend is storage + gas, well under any fraction of the + // deposit; a retained deposit (e.g. 0.5 NEAR) would exceed this ceiling. + let balance_after = submitter.view_account().await?.balance; + let net_spent = balance_before.as_yoctonear() - balance_after.as_yoctonear(); + let refund_floor = NearToken::from_millinear(100).as_yoctonear(); + assert!( + net_spent < refund_floor, + "excess deposit must be refunded (net spent {net_spent} yoctoNEAR should be storage + gas, < {refund_floor})" + ); Ok(()) } diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 9ac0c400e0..4945d0172b 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -231,6 +231,38 @@ async fn submit_participant_info__should_fail_and_store_nothing_on_verifier_cras .await; } +#[tokio::test] +async fn submit_participant_info__should_refund_and_store_nothing_when_post_dcap_checks_fail() { + // Given: a verifier that returns Verified, but an empty allowed-hash set, so the + // post-DCAP checks in resolve_verification reject the (genuinely verified) quote. + let (_worker, contract, submitter, balance_before) = + setup_with_stub(StubResponse::Verified(verified_report()), None).await; + + // When: a Dstack attestation is submitted. + let result = submit_dstack(&submitter, &contract).await; + + // Then: the failure originates inside the callback (not from the verifier), yet still + // refunds and fails the submission in a separate receipt, storing nothing. Asserted inline + // rather than via assert_submission_failed_cleanly because the error is an + // InvalidAttestation (empty-allowlist rejection), not a TeeError; the empty allowed + // mpc-image-hash list is the first post-DCAP check to reject. + let failures = result.failures(); + assert!( + !failures.is_empty(), + "expected the promise chain to fail on a receipt, got: {result:#?}" + ); + let rendered = format!("{failures:?}"); + assert!( + rendered.contains("the allowed mpc image hashes list is empty"), + "expected the empty-allowlist rejection, got: {rendered}" + ); + let stored = get_participant_attestation(&contract, &p2p_tls_key().into()) + .await + .unwrap(); + assert!(stored.is_none(), "nothing should be stored on failure"); + assert_deposit_refunded(&submitter, balance_before).await; +} + // TODO(#3787): un-ignore once the fixture allowlist setup lands. A Verified // verdict routes through `verify_post_dcap_and_store`, whose allowlist checks // (fixture image/launcher hashes and measurements voted in, submitter using the From 947d7cc4a8188341642ad56173686c9ce004d34c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 15 Jul 2026 15:01:09 +0200 Subject: [PATCH 15/26] test(contract): tighten attestation test assertions - verify_and_store_mock/revert tests: compare whole NodeAttestation values via a shared mock_valid_attestation helper instead of single fields; drop .expect()/.unwrap() lookups in favor of assert_eq!(map.get(k), Some(&entry)) - bind error payloads instead of discarding them (Invalid(msg), InvalidState, VerificationError variants); replace let-else+panic! and Ok(_)=>panic! with assert_matches! binding blocks / expect_err - rename attestation_expiring_at -> mock_attestation_with_expiry - derive PartialEq/Eq on ParticipantInsertion and Clone on NodeAttestation so insertions compare as whole values - deposit/refund tests: measure the storage stake from the contract's byte growth times env::storage_byte_cost() and assert net_spent == storage_stake + total_gas_fee(result) exactly, replacing hardcoded refund_floor/gas_ceiling and STORAGE_COST_PER_BYTE constants; add total_gas_fee helper --- .../tests/inprocess/attestation_submission.rs | 16 +++-- crates/contract/tests/sandbox/tee.rs | 41 +++++------ crates/contract/tests/sandbox/tee_verifier.rs | 70 +++++++++---------- .../tests/sandbox/utils/mpc_contract.rs | 11 +++ 4 files changed, 77 insertions(+), 61 deletions(-) diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index 05bd974b53..38277a3d93 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -3,7 +3,7 @@ use super::common; use mpc_contract::{ MpcContract, - errors::{Error, InvalidParameters, TeeError}, + errors::{Error, InvalidParameters, InvalidState, TeeError}, primitives::{ key_state::EpochId, participants::{ParticipantId, ParticipantInfo}, @@ -293,11 +293,12 @@ fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() { // Given: a participant whose submission context attaches only 1 yoctoNEAR. let mut setup = TestSetupBuilder::new().build(); let node = setup.get_participant_node_ids()[0].clone(); + let attached_deposit = NearToken::from_yoctonear(1); testing_env!( VMContextBuilder::new() .signer_account_id(node.account_id.clone()) .predecessor_account_id(node.account_id.clone()) - .attached_deposit(NearToken::from_yoctonear(1)) + .attached_deposit(attached_deposit) .build() ); @@ -317,7 +318,7 @@ fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() { assert_matches!( &result, Err(Error::InvalidParameters(InvalidParameters::InsufficientDeposit { attached, required })) - if required > attached + if *attached == attached_deposit.as_yoctonear() && required > attached ); } @@ -328,10 +329,10 @@ fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() let mut setup = TestSetupBuilder::new().build(); let node = setup.get_participant_node_ids()[0].clone(); - // When: that participant submits a Dstack attestation. + // When let result = setup.try_submit_attestation_for_node(&node, mock_dto_dstack_attestation()); - // Then: it is rejected with `VerifierNotConfigured`. + // Then assert_matches!( &result, Err(Error::TeeError(TeeError::VerifierNotConfigured)) @@ -458,7 +459,10 @@ fn clean_invalid_attestations__should_reject_when_not_running() { let result = setup.contract.clean_invalid_attestations(100); // Then: the call errors without mutating state. - assert_matches!(result, Err(_)); + assert_matches!( + result, + Err(Error::InvalidState(InvalidState::ProtocolStateNotRunning)) + ); } macro_rules! assert_allowed_docker_image_hashes { diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index f807713e61..3f419e7a5d 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -8,7 +8,7 @@ use crate::sandbox::{ mpc_contract::{ assert_running_return_participants, assert_running_return_threshold, get_participant_attestation, get_state, get_tee_accounts, submit_participant_info, - vote_add_launcher_hash, vote_for_hash, + total_gas_fee, vote_add_launcher_hash, vote_for_hash, }, resharing_utils::conclude_resharing, sign_utils::DomainResponseTest, @@ -260,6 +260,7 @@ pub async fn get_participants(contract: &Contract) -> Result { #[tokio::test] async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result<()> { let SandboxTestSetup { + worker, contract, mpc_signer_accounts, .. @@ -269,26 +270,29 @@ async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result .await; let submitter = &mpc_signer_accounts[0]; let balance_before = submitter.view_account().await?.balance; + let storage_before = worker.view_account(contract.id()).await?.storage_usage; - let success = submit_participant_info( + let result = submit_participant_info( submitter, &contract, &Attestation::Mock(MockAttestation::Valid), &p2p_tls_key().into(), ) - .await? - .is_success(); - assert!(success); + .await?; + assert!(result.is_success()); - // The submission attaches 1 NEAR but the contract charges only the measured storage cost - // and refunds the rest, so net spend is storage + gas, well under any fraction of the - // deposit; a retained deposit (e.g. 0.5 NEAR) would exceed this ceiling. + // The caller's net spend must be exactly the measured storage stake plus the fee + // actually burnt: the storage entry is charged from the attached deposit and every + // other yoctoNEAR of the deposit is refunded. + let bytes_grown = + u128::from(worker.view_account(contract.id()).await?.storage_usage - storage_before); + assert!(bytes_grown > 0); + let storage_stake = near_sdk::env::storage_byte_cost().saturating_mul(bytes_grown); let balance_after = submitter.view_account().await?.balance; - let net_spent = balance_before.as_yoctonear() - balance_after.as_yoctonear(); - let refund_floor = NearToken::from_millinear(100).as_yoctonear(); - assert!( - net_spent < refund_floor, - "excess deposit must be refunded (net spent {net_spent} yoctoNEAR should be storage + gas, < {refund_floor})" + let net_spent = balance_before.saturating_sub(balance_after); + assert_eq!( + net_spent, + storage_stake.saturating_add(total_gas_fee(&result)) ); Ok(()) } @@ -318,13 +322,10 @@ async fn test_clean_tee_status_denies_external_account_access() -> Result<()> { assert!(!result.is_success()); // Verify the error message indicates unauthorized access - match result.into_result() { - Err(failure) => { - let error_msg = format!("{:?}", failure); - assert!(error_msg.contains("Method clean_tee_status is private")); - } - Ok(_) => panic!("Call should have failed"), - } + let failure = result + .into_result() + .expect_err("clean_tee_status must reject a non-private caller"); + assert!(format!("{failure:?}").contains("Method clean_tee_status is private")); Ok(()) } diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 4945d0172b..7e751bbe15 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -31,7 +31,7 @@ use crate::sandbox::{ contract_build::stub_tee_verifier_contract, mpc_contract::{ get_participant_attestation, submit_participant_info, - submit_participant_info_with_deposit, vote_tee_verifier_change, + submit_participant_info_with_deposit, total_gas_fee, vote_tee_verifier_change, }, }, }; @@ -139,7 +139,18 @@ async fn assert_submission_failed_cleanly( .await .unwrap(); assert!(stored.is_none(), "nothing should be stored on failure"); - assert_deposit_refunded(submitter, balance_before).await; + assert_deposit_refunded(submitter, balance_before, result).await; +} + +/// Asserts the deposit was fully refunded: with nothing stored, the caller spends only gas. +async fn assert_deposit_refunded( + account: &Account, + balance_before: NearToken, + result: &ExecutionFinalResult, +) { + let balance_after = account.view_account().await.unwrap().balance; + let net_spent = balance_before.saturating_sub(balance_after); + assert_eq!(net_spent, total_gas_fee(result)); } #[tokio::test] @@ -260,7 +271,7 @@ async fn submit_participant_info__should_refund_and_store_nothing_when_post_dcap .await .unwrap(); assert!(stored.is_none(), "nothing should be stored on failure"); - assert_deposit_refunded(&submitter, balance_before).await; + assert_deposit_refunded(&submitter, balance_before, &result).await; } // TODO(#3787): un-ignore once the fixture allowlist setup lands. A Verified @@ -274,8 +285,13 @@ async fn submit_participant_info__should_refund_and_store_nothing_when_post_dcap async fn submit_participant_info__should_store_attestation_on_verified_quote() { // Given: a verifier that returns the report the real verifier would produce // for the fixture quote. - let (_worker, contract, submitter, balance_before) = + let (worker, contract, submitter, balance_before) = setup_with_stub(StubResponse::Verified(verified_report()), None).await; + let storage_before = worker + .view_account(contract.id()) + .await + .unwrap() + .storage_usage; // When: a Dstack attestation is submitted. let result = submit_dstack(&submitter, &contract).await; @@ -292,19 +308,21 @@ async fn submit_participant_info__should_store_attestation_on_verified_quote() { .unwrap(); assert!(stored.is_some(), "a verified attestation must be stored"); - // Bound net spend both sides: storage was charged (> 0), but the excess was - // refunded (< floor). The upper bound catches a wrongly-retained deposit. + // The caller's net spend must be exactly the measured storage stake plus the fee + // actually burnt; the rest of the SUBMIT_DEPOSIT is refunded. + let storage_after = worker + .view_account(contract.id()) + .await + .unwrap() + .storage_usage; + let bytes_grown = u128::from(storage_after - storage_before); + assert!(bytes_grown > 0); + let storage_stake = near_sdk::env::storage_byte_cost().saturating_mul(bytes_grown); let balance_after = submitter.view_account().await.unwrap().balance; - let net_spent = balance_before.as_yoctonear() - balance_after.as_yoctonear(); - let refund_floor = NearToken::from_millinear(100).as_yoctonear(); - assert!( - net_spent > 0, - "storage must be charged from the attached deposit" - ); - assert!( - net_spent < refund_floor, - "excess deposit must be refunded (net spent {net_spent} yoctoNEAR should be \ - storage + gas, < {refund_floor}); a retained {SUBMIT_DEPOSIT} deposit would exceed this" + let net_spent = balance_before.saturating_sub(balance_after); + assert_eq!( + net_spent, + storage_stake.saturating_add(total_gas_fee(&result)) ); } @@ -347,23 +365,5 @@ async fn submit_participant_info__should_fail_and_store_nothing_when_resolve_ver stored.is_none(), "nothing should be stored on an OOG resolve" ); - assert_deposit_refunded(&submitter, balance_before).await; -} - -/// Asserts the full 1 NEAR storage deposit was returned: the net spend is only -/// gas, well under any fraction of the deposit. -async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) { - let balance_after = account.view_account().await.unwrap().balance; - // Raw subtraction (not `saturating_sub`): if the contract over-refunds so - // `balance_after > balance_before`, this underflows and panics rather than - // clamping to 0 and silently passing. - let net_spent = balance_before.as_yoctonear() - balance_after.as_yoctonear(); - // Bound to the gas envelope, not the deposit: max gas (~0.03 NEAR at the - // sandbox price) sits far below this ceiling, while any partial retention of - // the 1 NEAR deposit (e.g. 0.5 NEAR) would exceed it and fail. - let gas_ceiling = NearToken::from_millinear(50).as_yoctonear(); - assert!( - net_spent < gas_ceiling, - "deposit should be fully refunded (net spent {net_spent} yoctoNEAR should be gas-only, < {gas_ceiling})" - ); + assert_deposit_refunded(&submitter, balance_before, &result).await; } diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index c481aae041..a529256a0e 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -12,6 +12,17 @@ use near_workspaces::{ Account, AccountId, Contract, result::ExecutionFinalResult, types::NearToken, }; +/// The gas fee the caller actually pays for a call, summed over its transaction and +/// receipts. This is gas only: it excludes both the refunded unused prepaid gas and any +/// storage-staking deposit (storage is locked on the contract, not burnt). +pub fn total_gas_fee(result: &ExecutionFinalResult) -> NearToken { + result + .outcomes() + .iter() + .map(|outcome| outcome.tokens_burnt) + .fold(NearToken::from_yoctonear(0), NearToken::saturating_add) +} + pub async fn get_state(contract: &Contract) -> ProtocolContractState { contract .view(method_names::STATE) From 6a98267ff56e6021ed1bc2c5f6f612b02f39abff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 15 Jul 2026 16:35:02 +0200 Subject: [PATCH 16/26] docs(contract): tighten async attestation test comments Condense the tee_verifier sandbox module doc, per-test `// Then:` notes, and the two TODO(#3787) blocks; drop the inaccurate "runtime refunds the deposit to the predecessor" note on the OOG TODO. Trim the duplicated non-`#[near]` rationale on the test-tee-verifier-types crate (Cargo.toml + lib.rs) and shorten the test-tee-verifier stub doc. --- crates/contract/tests/sandbox/tee_verifier.rs | 73 +++++-------------- crates/test-tee-verifier-types/Cargo.toml | 8 +- crates/test-tee-verifier-types/src/lib.rs | 6 +- crates/test-tee-verifier/src/lib.rs | 7 +- 4 files changed, 28 insertions(+), 66 deletions(-) diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 7e751bbe15..0bb55aa85b 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -1,27 +1,12 @@ -//! Sandbox tests for the async [`submit_participant_info`] flow that offloads -//! DCAP verification to a separate tee-verifier contract. +//! Sandbox tests for the async [`submit_participant_info`] flow that offloads DCAP +//! verification to a separate tee-verifier contract. //! -//! Each test deploys the `test-tee-verifier` stub, whose verify-quote returns a -//! response the test picks instead of running real `dcap-qvl`, votes it in as the -//! trusted verifier via [`vote_tee_verifier_change`], and then covers one branch -//! of the promise-chain flow. -//! -//! A Dstack submission spawns `verify_quote` on the trusted verifier with -//! [`MpcContract::resolve_verification`] chained as its callback. There is no -//! yield-resume and no timeout: [`resolve_verification`] settles every outcome -//! synchronously within the same chain. -//! -//! - verifier not configured → the submit tx fails synchronously with -//! [`TeeError::VerifierNotConfigured`], nothing stored. -//! - [`StubResponse::Rejected`] → [`resolve_verification`] refunds the deposit and -//! fires `fail_attestation_submission`, which panics in a separate receipt to -//! fail the submitter's transaction; nothing stored. -//! - stub panics (verifier unreachable) → the callback observes a failed promise, -//! resolves to [`TeeError::VerifierUnavailable`], and fails the same way. -//! -//! On failure the top-level submit call still returns its chained promise, so the -//! failure surfaces on the chain's receipt outcomes -//! ([`ExecutionFinalResult::failures`]), not on the top-level tx result. +//! Each test deploys the `test-tee-verifier` stub (returning a picked response +//! instead of running real `dcap-qvl`), votes it in as the trusted verifier, and +//! covers one branch of the promise chain: a Dstack submission spawns `verify_quote` +//! with [`MpcContract::resolve_verification`] chained as its callback, which settles +//! every outcome synchronously. When a submission fails, the top-level `submit` tx still +//! succeeds (it returned the chained promise); the error appears on one of the receipts. #![allow(non_snake_case)] use crate::sandbox::{ @@ -110,10 +95,9 @@ async fn submit_dstack(submitter: &Account, contract: &Contract) -> ExecutionFin .unwrap() } -/// Asserts a Dstack submission failed on the chain and left no committed state: -/// the failure surfaces on a receipt (`fail_attestation_submission` panics in its -/// own receipt), carries `expected_error`, nothing is stored, and the deposit is -/// refunded. +/// Asserts a Dstack submission failed cleanly: a receipt failed carrying +/// `expected_error` (`fail_attestation_submission` panics in its own receipt), no +/// attestation was stored, and the deposit was refunded. async fn assert_submission_failed_cleanly( result: &ExecutionFinalResult, contract: &Contract, @@ -204,10 +188,7 @@ async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_re // When: a Dstack attestation is submitted. let result = submit_dstack(&submitter, &contract).await; - // Then: resolve_verification refunds and fails the submission in a separate - // receipt; the failure is on the chain, not the top-level tx result. The - // stub wraps the reason in `VerifierError::DcapVerification`, whose Display - // prefixes "dcap verification failed: ". + // Then: the submission fails cleanly, reporting the verifier's rejection reason. assert_submission_failed_cleanly( &result, &contract, @@ -230,8 +211,7 @@ async fn submit_participant_info__should_fail_and_store_nothing_on_verifier_cras let result = submit_dstack(&submitter, &contract).await; // Then: the callback sees a failed promise, resolves to VerifierUnavailable, - // refunds, and fails the submission in a separate receipt. No timeout: the - // outcome settles synchronously within the same chain. + // refunds, and fails the submission in a separate receipt. assert_submission_failed_cleanly( &result, &contract, @@ -252,11 +232,8 @@ async fn submit_participant_info__should_refund_and_store_nothing_when_post_dcap // When: a Dstack attestation is submitted. let result = submit_dstack(&submitter, &contract).await; - // Then: the failure originates inside the callback (not from the verifier), yet still - // refunds and fails the submission in a separate receipt, storing nothing. Asserted inline - // rather than via assert_submission_failed_cleanly because the error is an - // InvalidAttestation (empty-allowlist rejection), not a TeeError; the empty allowed - // mpc-image-hash list is the first post-DCAP check to reject. + // Then: the callback's post-DCAP check rejects the (verified) quote. Asserted inline + // rather than via assert_submission_failed_cleanly since the error is not a TeeError. let failures = result.failures(); assert!( !failures.is_empty(), @@ -274,12 +251,8 @@ async fn submit_participant_info__should_refund_and_store_nothing_when_post_dcap assert_deposit_refunded(&submitter, balance_before, &result).await; } -// TODO(#3787): un-ignore once the fixture allowlist setup lands. A Verified -// verdict routes through `verify_post_dcap_and_store`, whose allowlist checks -// (fixture image/launcher hashes and measurements voted in, submitter using the -// fixture keys) must pass before the attestation is stored. With an empty -// allowlist the post-DCAP check fails and the submission is rejected instead of -// stored, so the happy path cannot be exercised here yet. +// TODO(#3787): un-ignore once the fixture allowlist setup lands; without it the +// post-DCAP check rejects the quote, so the store happy path can't run here yet. #[ignore = "needs fixture allowlist setup to pass the post-DCAP checks; tracked in #3787"] #[tokio::test] async fn submit_participant_info__should_store_attestation_on_verified_quote() { @@ -326,15 +299,9 @@ async fn submit_participant_info__should_store_attestation_on_verified_quote() { ); } -// TODO(#3787): un-ignore once the fixture allowlist setup lands. To OOG, -// `resolve_verification` must reach the heavy RTMR3 replay in the post-DCAP -// checks, which needs the allowlist populated and the submitter using the fixture -// keys. With an empty allowlist the post-DCAP check fails fast and -// `resolve_verification` completes well under 1 TGas, re-testing the rejection -// path instead. Under the promise-chain model an OOG rolls the whole callback -// receipt back atomically: nothing is stored, the runtime refunds the attached -// deposit to the predecessor, and `fail_attestation_submission` never fires, so -// the chain still surfaces a failed receipt. No timeout is involved. +// TODO(#3787): un-ignore once the fixture allowlist setup lands; without it the +// post-DCAP check fails fast and resolve_verification never reaches the heavy work +// needed to run it out of gas, so this re-tests the rejection path instead. #[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3787"] #[tokio::test] async fn submit_participant_info__should_fail_and_store_nothing_when_resolve_verification_runs_out_of_gas() diff --git a/crates/test-tee-verifier-types/Cargo.toml b/crates/test-tee-verifier-types/Cargo.toml index baa76ead72..28d34b9cdc 100644 --- a/crates/test-tee-verifier-types/Cargo.toml +++ b/crates/test-tee-verifier-types/Cargo.toml @@ -4,11 +4,9 @@ version = { workspace = true } license = { workspace = true } edition = { workspace = true } -# Wire types shared between the `test-tee-verifier` stub contract and the -# `mpc-contract` sandbox tests that drive it. A plain lib (no `#[near]`) so both -# a contract crate and a test binary can depend on it without the duplicate-ABI -# symbol / `--all-features` collision that importing the stub crate itself would -# cause (see docs / the mpc-contract sandbox tests). +# Wire types shared between the `test-tee-verifier` stub contract and the sandbox +# tests that drive it. Kept as a plain (non-`#[near]`) lib so a test crate can depend +# on it without pulling a contract's duplicate ABI symbol under `--all-features`. [features] # Mirrors the stub's `abi` feature: derives the borsh schema on the wire types so diff --git a/crates/test-tee-verifier-types/src/lib.rs b/crates/test-tee-verifier-types/src/lib.rs index 06e5344df8..c86372fc23 100644 --- a/crates/test-tee-verifier-types/src/lib.rs +++ b/crates/test-tee-verifier-types/src/lib.rs @@ -1,10 +1,8 @@ //! Wire types shared between the `test-tee-verifier` stub contract and the //! `mpc-contract` sandbox tests that drive it. //! -//! Kept in a plain (non-`#[near]`) crate so both a contract crate and a test -//! binary can depend on the same definition: importing the stub contract itself -//! would emit a duplicate contract-ABI symbol and unify its `abi` feature under -//! `cargo test --all-features`. +//! Kept as a plain (non-`#[near]`) crate so a test crate can depend on it without +//! pulling the stub contract's duplicate ABI symbol under `cargo test --all-features`. use borsh::{BorshDeserialize, BorshSerialize}; diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs index 2654507109..502c6db571 100644 --- a/crates/test-tee-verifier/src/lib.rs +++ b/crates/test-tee-verifier/src/lib.rs @@ -1,7 +1,6 @@ -//! Test-only stub of the `tee-verifier` contract. -//! -//! [`TestTeeVerifier::verify_quote`] returns a [`StubResponse`] fixed at init -//! time instead of running real `dcap_qvl::verify`. +//! Test-only stub of the `tee-verifier` contract: [`TestTeeVerifier::verify_quote`] +//! returns a [`StubResponse`] chosen at init time instead of running DCAP quote +//! verification, letting tests drive any verifier outcome deterministically. use near_sdk::{env, near}; use tee_verifier_interface::{Collateral, QuoteBytes, VerificationResult, VerifierError}; From 2bd43f9de90cbce1a732ba12a4b7ef5fd53ff436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 15 Jul 2026 16:42:18 +0200 Subject: [PATCH 17/26] test(contract): drop doc changes, split to a stacked PR Revert the TEE attestation doc edits (docs/design/attestation-verifier-contract.md, docs/localnet/tee-localnet.md, docs/running-an-mpc-node-in-tdx-external-guide.md) to their base-branch content so this PR is test-only. The doc updates move to a stacked PR tracked by #3825. --- docs/design/attestation-verifier-contract.md | 459 ++++++++++-------- docs/localnet/tee-localnet.md | 2 +- ...nning-an-mpc-node-in-tdx-external-guide.md | 3 +- 3 files changed, 261 insertions(+), 203 deletions(-) diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 3ddb42ca58..0346ce6c69 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -59,93 +59,92 @@ flowchart LR ### Submission flow -`mpc-contract`'s [`submit_participant_info`][submit-participant-info] becomes asynchronous for Dstack attestations, but without yield-resume: it settles the submission entirely inside a single cross-contract promise chain. The method returns `Result, Error>`. A `Mock` attestation is verified and stored synchronously and returns `Ok(PromiseOrValue::Value(()))`. A `Dstack` attestation is handed to `submit_dstack_attestation`, which builds a `Promise` that calls `tee-verifier::verify_quote` and chains `resolve_verification` as its `.then` callback; the method returns `Ok(PromiseOrValue::Promise(...))`. The attached deposit is forwarded onto the callback via `.with_attached_deposit(env::attached_deposit())` rather than stashed in contract state, so `resolve_verification` can charge storage or refund from it directly. +`mpc-contract`'s [`submit_participant_info`][submit-participant-info] becomes asynchronous for Dstack attestations and uses the same yield-resume pattern already in place for `sign`, `request_app_private_key`, and `request_verify_foreign_tx`. The method registers a yielded promise via [`env::promise_yield_create`][promise-yield-create] (going through the contract's existing [`enqueue_yield_request`][enqueue-yield-request] helper), stashes the resulting `data_id` in `pending_attestations`, and fires a cross-contract call to `tee-verifier::verify_quote` whose own `.then` callback (`resolve_verification`) bridges the verifier's response into a `promise_yield_resume`. Because `verify_quote` returns a `VerificationResult` value rather than failing the receipt (see [§Why a rejection is a value](#why-a-rejection-is-a-value-not-a-failed-receipt)), `resolve_verification`'s `#[callback_result]` distinguishes three outcomes: `Verified` runs the post-DCAP checks (RTMR3 replay, app-compose validation, measurement allowlist matching, report-data binding) and resumes; `Rejected` refunds and resumes **immediately** with the reason; and only `Err(PromiseError::Failed)` — the verifier unreachable or crashed — returns early, leaving cleanup to the ~200-block timeout. The yield-callback `on_attestation_verified` stays trivial, the same shape as the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]: it returns the resumed outcome to the caller, and only on the timeout (`Err(PromiseError::Failed)`) does it remove the pending entry and schedule a refund. Keeping the heavy work in `resolve_verification` makes an OOG in the yield-callback implausible, and an OOG in `resolve_verification` rolls back its whole receipt so the timeout still fires the trivial callback for cleanup. The exact branch-by-branch behavior is in [§Handling failures](#handling-failures) below. -Because `verify_quote` returns a `VerificationResult` value rather than failing the receipt (see [§Why a rejection is a value](#why-a-rejection-is-a-value-not-a-failed-receipt)), `resolve_verification`'s `#[callback_result]` distinguishes three outcomes: `Ok(Verified)` runs the post-DCAP checks (RTMR3 replay, app-compose validation, measurement allowlist matching, report-data binding) and, on success, stores the attestation and charges storage; `Ok(Rejected)` returns a `QuoteRejected` error carrying the reason; and `Err(PromiseError::Failed)` — the verifier unreachable, panicked, or out of gas — returns `VerifierUnavailable`. On any error branch `resolve_verification` refunds the whole attached deposit and fires a *separate* `fail_attestation_submission` receipt whose panic fails the submitter's transaction. There is no yield, no `data_id`, no `pending_attestations` entry, and no ~200-block timeout: a failure settles immediately within the same promise chain. The exact branch-by-branch behavior is in [§Handling failures](#handling-failures) below. +The post-DCAP policy inputs are the same fields `mpc-contract` already holds today — the allowed-image-hash list, the per-account TLS / account public-key binding, and the stored-attestation map. No new policy state is introduced; the only state addition is the `pending_attestations` map described below, which is bookkeeping for the in-flight yield. -The post-DCAP policy inputs are the same fields `mpc-contract` already holds today — the allowed-image-hash list, the per-account TLS / account public-key binding, and the stored-attestation map. No new policy state is introduced, and the chain carries no bookkeeping map: everything `resolve_verification` needs travels as a `VerificationContext` borsh argument on the callback. - -The periodic re-validation path ([`re_verify`](../../crates/mpc-attestation/src/attestation.rs)) does not call `dcap_qvl::verify` — it re-checks post-DCAP allowlist invariants against already-stored attestations — and is therefore unaffected by this design. It stays synchronous. +The periodic re-validation path ([`re_verify`](../../crates/contract/src/tee/tee_state.rs)) does not call `dcap_qvl::verify` — it re-checks post-DCAP allowlist invariants against already-stored attestations — and is therefore unaffected by this design. It stays synchronous. ```mermaid sequenceDiagram participant Op as Operator participant MPC as mpc-contract + participant State as State participant Ver as tee-verifier participant DCAP as dcap-qvl Op->>MPC: submit_participant_info(Dstack, tls_pk) - MPC->>Ver: Promise: verify_quote(quote, collateral) - Note over MPC: .then resolve_verification(VerificationContext),
attached deposit forwarded to the callback + MPC->>MPC: promise_yield_create → data_id + MPC->>State: insert PendingAttestation { data_id, ... } + MPC->>Ver: Promise: verify_quote (chained .then resolve_verification) Ver->>DCAP: verify(quote, collateral, now) - alt Verified + store ok + alt Verified (post-DCAP runs, then resumes) Ver-->>MPC: VerificationResult::Verified(report) - MPC->>MPC: resolve_verification: verify_post_dcap_and_store vs fresh allowlist - MPC->>MPC: charge_attestation_storage (refund excess) - MPC-->>Op: PromiseOrValue::Value(()) — success - else Verified + post-DCAP fail, or Rejected, or verifier unreachable - Ver-->>MPC: Verified(report) / Rejected(reason) / (no answer) - MPC->>MPC: resolve_verification produces Err (QuoteRejected / VerifierUnavailable) - MPC->>Op: refund whole attached deposit (this receipt) - MPC->>MPC: fail_attestation_submission receipt panics - MPC-->>Op: transaction fails (carrying the reason) + MPC->>MPC: resolve_verification: finish_verify vs fresh allowlist + MPC->>State: store on pass / refund on fail, remove PendingAttestation + MPC->>MPC: promise_yield_resume(data_id, FinalOutcome) + MPC-->>Op: success or error, immediately + else Rejected (resumes immediately) + Ver-->>MPC: VerificationResult::Rejected(reason) + MPC->>MPC: resolve_verification: refund, remove PendingAttestation + MPC->>MPC: promise_yield_resume(data_id, FinalOutcome::Err(reason)) + MPC-->>Op: error (carrying reason), immediately + else No verdict — verifier unreachable / silent for ~200 blocks + Note over MPC: resolve_verification (if it runs) only logs and returns.
Cleanup is left to the runtime yield-timeout. + MPC->>MPC: on_attestation_verified fires with Err(PromiseError::Failed) + MPC->>State: remove PendingAttestation, refund + MPC-->>Op: error end ``` #### Caller-side impact -The only caller of `submit_participant_info` in production is `mpc-node`'s `periodic_attestation_submission` task, which resubmits on a 1-hour cadence and on attestation-removal events. It already polls contract state to confirm the attestation is actually stored, with exponential backoff (100 ms → 60 s, capped at 12 h). That polling-based success criterion is what makes the sync→async change transparent. The returned `Promise` now resolves through the chain with the actual outcome — success, a verifier-rejection error, a post-DCAP-failure error, or a `VerifierUnavailable` error if the verifier never answers — so any future caller that wants to await the result synchronously can, without changing the contract. There is no ~200-block timeout error to account for: every path settles as soon as the verifier's receipt finishes. +The only caller of `submit_participant_info` in production is `mpc-node`'s `periodic_attestation_submission` task, which resubmits on a 1-hour cadence and on attestation-removal events. It already polls contract state to confirm the attestation is actually stored, with exponential backoff (100 ms → 60 s, capped at 12 h). That polling-based success criterion is what makes the sync→async change transparent. Under yield-resume the returned `Promise` also resolves with the actual outcome — success, a verifier-rejection error, or a post-DCAP-failure error (each as soon as the verifier answers), or a timeout error after ~200 blocks if it never does — so any future caller that wants to await the result synchronously can, without changing the contract. #### Handling failures -The submission produces no in-flight state to clean up: nothing is inserted into contract storage at submit time, so there is no pending entry that a failure could leave wedged and no "already pending" guard to trip on a resubmit. What a failure must still get right is the money — the attached deposit — and the caller-facing outcome. Both are handled in the single `.then` callback, `resolve_verification`. - -`resolve_verification` is a `#[private]` `#[payable]` method. It is `#[payable]` because the deposit rides forward onto it via `.with_attached_deposit`, so `env::attached_deposit()` inside the callback returns the amount the submitter attached. It observes the verifier's answer through `#[callback_result]` and reduces it to a `Result<(), Error>`: - -- `Ok(VerificationResult::Verified(report))` → `verify_post_dcap_and_store(&context, &report)`, which returns `Ok(())` on a clean store or an `Err` if a post-DCAP check or the storage charge fails. -- `Ok(VerificationResult::Rejected(reason))` → `Err(QuoteRejected { reason })`. -- `Err(promise_err)` → `Err(VerifierUnavailable)` — the verifier was unreachable, panicked, or ran out of gas. +The first thing `submit_participant_info` does is insert a `PendingAttestation` entry, and that entry has to come back out once verification finishes — successfully or not. If a failure leaves the entry behind, the submitter's account is wedged: every future `submit_participant_info` call panics on the "already pending" guard, and the deposit stays locked because the refund is part of the cleanup the contract never got around to. -On `Ok(())` the callback returns `PromiseOrValue::Value(())`; the attestation is stored and storage has been charged, with any excess deposit refunded inside `charge_attestation_storage`. +That makes *where* the cleanup runs the central question, because NEAR offers two natural homes for "do something when the verifier responds" and they have very different failure modes. -On `Err(err)` the callback does two things, in order, and the order is the whole point: +A **`.then` callback** is a normal cross-contract callback chained onto the verifier's promise. The runtime runs it in a fresh receipt once the verifier's receipt finishes; if it panics or runs out of gas, that receipt rolls back atomically and the chain ends. Because the receipt is independent of whatever yield is parked in parallel, its failure has no special effect on the submitter's call — the submitter just keeps waiting on the yield. -1. `refund_to(&account_id, env::attached_deposit())` — refund the *entire* attached deposit in this receipt. -2. Schedule a *separate* `fail_attestation_submission` receipt via `Promise::new(current_account).function_call(...).as_return()`, and return it as `PromiseOrValue::Promise`. +A **yield-callback** is different. When `submit_participant_info` calls `promise_yield_create`, it asks the runtime to *park* the submitter's call so the contract can return its result later. The runtime fires the named callback exactly once per `data_id` — either when something calls `promise_yield_resume(data_id, payload)`, or after ~200 blocks of silence with `Err(PromiseError::Failed)`. That single firing's return value is what the submitter eventually receives. There is no second invocation: an OOG inside the yield-callback rolls back its whole receipt and drops whatever cleanup it was meant to do, with no automatic retry. -The refund and the failure live in different receipts deliberately. `fail_attestation_submission` is a tiny `#[private]` method that logs the reason and then `env::panic_str(&reason)` — its panic is what fails the submitter's transaction and surfaces the error. If that panic instead happened inside `resolve_verification` (the `#[handle_result]`-return-an-`Err` shape), it would roll back the whole callback receipt, discarding the refund transfer and any created promises along with it. Splitting them lets the refund commit in the first receipt while the second receipt fails the caller's transaction afterward. - -`verify_post_dcap_and_store` has its own commit-order subtlety. Unlike the synchronous `Mock` path — where returning an `Err` rolls back the entire method receipt and un-does any partial store — this callback receipt *commits regardless* of the `Err` it hands back to `resolve_verification`. So the store cannot be left to implicit rollback. `verify_post_dcap_and_store` snapshots `env::storage_usage()`, calls `verify_and_store_dstack`, then `charge_attestation_storage`; if the charge fails (`InsufficientDeposit`), it explicitly calls `tee_state.revert_dstack_store(tls_pk, insertion)` before returning the error, so the caller never gets storage for free plus a full refund. +The asymmetry decides the design. The work the verifier's *answer* unlocks — post-DCAP checks, the `stored_attestations` insert, the refund on rejection or post-DCAP failure, the pending-entry removal, the `promise_yield_resume` call — lives in the `.then` bridge `resolve_verification`. If it aborts mid-flight, the entire receipt rolls back atomically (including the resume), so the yield stays parked and the runtime's 200-block timeout still fires the yield-callback for cleanup — same recovery as "verifier never responded." `resolve_verification` resolves immediately on either answer it can act on: `Verified` (run post-DCAP, then resume) and `Rejected` (refund and resume with the reason). Only `Err(PromiseError::Failed)` — no verdict, the verifier was unreachable or crashed — is deliberately *not* resolved here: `resolve_verification` logs and returns early, routing that case to the timeout cleanup. The yield-callback `on_attestation_verified` is intentionally tiny: on resume, return the value to the caller; on its `Err(PromiseError::Failed)` branch — verifier unreachable or silent timeout — remove the pending entry and schedule a refund. Walking every path the system can take: -- Verifier returned `Verified`, post-DCAP checks pass, storage charge succeeds → attestation stored, excess refunded, `Value(())`. Caller polls and sees the entry. -- Verifier returned `Verified` but a post-DCAP check fails → `verify_and_store_dstack` errors (nothing was stored), `resolve_verification` refunds and fires the fail receipt. -- Verifier returned `Verified`, post-DCAP passes, but the storage charge fails → `verify_post_dcap_and_store` reverts the store explicitly, returns the error, `resolve_verification` refunds and fires the fail receipt. -- Verifier returned `Rejected` → `QuoteRejected`, refund + fail receipt. -- Verifier unreachable / panicked / out of gas (`Err(PromiseError::Failed)`) → `VerifierUnavailable`, refund + fail receipt. This is handled right here, immediately; it is not deferred to any timeout. -- `resolve_verification` itself runs out of gas or panics mid-receipt → the whole callback receipt rolls back atomically, no partial commits, and the submitter's transaction fails. Because nothing was inserted at submit time, there is no orphaned state to reclaim. +- `resolve_verification` resumes (verifier returned `Verified` (post-DCAP pass or fail) or `Rejected`) → it cleaned up before resuming, caller receives the outcome. +- `resolve_verification` returns early (verifier unreachable — `Err(PromiseError::Failed)`) → timeout fires, yield-callback cleans up. +- `resolve_verification` aborts (OOG / panic mid-receipt) → timeout fires, yield-callback cleans up. +- `resolve_verification` never runs (verifier silent) → timeout fires, yield-callback cleans up. +- Yield-callback itself OOGs → genuine orphan. Its body is one `LookupMap::remove` plus one Promise schedule, small enough that an out-of-gas failure is implausible with a sensible gas budget. -The verifier still returns its verdict as a *value* rather than a failed receipt, so `#[callback_result]` can tell a definitive `Rejected` apart from `Err(PromiseError::Failed)` (no answer). Under the no-yield design both still lead to an immediate fail-and-refund in `resolve_verification`; the distinction only changes the error type and message the caller sees (`QuoteRejected` with the reason vs `VerifierUnavailable`), not whether cleanup is immediate. +This isn't a novel pattern in this codebase: `sign`, `request_app_private_key`, and `request_verify_foreign_tx` already use the same split. The heavy work and the `promise_yield_resume` call live in [`pending_requests::resolve_yields_for`][pending-requests-mod] (invoked from `respond`, `respond_ckd`, and `respond_verify_foreign_tx`), and [`return_signature_and_clean_state_on_success`][sign-yield-callback] is a tiny yield-callback that just routes the outcome back to the caller and handles the no-response timeout. The attestation flow inherits the same orphan-proof argument: heavy work in a safely-recoverable receipt, cleanup in a callback small enough that gas budgeting makes its failure implausible. ### Contract state changes -`resolve_verification` runs in a later block than `submit_participant_info`, as an independent contract invocation, so anything it needs from the original call must travel with it. That is done not through contract storage but through a borsh callback argument: +The callback runs in a later block than `submit_participant_info`, as an independent contract invocation. Anything the callback still needs must be stashed in contract storage, in a new field: ```rust -pub struct VerificationContext { - pub(crate) node_id: NodeId, - pub(crate) attestation: DstackAttestation, -} +pending_attestations: LookupMap ``` -`VerificationContext` carries the submitter's `NodeId` (account id, TLS public key, and account public key — the binding the post-DCAP report-data check reproves) and the full `DstackAttestation` payload (RTMR3 event log, app-compose, report-data) that the post-DCAP checks consume. It is passed to `resolve_verification` as a `#[serializer(borsh)]` argument and is never written to contract state. The attached deposit is *not* part of it — it rides forward on the promise via `.with_attached_deposit`, so `env::attached_deposit()` in the callback yields the submitter's deposit directly. +This map mirrors the other pending-request maps in `mpc-contract` ([`pending_signature_requests`][pending-requests-mod], `pending_ckd_requests`, `pending_verify_foreign_tx_requests`), but stores a single `PendingAttestation` per `AccountId` rather than a `Vec`: attestation submissions are 1-per-account. -This design adds **no** new attestation-related state. There is no `pending_attestations` map, no `PendingAttestation` struct, no `AttestationResult` enum, and no stashed `data_id` or deposit. The only new fields on the contract are `tee_verifier_account_id` and `tee_verifier_votes`, both from [§Voting on the trusted verifier](#voting-on-the-trusted-verifier-in-mpc-contract). +Each [`PendingAttestation`](#mpc-contractsubmit_participant_info) holds: -Notably, the **post-DCAP policy state** — allowed MPC image hashes, allowed launcher compose hashes, and accepted measurements — is not snapshotted either. `verify_post_dcap_and_store` reads all of it fresh from contract state when the callback runs, so any governance vote that adds or removes an entry mid-flight applies to verifications it overlaps. Snapshotting at request time would freeze each submission against stale policy — wrong default for a security control, where removing a compromised hash should take effect immediately. +- **The submitter's `Attestation::Dstack` payload** — the RTMR3 event log, app-compose, and report-data the post-DCAP checks consume. +- **The submitter's TLS public key** — the callback hashes it with the submitter's account public key and compares to the quote's `report_data` field, proving the enclave produced the quote for this specific submitter. +- **The attached deposit** — covers storage staking on success, refunded to the signer of the original `submit_participant_info` transaction on failure. `env::attached_deposit()` is not visible from the callback receipt, so the value is stashed at submit time and the recipient `AccountId` is the same one used to key the entry (set by `Self::assert_caller_is_signer()`). +- **`data_id: CryptoHash`** — the yield handle returned by [`env::promise_yield_create`][promise-yield-create]. The intermediate `.then` callback (`resolve_verification`) reads this back to call [`env::promise_yield_resume`][promise-yield-resume] with a `FinalOutcome` after the post-DCAP checks have run. + +Entries are removed by `resolve_verification` on every branch where the verifier returned an answer — `Verified` (post-DCAP success or failure) and `Rejected`; only when the verifier gave no verdict (`Err(PromiseError::Failed)` — unreachable or crashed) does it return early and leave the entry in place. `on_attestation_verified` then removes the entry on its `Err(PromiseError::Failed)` branch, which covers that unreachable case and the no-response timeout. + +Notably absent from `PendingAttestation`: the **post-DCAP policy state** — allowed MPC image hashes, allowed launcher compose hashes, and accepted measurements. `resolve_verification` re-reads all of them from contract state, so any governance vote that adds or removes an entry mid-flight applies to verifications it overlaps. Snapshotting at request time would freeze each submission against stale policy — wrong default for a security control, where removing a compromised hash should take effect immediately. ```mermaid sequenceDiagram @@ -155,6 +154,8 @@ sequenceDiagram participant Ver as tee-verifier Op->>MPC: submit_participant_info(Dstack, tls_pk) + MPC->>MPC: promise_yield_create → data_id + MPC->>MPC: insert PendingAttestation { data_id, ... } MPC->>Ver: Promise: verify_quote(...) (.then resolve_verification) Gov->>MPC: vote_add_image_hash(H) @@ -162,8 +163,10 @@ sequenceDiagram Ver-->>MPC: VerificationResult::Verified(report) MPC->>MPC: resolve_verification - MPC->>MPC: verify_post_dcap_and_store reads allowlist fresh (sees H) - MPC->>MPC: store on pass / refund + fail receipt on error + MPC->>MPC: read allowlist (sees H) + MPC->>MPC: finish_verify against fresh allowlist + MPC->>MPC: promise_yield_resume(data_id, FinalOutcome) + MPC->>MPC: on_attestation_verified (trivial: return value) ``` ## Crate layout @@ -278,13 +281,13 @@ pub enum VerificationResult { #### Why a rejection is a value, not a failed receipt -Encoding the rejection the obvious way — `Result` via `#[handle_result]`, so a rejection fails the receipt — loses the reason. NEAR's promise result is either `Successful(bytes)` or `Failed`, and **`Failed` carries no payload**: the caller's `#[callback_result]` sees a bare `Err(PromiseError::Failed)`, *identical* to the verifier being unreachable or out of gas. `mpc-contract` still wants to tell "the verifier rejected this quote" apart from "the verifier did not answer", so it can report the right error to the caller. Returning the outcome as a *value* keeps them distinct, so `#[callback_result]` observes: +Encoding the rejection the obvious way — `Result` via `#[handle_result]`, so a rejection fails the receipt — loses the reason. NEAR's promise result is either `Successful(bytes)` or `Failed`, and **`Failed` carries no payload**: the caller's `#[callback_result]` sees a bare `Err(PromiseError::Failed)`, *identical* to the verifier being unreachable or out of gas. But `mpc-contract` must treat "the verifier rejected this quote" (definitive — refund and finish now) differently from "the verifier did not answer" (transient — wait for the yield timeout, the node resubmits). Returning the outcome as a *value* keeps them distinct, so `#[callback_result]` observes: - `Ok(VerificationResult::Verified(report))` — quote valid; run post-DCAP checks. -- `Ok(VerificationResult::Rejected(reason))` — rejected; `resolve_verification` returns `QuoteRejected { reason }`. -- `Err(PromiseError::Failed)` — unreachable / panicked / out of gas; `resolve_verification` returns `VerifierUnavailable`. +- `Ok(VerificationResult::Rejected(reason))` — rejected; refund and resume **immediately**, with the reason. +- `Err(PromiseError::Failed)` — unreachable / panicked / timed out; the yield timeout cleans up. -This is the NEP-141 / NEP-171 shape — the meaningful outcome rides in the success payload, a failed receipt is reserved for "something broke". Under the no-yield design both error branches lead to the same immediate refund-and-fail; keeping them distinct only changes the error type and message the caller receives. (near-sdk also refuses to serialize a bare `Result` return, so `VerificationResult` is a dedicated sum type.) +This is the NEP-141 / NEP-171 shape — the meaningful outcome rides in the success payload, a failed receipt is reserved for "something broke" — and it preserves `mpc-contract`'s existing invariant that a rejection and a non-answer are never the same event. (near-sdk also refuses to serialize a bare `Result` return, so `VerificationResult` is a dedicated sum type.) ### Voting on the trusted verifier in `mpc-contract` @@ -298,7 +301,7 @@ The proposal payload is the pair `(candidate_account_id, expected_code_hash)`. ` #[near(serializers = [borsh])] pub struct VerifierChangeProposal { pub candidate_account_id: AccountId, - pub expected_code_hash: TeeVerifierCodeHash, + pub expected_code_hash: CryptoHash, } impl ProposalHashEncoding for VerifierChangeProposal { @@ -319,7 +322,7 @@ impl MpcContract { pub fn vote_tee_verifier_change( &mut self, candidate_account_id: AccountId, - expected_code_hash: TeeVerifierCodeHash, + expected_code_hash: CryptoHash, ); /// Withdraw the caller's current vote on any pending verifier-change @@ -334,20 +337,17 @@ The contract gains two new state fields: pub struct MpcContract { // ... existing fields ... - /// The locked account `mpc-contract` currently trusts as the verifier, or - /// `None` until participants vote one in (a `Dstack` `submit_participant_info` - /// is then rejected with `VerifierNotConfigured`). `submit_participant_info` - /// calls `verify_quote` on this account. Mutated only by the threshold-crossing - /// vote above; the mutation re-routes future submissions and does not touch - /// already-stored attestations. (Making this non-`Option` once a verifier is - /// voted in is the follow-up #3639.) - tee_verifier_account_id: Option, + /// The locked account `mpc-contract` currently trusts as the verifier. + /// `submit_participant_info` calls `verify_quote` on this account. + /// Mutated only by the threshold-crossing vote above; the mutation + /// re-routes future submissions and does not touch already-stored + /// attestations. + tee_verifier_account_id: AccountId, /// Pending votes for changing `tee_verifier_account_id`. Each voter is an /// active MPC participant; each proposal is hashed from - /// `(candidate_account_id, expected_code_hash)`. `TeeVerifierVotes` is a thin - /// newtype wrapping the generic `Votes`. - tee_verifier_votes: TeeVerifierVotes, + /// `(candidate_account_id, expected_code_hash)`. + tee_verifier_votes: Votes, } ``` @@ -371,7 +371,7 @@ sequenceDiagram Note over MPC: tee_verifier_account_id = new (routing only,
no eviction) VerOld-->>MPC: VerificationResult::Verified(report) - MPC->>MPC: resolve_verification (post-DCAP + store, as usual) + MPC->>MPC: resolve_verification (post-DCAP + insert, as usual) Note over MPC: stored entry ages out within the
expiration window via re_verify Op->>MPC: submit_participant_info(Dstack, tls_pk) (next hourly resubmit) @@ -381,186 +381,235 @@ sequenceDiagram ### `mpc-contract::submit_participant_info` -The method resolves a Dstack submission through a two-receipt promise chain: `verify_quote` on the verifier, then `resolve_verification` as its callback — see [§Submission flow](#submission-flow) above for the architecture. It returns `Result, Error>`. A `Mock` attestation is verified and stored synchronously and returns `Ok(PromiseOrValue::Value(()))`. A `Dstack` attestation is delegated to `submit_dstack_attestation`, which builds the chain and returns `Ok(PromiseOrValue::Promise(...))`. The attached deposit is forwarded onto `resolve_verification` via `.with_attached_deposit`, so the callback can charge storage or refund without any state being stashed at submit time. There is no `pending_attestations` insert and no "one in-flight per account" guard. Draft implementation: +The method splits across three receipts joined by yield-resume — see [§Submission flow](#submission-flow) above for the architecture. The return type is [`PromiseOrValue<()>`](https://docs.rs/near-sdk/5.26.1/near_sdk/enum.PromiseOrValue.html), `near-sdk`'s "sometimes synchronous, sometimes a Promise chain" type: `Mock` attestations return `Value(())` immediately, and `Dstack` attestations return the yielded `Promise` from [`env::promise_yield_create`][promise-yield-create], which the runtime resolves either when `resolve_verification` calls [`env::promise_yield_resume`][promise-yield-resume] or after ~200 blocks of silence. The post-DCAP checks and the `stored_attestations` insert live in `resolve_verification`, not in the yield-callback — that's what keeps `on_attestation_verified` trivial enough to match the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]. Draft implementation: ```rust impl MpcContract { - #[payable] - #[handle_result] pub fn submit_participant_info( &mut self, attestation: Attestation, - tls_public_key: Ed25519PublicKey, - ) -> Result, Error> { + tls_pk: Ed25519PublicKey, + ) -> PromiseOrValue<()> { // Existing convention: caller must be the signer of this transaction, // not a relayer or proxy. let account_id = Self::assert_caller_is_signer(); - let node_id = NodeId { account_id, tls_public_key, /* account_public_key */ }; - match attestation { - // Synchronous: no DCAP, verified and stored in this call. A - // returned Err here rolls back the whole receipt. + // Unchanged from today. Attestation::Mock(mock) => { - let initial_storage = env::storage_usage(); - self.tee_state.verify_and_store_mock(node_id, mock, ...)?; - self.charge_attestation_storage(&node_id.account_id, initial_storage)?; - Ok(PromiseOrValue::Value(())) + self.verify_mock_synchronously(mock, tls_pk); + PromiseOrValue::Value(()) } - // Dstack: async via the verifier promise chain. - Attestation::Dstack(attestation) => Ok(PromiseOrValue::Promise( - self.submit_dstack_attestation(node_id, attestation)?, - )), - } - } - - /// Builds the verifier promise chain. Fails the submit transaction - /// synchronously with `VerifierNotConfigured` if no verifier has been - /// voted in — there is no account to call `verify_quote` on. Otherwise it - /// calls `verify_quote` on the trusted verifier and chains - /// `resolve_verification` as its `.then` callback, forwarding the attached - /// deposit onto that callback. Quote/collateral are serialized by - /// reference so `attestation` can move into the `VerificationContext`. - fn submit_dstack_attestation( - &mut self, - node_id: NodeId, - attestation: DstackAttestation, - ) -> Result { - let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { - return Err(TeeError::VerifierNotConfigured.into()); - }; + // Dstack: yield-resume. + Attestation::Dstack(dstack) => { + // One in-flight verification per AccountId. A duplicate submit + // before the previous one finishes (verifier response or + // runtime timeout) is rejected outright — same shape as + // duplicate sign requests. + if self.pending_attestations.contains_key(&account_id) { + env::panic_str("verification already pending"); + } + + let (quote, collateral) = extract_dcap_inputs(&dstack); + let attached_deposit = env::attached_deposit(); + + // Reuses the existing `enqueue_yield_request` helper that + // wraps `env::promise_yield_create`. The helper allocates + // `data_id`, registers `on_attestation_verified` as the + // yield-callback, and surfaces `data_id` via the `insert` + // closure so we can stash it together with the rest of the + // `PendingAttestation` fields. + self.enqueue_yield_request( + "on_attestation_verified", + borsh::to_vec(&account_id).unwrap(), + Gas::from_tgas(YIELD_CALLBACK_GAS_TGAS), + |this, data_id| { + this.pending_attestations.insert( + account_id.clone(), + PendingAttestation { + dstack, + tls_pk, + attached_deposit, + data_id, + }, + ); + }, + ); - Ok(Promise::new(verifier_account_id) - .function_call( - "verify_quote".into(), - borsh::to_vec(&(&attestation.quote, &attestation.collateral)).unwrap(), - NearToken::from_yoctonear(0), - Gas::from_tgas(self.config.verifier_tera_gas), - ) - .then( - Self::ext(env::current_account_id()) - .with_static_gas(Gas::from_tgas(self.config.resolve_verification_tera_gas)) - .with_attached_deposit(env::attached_deposit()) - .resolve_verification(VerificationContext { node_id, attestation }), - )) + // Cross-contract call to the verifier. Its `.then` callback + // (`resolve_verification`) is the bridge that turns the + // verifier's response into a `promise_yield_resume` on the + // yield this method registered above. + Promise::new(self.tee_verifier_account_id.clone()) + .function_call( + "verify_quote".into(), + borsh::to_vec(&(quote, collateral)).unwrap(), + NearToken::from_yoctonear(0), + Gas::from_tgas(VERIFIER_GAS_TGAS), + ) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(Gas::from_tgas(RESOLVE_GAS_TGAS)) + .resolve_verification(account_id), + ); + + // The yield handle was returned by `enqueue_yield_request` + // via `env::promise_return`, so the caller's `Promise` + // resolves with whatever the yield-callback returns. + PromiseOrValue::Value(()) + } + } } - /// Verify-quote callback. `#[payable]` because the submitter's deposit - /// rides forward via `.with_attached_deposit`, so `env::attached_deposit()` - /// here is the amount they attached. `#[callback_result]` distinguishes the - /// three verifier outcomes: - /// - /// - `Ok(Verified)` → run post-DCAP checks and store. - /// - `Ok(Rejected)` → `QuoteRejected { reason }`. - /// - `Err(_)` → `VerifierUnavailable` (unreachable / panicked / OOG). + /// `.then` bridge between the verifier's cross-contract call and the + /// yield this submission registered. Owns every outcome where the verifier + /// *answered* (`Ok(VerificationResult::{Verified,Rejected})`): on + /// `Verified` it runs the post-DCAP checks against fresh policy state and + /// inserts into `stored_attestations` on success; on `Rejected` it skips + /// straight to the refund. Either way it removes the pending entry, + /// schedules a refund where the outcome is an error, and calls + /// `promise_yield_resume(data_id, FinalOutcome)` as the LAST step of the + /// receipt — so a rejected quote is resolved *immediately*, not at the + /// timeout. Only `Err(PromiseError::Failed)` (no verdict — verifier + /// unreachable or crashed) is logged and returned early WITHOUT resuming or + /// removing the pending entry; `on_attestation_verified` owns that cleanup + /// on its `Err(PromiseError::Failed)` branch, so we must not race the + /// timeout for it. State mutations in this receipt are visible to the + /// yield-callback that fires next; if any line below `promise_yield_resume` + /// panicked or OOG'd, the entire receipt would roll back atomically (no + /// partial state commits) and the runtime's ~200-block yield-timeout would + /// still fire `on_attestation_verified` with `Err(PromiseError::Failed)` + /// for cleanup. /// - /// On success returns `Value(())` (attestation stored, storage charged, - /// excess refunded). On any error it refunds the WHOLE attached deposit in - /// this receipt, then fires a SEPARATE `fail_attestation_submission` - /// receipt whose panic fails the caller's transaction — the split is what - /// lets the refund commit, since a panic in this receipt would roll it back - /// (and drop the created promises) along with the refund. + /// Same architectural shape as [`pending_requests::resolve_yields_for`][pending-requests-mod] + /// in the sign-request flow: the response-side function owns the state + /// mutation and the `promise_yield_resume` call; the yield-callback is + /// kept trivial. #[private] - #[payable] pub fn resolve_verification( &mut self, - #[serializer(borsh)] context: VerificationContext, - #[serializer(borsh)] - #[callback_result] - result: Result, - ) -> PromiseOrValue<()> { - let account_id = context.node_id.account_id.clone(); - - let attestation_result = match result { - Ok(VerificationResult::Verified(report)) => { - self.verify_post_dcap_and_store(&context, &report) + account_id: AccountId, + #[callback_result] result: Result, + ) { + let final_outcome = match result { + // No verdict: the verifier was unreachable, panicked, or ran out of + // gas. Do nothing — the runtime's yield-timeout will fire + // `on_attestation_verified` with `Err(PromiseError::Failed)` and + // clean up the pending entry there. We must not call + // `promise_yield_resume` here, or we'd race the timeout for + // ownership of the cleanup path. + Err(promise_err) => { + log!("verifier did not answer for {account_id}: {promise_err:?}"); + return; } + // The verifier ran and rejected the quote. A definitive verdict: + // refund and resume now, with the reason, rather than waiting for + // the timeout. Ok(VerificationResult::Rejected(reason)) => { log!("verifier rejected quote for {account_id}: {reason}"); - Err(TeeError::QuoteRejected { reason: reason.to_string() }.into()) + FinalOutcome::Err(format!("verifier: {reason}")) } - Err(promise_err) => { - log!("verifier did not answer for {account_id}: {promise_err:?}"); - Err(TeeError::VerifierUnavailable.into()) + Ok(VerificationResult::Verified(report)) => { + let pending = self.pending_attestations.get(&account_id).expect( + "PendingAttestation must exist while resolve_verification holds the yield", + ); + // Post-DCAP checks operate on the verified report plus state held + // here. The allowlist is read fresh — governance votes mid-flight + // take effect. + match finish_verify(pending, &report, self.allowlist_fresh()) { + Ok(()) => { + self.tee_state.stored_attestations.insert( + pending.tls_pk.clone(), + VerifiedAttestation::from((pending.clone(), report)), + ); + FinalOutcome::Ok + } + Err(reason) => { + log!("post-DCAP check failed for {account_id}: {reason}"); + FinalOutcome::Err(format!("post-DCAP: {reason}")) + } + } } }; - match attestation_result { - Ok(()) => PromiseOrValue::Value(()), - Err(err) => { - refund_to(&account_id, env::attached_deposit()); - let promise = Promise::new(env::current_account_id()).function_call( - "fail_attestation_submission".into(), - borsh::to_vec(&err.to_string()).unwrap(), - NearToken::from_yoctonear(0), - Gas::from_tgas(self.config.fail_attestation_submission_tera_gas), - ); - PromiseOrValue::Promise(promise.as_return()) - } + let pending = self + .pending_attestations + .remove(&account_id) + .expect("PendingAttestation must exist while resolve_verification holds the yield"); + if matches!(final_outcome, FinalOutcome::Err(_)) { + refund_deposit(&account_id, pending.attached_deposit); } + // `promise_yield_resume` must be the LAST host call in this receipt: + // anything after it could panic and roll back the state mutations above. + env::promise_yield_resume(&pending.data_id, borsh::to_vec(&final_outcome).unwrap()); } - /// Runs the post-DCAP checks and stores the attestation for a `Verified` - /// response. The callback receipt commits regardless of the `Err` returned, - /// so a failed storage charge cannot rely on implicit rollback: it reverts - /// the store explicitly, or the caller would get storage for free plus a - /// full refund. - fn verify_post_dcap_and_store( + /// Yield-callback. Same shape as the sign-request callback + /// [`return_signature_and_clean_state_on_success`][sign-yield-callback]: the + /// `Verified` and `Rejected` outcomes (every case where the verifier + /// answered) were already finalized by `resolve_verification` (which removed + /// the pending entry and scheduled any refund before calling + /// `promise_yield_resume`), so this body just returns the outcome to the + /// caller. + /// + /// The only branch that does real work is `Err(PromiseError::Failed)`, fired + /// by the runtime ~200 blocks after submit if no `promise_yield_resume` has + /// landed: the verifier was unreachable / never responded so + /// `resolve_verification` deliberately returned early, or it ran but rolled + /// back (OOM / panic). On that branch the pending entry is still present, so + /// it removes the entry and schedules a deposit refund. + #[private] + pub fn on_attestation_verified( &mut self, - context: &VerificationContext, - report: &VerifiedReport, - ) -> Result<(), Error> { - let account_id = &context.node_id.account_id; - let initial_storage = env::storage_usage(); - let insertion = self.tee_state.verify_and_store_dstack( - context.node_id.clone(), - &context.attestation, - report, - /* tee_upgrade_deadline_duration */ - )?; - - match self.charge_attestation_storage(account_id, initial_storage) { - Ok(()) => Ok(()), - Err(err) => { - self.tee_state - .revert_dstack_store(&context.node_id.tls_public_key, insertion); - Err(err) + account_id: AccountId, + #[callback_result] result: Result, + ) -> Result<(), String> { + match result { + Ok(FinalOutcome::Ok) => Ok(()), + Ok(FinalOutcome::Err(reason)) => Err(reason), + Err(_promise_err) => { + if let Some(pending) = self.pending_attestations.remove(&account_id) { + refund_deposit(&account_id, pending.attached_deposit); + log!("yield timeout for {account_id}: refunded and cleaned up"); + } + Err("verifier did not respond within yield-resume window".to_string()) } } } +} - /// Separate receipt whose panic fails the caller's transaction after the - /// refund in `resolve_verification` has committed. - #[private] - pub fn fail_attestation_submission(#[serializer(borsh)] reason: String) { - log!("fail_attestation_submission: {reason}"); - env::panic_str(&reason); - } +#[derive(BorshSerialize, BorshDeserialize)] +pub enum FinalOutcome { + Ok, + Err(String), } ``` -`charge_attestation_storage` reads `env::attached_deposit()` itself: if the attached amount is less than the measured storage cost it returns `InsufficientDeposit`; otherwise it refunds the excess to the account via `refund_to`. `refund_to` is the generic refund helper (a detached `transfer` promise, no-op on zero). +`VERIFIER_GAS_TGAS`, `RESOLVE_GAS_TGAS`, and `YIELD_CALLBACK_GAS_TGAS` are placeholders until benchmarked. The verifier-side cost is dominated by ECDSA verifications and X.509-chain walking inside `dcap_qvl::verify::verify`. The bulk of the contract-side post-DCAP work — RTMR3 replay, app-compose validation, allowlist matching, report-data binding, plus the `stored_attestations.insert` — runs inside `resolve_verification`, so `RESOLVE_GAS_TGAS` gets the largest budget. `YIELD_CALLBACK_GAS_TGAS` can be conservatively small (on the order of 10 TGas with comfortable headroom): the yield-callback only does a `LookupMap::remove` and schedules a `Promise` on the timeout branch, and just returns a value on the resume branch. -`verifier_tera_gas`, `resolve_verification_tera_gas`, and `fail_attestation_submission_tera_gas` are unbenchmarked estimates until measured. The verifier-side cost (`verifier_tera_gas`) is dominated by ECDSA verifications and X.509-chain walking inside `dcap_qvl::verify::verify`, so it gets the largest budget. `resolve_verification_tera_gas` covers the contract-side post-DCAP work — RTMR3 replay, app-compose validation, allowlist matching, report-data binding — plus the `verify_and_store_dstack` insert and the storage charge. `fail_attestation_submission_tera_gas` can be tiny (a couple of TGas): the method only logs and panics. +The contract gains the following state fields: -### Contract state changes summary +```rust +pub struct MpcContract { + // ... existing fields, including tee_verifier_account_id and + // tee_verifier_votes from §Voting on the trusted verifier ... + pending_attestations: LookupMap, +} -No new attestation state fields. The chain carries a `VerificationContext { node_id, attestation }` as a borsh callback argument; nothing new is written to storage. The only new fields on the contract are `tee_verifier_account_id` and `tee_verifier_votes` from [§Voting on the trusted verifier](#voting-on-the-trusted-verifier-in-mpc-contract). +pub struct PendingAttestation { + pub dstack: DstackAttestation, + pub tls_pk: Ed25519PublicKey, + pub attached_deposit: NearToken, + pub data_id: CryptoHash, +} +``` ## Testing -The no-yield chain adds a handful of resolution branches the synchronous version never had, all inside `resolve_verification` and the helper it delegates to: - -- **Verifier not configured** — `Dstack` submit while `tee_verifier_account_id` is `None` fails *synchronously* with `VerifierNotConfigured`; the submit transaction itself errors, no promise is scheduled. -- **Verified + store happy path** — `verify_quote` returns `Verified`, post-DCAP passes, storage charged, excess refunded, `Value(())`. The attestation is present in state afterward. -- **Verified + post-DCAP fail** — `verify_and_store_dstack` errors; `resolve_verification` refunds the whole deposit and fires the `fail_attestation_submission` receipt; nothing is stored. -- **Verified + insufficient deposit** — post-DCAP passes but `charge_attestation_storage` returns `InsufficientDeposit`; `verify_post_dcap_and_store` reverts the store explicitly, so state is unchanged; refund + fail receipt. -- **Rejected → fail + refund** — `verify_quote` returns `Rejected`; `resolve_verification` returns `QuoteRejected` carrying the reason; refund + fail receipt. -- **Verifier unreachable → `VerifierUnavailable`** — the callback observes `Err(PromiseError::Failed)`; refund + fail receipt. -- **OOG in `resolve_verification` rolls back atomically** — an out-of-gas or panic mid-callback rolls back the whole receipt (no partial store, no partial refund) and fails the caller's transaction; because nothing was inserted at submit time, there is no orphaned state to reclaim. +The yield-resume split adds four resolution branches the synchronous version never had. Three do their work in `resolve_verification`, each from a distinct verifier answer: `Verified` + post-DCAP pass (store + resume `Ok`), `Verified` + post-DCAP fail (refund + resume `Err`), and `Rejected` (refund + resume `Err`, immediately — the path that recovers the synchronous-rejection behavior the split would otherwise lose). The fourth lives in `on_attestation_verified`, on its `Err(PromiseError::Failed)` branch, reached when the verifier gave no verdict — unreachable, panicked, or no resume landed within ~200 blocks (verifier silent, or a `resolve_verification` receipt that rolled back). That no-verdict case re-enters `resolve_verification`, which logs and returns early without resuming, so its cleanup happens in `on_attestation_verified`. Each branch needs test coverage, and exercising them requires the verifier to return specific answers on demand — a `Verified` or `Rejected` value for the three `resolve_verification` branches, and for the no-verdict path either an unreachable account or the test driver advancing the chain past the yield-resume window without resuming. The verifier-rotation design changes the test surface in three ways. First, the expiration window itself: an entry whose `expiry_timestamp_seconds` is in the past must be rejected by `re_verify` even when every post-DCAP allowlist invariant still holds, and an entry within the (shortened) window must still pass — this is the existing expiry check, now exercised against the lowered `DEFAULT_EXPIRATION_DURATION_SECONDS`. Second, rotation routing: after `vote_tee_verifier_change` crosses threshold, the next `submit_participant_info` must call `verify_quote` on the new `tee_verifier_account_id`, and existing stored entries must remain present (no purge) until they expire. Third, the in-flight case: a verification scheduled against the old verifier that resolves after the vote crosses threshold must still be stored as a normal entry — it is not treated specially and ages out via the same expiration window as any other entry. -To make that practical, we introduce a stub `tee-verifier` crate: same `tee-verifier-interface` DTOs as the real verifier, but `verify_quote` returns whatever `VerificationResult` (`Verified` or `Rejected`) the test asks for — and a stub that panics, or an undeployed account, covers the `VerifierUnavailable` path. Sandbox tests deploy the stub like any other verifier candidate — lock its account, then call `vote_tee_verifier_change` from the test setup to point `mpc-contract` at the stub. This runs the same code path as production; nothing in `mpc-contract` knows or cares whether it's talking to the real verifier or the stub. +To make that practical, we introduce a stub `tee-verifier` crate: same `tee-verifier-interface` DTOs as the real verifier, but `verify_quote` returns whatever `VerificationResult` (`Verified` or `Rejected`) the test asks for — and a stub that panics, or an undeployed account, covers the no-verdict path. Sandbox tests deploy the stub like any other verifier candidate — lock its account, then call `vote_tee_verifier_change` from the test setup to point `mpc-contract` at the stub. This runs the same code path as production; nothing in `mpc-contract` knows or cares whether it's talking to the real verifier or the stub. E2E tests in `crates/e2e-tests` deploy either the real `tee-verifier` (when the test wants real `dcap-qvl` against a fixture quote) or the stub (for everything else). The change is one extra `deploy` call in the setup helper. @@ -569,9 +618,17 @@ E2E tests in `crates/e2e-tests` deploy either the real `tee-verifier` (when the [nep-509]: https://github.com/near/NEPs/blob/master/neps/nep-0509.md [re-verify]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/mpc-attestation/src/attestation.rs#L93 [periodic-attestation-submission]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/tee/remote_attestation.rs#L140 +[attestation-resubmission-interval]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/run.rs#L43 +[attestation-attempts-metric]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/metrics.rs#L364 [verify-tee]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1543 [clean-invalid-attestations]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1646 +[clean-foreign-chain-data]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1669 [monitor-attestation-removal]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/tee/remote_attestation.rs#L209 [submit-participant-info]: https://github.com/near/mpc/blob/efe49230bb66854c55bba080e7610e42f9221506/crates/contract/src/lib.rs#L754-L782 [launcher-pattern]: https://github.com/near/mpc/blob/efe49230bb66854c55bba080e7610e42f9221506/docs/tee-lifecycle.md#upgrade [slack-launcher-discussion]: https://nearone.slack.com/archives/C0B12RKBSAV/p1777897902903889 +[promise-yield-create]: https://docs.rs/near-sdk/5.26.1/near_sdk/env/fn.promise_yield_create.html +[promise-yield-resume]: https://docs.rs/near-sdk/5.26.1/near_sdk/env/fn.promise_yield_resume.html +[enqueue-yield-request]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L301-L323 +[pending-requests-mod]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/pending_requests.rs +[sign-yield-callback]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1999-L2023 diff --git a/docs/localnet/tee-localnet.md b/docs/localnet/tee-localnet.md index 07c32cba98..054a39042f 100644 --- a/docs/localnet/tee-localnet.md +++ b/docs/localnet/tee-localnet.md @@ -276,7 +276,7 @@ near transaction view-status network-config mpc-localnet ``` ``` -(ExecutionError("Smart contract panicked: the submitted attestation failed verification, reason: Custom(\"the allowed mpc image hashes list is empty\")" +(ExecutionError("Smart contract panicked: Invalid TEE Remote Attestation.: TeeQuoteStatus is invalid: the allowed mpc image hashes list is empty" ``` ### Vote Commands diff --git a/docs/running-an-mpc-node-in-tdx-external-guide.md b/docs/running-an-mpc-node-in-tdx-external-guide.md index 60c969e0ec..3c455fc1f5 100644 --- a/docs/running-an-mpc-node-in-tdx-external-guide.md +++ b/docs/running-an-mpc-node-in-tdx-external-guide.md @@ -2062,7 +2062,8 @@ The error after `err=` is the NEAR runtime error. Common ones: If the transaction reaches execution and the contract panics, the node logs only the generic retry line above; the actual message lives in the transaction receipt. Find the tx on `https://testnet.nearblocks.io/address/` and open the failed `submit_participant_info` call — the error appears under the action's status / logs. The contract wraps the attestation-side error like this: ``` -the submitted attestation failed verification, reason: Custom("...") +Invalid TEE Remote Attestation: TeeQuoteStatus is invalid: + the submitted attestation failed verification, reason: Custom("...") ``` The `reason` is the same `VerificationError` the client-side WARN reports (see section 1) — for example `Custom("the allowed mpc image hashes list is empty")`. Errors that **only** surface on-chain (because they're checked against the contract's allowed-measurements list, the contract's deposit logic, or the contract's caller assertion): From aa1906f436e585f68a3abcb3f6b1fa905dd92132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 15 Jul 2026 17:32:42 +0200 Subject: [PATCH 18/26] test(contract): drop the OOG deposit-refund assertion On a resolve_verification out-of-gas the callback dies before its refund_to, so the runtime returns the forwarded deposit to the predecessor (the contract), not the submitter. The OOG test now asserts only the atomic-rollback property (chain fails, nothing stored) and no longer asserts a refund that does not occur. --- crates/contract/tests/sandbox/tee_verifier.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 0bb55aa85b..af99ed629c 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -312,15 +312,16 @@ async fn submit_participant_info__should_fail_and_store_nothing_when_resolve_ver resolve_verification_tera_gas: Some(1), ..Default::default() }; - let (_worker, contract, submitter, balance_before) = + let (_worker, contract, submitter, _balance_before) = setup_with_stub(StubResponse::Verified(verified_report()), Some(init_config)).await; // When: a Dstack attestation is submitted. let result = submit_dstack(&submitter, &contract).await; - // Then: the callback receipt fails wholesale, nothing is stored, and the - // runtime refunds the attached deposit. Proves an OOG in resolve cannot commit - // partial state. + // Then: the OOG rolls the whole callback receipt back — the submission is reported + // as failed and nothing is stored. No deposit-refund assertion: on an OOG the callback + // dies before its `refund_to`, so the runtime returns the deposit to the predecessor + // (the contract), never to the submitter, and nothing returns it later. assert!( !result.failures().is_empty(), "an OOG resolve_verification must fail the chain, got: {result:#?}" @@ -332,5 +333,4 @@ async fn submit_participant_info__should_fail_and_store_nothing_when_resolve_ver stored.is_none(), "nothing should be stored on an OOG resolve" ); - assert_deposit_refunded(&submitter, balance_before, &result).await; } From f58289402a1b52903d1aec6bd070ed86c6b587df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 16 Jul 2026 16:51:16 +0200 Subject: [PATCH 19/26] test(contract): reconcile async attestation tests with flat-fee parent Rebasing onto the flat-fee parent left tests calling deleted code or asserting the old refund-on-success model. - Drop the two revert_dstack_store unit tests (the function is gone). - Adapt the mock-success and verified-quote sandbox tests to assert the whole flat fee is consumed, reusing the shared SUBMIT_PARTICIPANT_INFO_DEPOSIT const. Failure-path refunds (rejection / post-DCAP fail / unavailable) are unchanged. --- crates/contract/src/tee/tee_state.rs | 64 ------------------- crates/contract/tests/sandbox/tee.rs | 19 ++---- crates/contract/tests/sandbox/tee_verifier.rs | 41 ++++-------- 3 files changed, 20 insertions(+), 104 deletions(-) diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 509a7b0c4c..2d68e7a239 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -1383,70 +1383,6 @@ mod tests { assert_eq!(stored.node_id, rotated_node); } - #[test] - fn revert_dstack_store__should_restore_the_displaced_entry_on_update() { - // Given: `alice` has an attestation, then updates it (the second insertion - // returns the displaced original wrapped in `UpdatedExistingParticipant`). - const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); - let mut tee_state = TeeState::default(); - let account_id = "alice.near".parse().unwrap(); - let tls_public_key = bogus_ed25519_public_key(); - let original_node = create_node_id(&account_id, &tls_public_key); - tee_state - .verify_and_store_mock( - original_node.clone(), - MockAttestation::Valid, - TEE_UPGRADE_DURATION, - ) - .expect("initial insertion should succeed"); - let updated_node = create_node_id(&account_id, &tls_public_key); - let insertion = tee_state - .verify_and_store_mock(updated_node, MockAttestation::Valid, TEE_UPGRADE_DURATION) - .expect("update should succeed"); - - let original_entry = NodeAttestation { - node_id: original_node, - verified_attestation: VerifiedAttestation::Mock(MockAttestation::Valid), - }; - let ParticipantInsertion::UpdatedExistingParticipant(displaced) = &insertion else { - panic!("expected an update, got {insertion:?}"); - }; - assert_eq!(*displaced, original_entry); - - // When: the store is reverted. - tee_state.revert_dstack_store(&tls_public_key, insertion); - - // Then: the whole original entry is back in place. - let stored = tee_state - .stored_attestations - .get(&tls_public_key) - .expect("original entry must be restored"); - assert_eq!(*stored, original_entry); - } - - #[test] - fn revert_dstack_store__should_remove_the_newly_inserted_entry() { - // Given: a brand-new attestation for `alice` (no prior entry displaced). - const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); - let mut tee_state = TeeState::default(); - let account_id = "alice.near".parse().unwrap(); - let tls_public_key = bogus_ed25519_public_key(); - let node = create_node_id(&account_id, &tls_public_key); - let insertion = tee_state - .verify_and_store_mock(node, MockAttestation::Valid, TEE_UPGRADE_DURATION) - .expect("insertion should succeed"); - assert_matches!(insertion, ParticipantInsertion::NewlyInsertedParticipant); - - // When: the store is reverted. - tee_state.revert_dstack_store(&tls_public_key, insertion); - - // Then: the entry is gone. - assert!( - tee_state.stored_attestations.get(&tls_public_key).is_none(), - "newly inserted entry must be removed on revert" - ); - } - #[test] fn verify_and_store_mock__should_reject_invalid_attestations() { let mut tee_state = TeeState::default(); diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index 3f419e7a5d..030266f1de 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -8,7 +8,7 @@ use crate::sandbox::{ mpc_contract::{ assert_running_return_participants, assert_running_return_threshold, get_participant_attestation, get_state, get_tee_accounts, submit_participant_info, - total_gas_fee, vote_add_launcher_hash, vote_for_hash, + vote_add_launcher_hash, vote_for_hash, }, resharing_utils::conclude_resharing, sign_utils::DomainResponseTest, @@ -260,7 +260,6 @@ pub async fn get_participants(contract: &Contract) -> Result { #[tokio::test] async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result<()> { let SandboxTestSetup { - worker, contract, mpc_signer_accounts, .. @@ -270,7 +269,6 @@ async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result .await; let submitter = &mpc_signer_accounts[0]; let balance_before = submitter.view_account().await?.balance; - let storage_before = worker.view_account(contract.id()).await?.storage_usage; let result = submit_participant_info( submitter, @@ -281,18 +279,13 @@ async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result .await?; assert!(result.is_success()); - // The caller's net spend must be exactly the measured storage stake plus the fee - // actually burnt: the storage entry is charged from the attached deposit and every - // other yoctoNEAR of the deposit is refunded. - let bytes_grown = - u128::from(worker.view_account(contract.id()).await?.storage_usage - storage_before); - assert!(bytes_grown > 0); - let storage_stake = near_sdk::env::storage_byte_cost().saturating_mul(bytes_grown); + // The flat fee is consumed (not refunded), so net spend is at least the fee + // (the rest is gas). A refund would drop it below the fee. let balance_after = submitter.view_account().await?.balance; let net_spent = balance_before.saturating_sub(balance_after); - assert_eq!( - net_spent, - storage_stake.saturating_add(total_gas_fee(&result)) + assert!( + net_spent >= SUBMIT_PARTICIPANT_INFO_DEPOSIT, + "flat fee must be consumed, not refunded: spent {net_spent}, fee {SUBMIT_PARTICIPANT_INFO_DEPOSIT}" ); Ok(()) } diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index af99ed629c..8594f23332 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -12,7 +12,7 @@ use crate::sandbox::{ common::SandboxTestSetup, utils::{ - consts::ALL_PROTOCOLS, + consts::{ALL_PROTOCOLS, SUBMIT_PARTICIPANT_INFO_DEPOSIT}, contract_build::stub_tee_verifier_contract, mpc_contract::{ get_participant_attestation, submit_participant_info, @@ -28,9 +28,9 @@ use near_workspaces::{ use test_tee_verifier_types::StubResponse; use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key, verified_report}; -/// Deposit attached to a Dstack submission: covers storage on success, fully -/// refunded on failure. -const SUBMIT_DEPOSIT: NearToken = NearToken::from_near(1); +/// Deposit attached to a Dstack submission: the flat storage fee, consumed on +/// success and fully refunded on failure. +const SUBMIT_DEPOSIT: NearToken = SUBMIT_PARTICIPANT_INFO_DEPOSIT; /// 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 @@ -258,44 +258,31 @@ async fn submit_participant_info__should_refund_and_store_nothing_when_post_dcap async fn submit_participant_info__should_store_attestation_on_verified_quote() { // Given: a verifier that returns the report the real verifier would produce // for the fixture quote. - let (worker, contract, submitter, balance_before) = + let (_worker, contract, submitter, balance_before) = setup_with_stub(StubResponse::Verified(verified_report()), None).await; - let storage_before = worker - .view_account(contract.id()) - .await - .unwrap() - .storage_usage; // When: a Dstack attestation is submitted. let result = submit_dstack(&submitter, &contract).await; - // Then: the chain succeeds and the attestation is stored; storage is charged - // and the excess deposit refunded, so net spend is storage + gas, well under - // the full deposit. + // Then: every receipt in the verify_quote -> resolve_verification chain + // succeeds, the attestation is stored, and the flat fee is consumed (not + // refunded), so net spend is the fee plus gas. assert!( result.failures().is_empty(), - "the verified submission chain must succeed, got: {result:#?}" + "no receipt in the verify_quote -> resolve_verification promise chain may fail, got: {result:#?}" ); let stored = get_participant_attestation(&contract, &p2p_tls_key().into()) .await .unwrap(); assert!(stored.is_some(), "a verified attestation must be stored"); - // The caller's net spend must be exactly the measured storage stake plus the fee - // actually burnt; the rest of the SUBMIT_DEPOSIT is refunded. - let storage_after = worker - .view_account(contract.id()) - .await - .unwrap() - .storage_usage; - let bytes_grown = u128::from(storage_after - storage_before); - assert!(bytes_grown > 0); - let storage_stake = near_sdk::env::storage_byte_cost().saturating_mul(bytes_grown); + // net spend is at least the fee (the rest is gas); a refund would drop it + // below the fee, proving the whole fee was consumed, not returned. let balance_after = submitter.view_account().await.unwrap().balance; let net_spent = balance_before.saturating_sub(balance_after); - assert_eq!( - net_spent, - storage_stake.saturating_add(total_gas_fee(&result)) + assert!( + net_spent >= SUBMIT_DEPOSIT, + "flat fee must be consumed, not refunded: spent {net_spent}, fee {SUBMIT_DEPOSIT}" ); } From c521d0dae1d9a1bbc7c34bb00513815f2e67688f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 17 Jul 2026 09:54:59 +0200 Subject: [PATCH 20/26] test(contract): drop tests-only PartialEq/Eq from NodeAttestation The derive was added so a test could compare a whole stored NodeAttestation, but that comparison was dropped during the flat-fee reconcile. Surviving tests compare via node_id, so the derive is unused. Addresses review r3600908329. --- crates/contract/src/tee/tee_state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 2d68e7a239..24f1ad737b 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -63,7 +63,7 @@ pub enum TeeValidationResult { }, } -#[derive(Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[derive(Debug, BorshSerialize, BorshDeserialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) From 6014d1147b384cccef1296d300ffc903062ca9f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 17 Jul 2026 10:34:30 +0200 Subject: [PATCH 21/26] test(contract): trim redundant Given/When/Then comment explanations Reduce the G/W/T markers whose text just restated the adjacent code or the test name to bare markers; keep the ones that note non-obvious behavior (sync-vs-receipt failure, why a verified quote is still rejected, OOG rollback). Addresses review r3600917443. --- crates/contract/src/tee/tee_state.rs | 6 +++--- .../tests/inprocess/attestation_submission.rs | 11 ++++------ crates/contract/tests/sandbox/tee_verifier.rs | 21 +++++++++---------- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 24f1ad737b..4df7c75145 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -1415,18 +1415,18 @@ mod tests { #[test] fn verify_and_store_dstack__should_reject_and_store_nothing_when_post_dcap_checks_fail() { - // Given: an empty allowlist, so any Dstack attestation fails the post-DCAP checks. + // Given let mut tee_state = TeeState::default(); let Attestation::Dstack(dstack) = mock_dstack_attestation() else { panic!("fixture is a Dstack attestation"); }; let node_id = node_id_for(&"alice.near".parse().unwrap()); - // When: it is verified and stored. + // When let result = tee_state.verify_and_store_dstack(node_id, &dstack, &verified_report(), Duration::MAX); - // Then: it is rejected and nothing is stored. + // Then assert_matches!( result, Err(AttestationSubmissionError::InvalidAttestation(_)) diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index 38277a3d93..e1386aaa5f 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -290,7 +290,7 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { /// cannot store an attestation without paying for it. #[test] fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() { - // Given: a participant whose submission context attaches only 1 yoctoNEAR. + // Given let mut setup = TestSetupBuilder::new().build(); let node = setup.get_participant_node_ids()[0].clone(); let attached_deposit = NearToken::from_yoctonear(1); @@ -302,7 +302,7 @@ fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() { .build() ); - // When: that participant submits a valid mock attestation. + // When let result = setup .contract .submit_participant_info( @@ -311,10 +311,7 @@ fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() { ) .map(|_| ()); - // Then: the storage charge rejects it, with the required cost exceeding the attached deposit. - // (The mock path stores before charging and relies on the runtime rolling the receipt back on - // this Err; that rollback is a chain-level guarantee not modeled by the in-process VM, so we - // assert only the error here.) + // Then assert_matches!( &result, Err(Error::InvalidParameters(InvalidParameters::InsufficientDeposit { attached, required })) @@ -325,7 +322,7 @@ fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() { /// Test that a `Dstack` submission is rejected when no verifier is configured. #[test] fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() { - // Given: a running contract with no TEE verifier voted in. + // Given let mut setup = TestSetupBuilder::new().build(); let node = setup.get_participant_node_ids()[0].clone(); diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 8594f23332..155fb6107f 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -139,7 +139,7 @@ async fn assert_deposit_refunded( #[tokio::test] async fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() { - // Given: no verifier voted in. + // Given let SandboxTestSetup { mpc_signer_accounts, contract, @@ -149,7 +149,7 @@ async fn submit_participant_info__should_reject_dstack_when_verifier_not_configu .build() .await; - // When: a Dstack attestation is submitted. + // When let result = submit_participant_info( &mpc_signer_accounts[0], &contract, @@ -181,14 +181,14 @@ async fn submit_participant_info__should_reject_dstack_when_verifier_not_configu #[tokio::test] async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_rejection() { - // Given: a verifier that always rejects. + // Given let (_worker, contract, submitter, balance_before) = setup_with_stub(StubResponse::Rejected("test rejection".to_string()), None).await; - // When: a Dstack attestation is submitted. + // When let result = submit_dstack(&submitter, &contract).await; - // Then: the submission fails cleanly, reporting the verifier's rejection reason. + // Then assert_submission_failed_cleanly( &result, &contract, @@ -207,7 +207,7 @@ async fn submit_participant_info__should_fail_and_store_nothing_on_verifier_cras let (_worker, contract, submitter, balance_before) = setup_with_stub(StubResponse::Panic, None).await; - // When: a Dstack attestation is submitted. + // When let result = submit_dstack(&submitter, &contract).await; // Then: the callback sees a failed promise, resolves to VerifierUnavailable, @@ -229,7 +229,7 @@ async fn submit_participant_info__should_refund_and_store_nothing_when_post_dcap let (_worker, contract, submitter, balance_before) = setup_with_stub(StubResponse::Verified(verified_report()), None).await; - // When: a Dstack attestation is submitted. + // When let result = submit_dstack(&submitter, &contract).await; // Then: the callback's post-DCAP check rejects the (verified) quote. Asserted inline @@ -256,12 +256,11 @@ async fn submit_participant_info__should_refund_and_store_nothing_when_post_dcap #[ignore = "needs fixture allowlist setup to pass the post-DCAP checks; tracked in #3787"] #[tokio::test] async fn submit_participant_info__should_store_attestation_on_verified_quote() { - // Given: a verifier that returns the report the real verifier would produce - // for the fixture quote. + // Given let (_worker, contract, submitter, balance_before) = setup_with_stub(StubResponse::Verified(verified_report()), None).await; - // When: a Dstack attestation is submitted. + // When let result = submit_dstack(&submitter, &contract).await; // Then: every receipt in the verify_quote -> resolve_verification chain @@ -302,7 +301,7 @@ async fn submit_participant_info__should_fail_and_store_nothing_when_resolve_ver let (_worker, contract, submitter, _balance_before) = setup_with_stub(StubResponse::Verified(verified_report()), Some(init_config)).await; - // When: a Dstack attestation is submitted. + // When let result = submit_dstack(&submitter, &contract).await; // Then: the OOG rolls the whole callback receipt back — the submission is reported From 198ed044f995c3e2b7b6cff8c0a27aaf4512ff9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 17 Jul 2026 10:46:37 +0200 Subject: [PATCH 22/26] test(contract): drop unused PartialEq/Eq from VerifiedAttestation and ValidatedDstackAttestation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were added for the revert_dstack_store whole-value assertion, which was dropped in the flat-fee reconcile (same as NodeAttestation). They are now unused. MockAttestation keeps PartialEq — production tx_sender.rs compares it. Addresses review r3601096217. --- crates/mpc-attestation/src/attestation.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 51b95f3b6c..4a95134e74 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -37,7 +37,7 @@ pub enum Attestation { Mock(MockAttestation), } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] +#[derive(Clone, Debug, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) @@ -200,7 +200,7 @@ impl MockAttestation { } } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] +#[derive(Clone, Debug, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) From 63f634c5d5e1094868473e5e7033354b1f7fe744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 17 Jul 2026 11:01:15 +0200 Subject: [PATCH 23/26] test(contract): drop redundant total_gas_fee doc comment The function name and body convey the meaning; the comment restated them. Addresses review r3601236195. --- crates/contract/tests/sandbox/utils/mpc_contract.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index a529256a0e..5006c2dacd 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -12,9 +12,6 @@ use near_workspaces::{ Account, AccountId, Contract, result::ExecutionFinalResult, types::NearToken, }; -/// The gas fee the caller actually pays for a call, summed over its transaction and -/// receipts. This is gas only: it excludes both the refunded unused prepaid gas and any -/// storage-staking deposit (storage is locked on the contract, not burnt). pub fn total_gas_fee(result: &ExecutionFinalResult) -> NearToken { result .outcomes() From a0e276b369aa0f17c68531dd0bc039578def35eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 17 Jul 2026 15:59:03 +0200 Subject: [PATCH 24/26] test(contract): drop stub tee-verifier, drive real verifier + in-process callback tests Remove the test-tee-verifier and test-tee-verifier-types crates. The stub existed only to feed structured verdicts into the async submit_participant_info flow; it duplicated the real verifier and added maintenance surface for little gain. Sandbox tee_verifier tests now drive the real tee-verifier (or no verifier): - rejection via a malformed quote (real dcap-qvl parse failure) - VerifierUnavailable via a never-deployed verifier account The Verified verdict cannot be exercised in the sandbox (real verify_quote checks live block time; the fixture collateral is expired and the sandbox clock is forward-only), so it is covered in-process instead: resolve_verification is called directly under a pinned clock with fixture-keyed report data, asserting the Verified arm stores and the Err arm returns the fail promise without storing. --- Cargo.lock | 20 -- Cargo.toml | 3 - crates/contract/Cargo.toml | 1 - crates/contract/src/lib.rs | 86 +++++++ crates/contract/src/tee/tee_state.rs | 46 +++- crates/contract/tests/sandbox/tee_verifier.rs | 227 +++++------------- .../tests/sandbox/utils/contract_build.rs | 9 +- crates/tee-context/Cargo.toml | 2 +- crates/test-tee-verifier-types/Cargo.toml | 21 -- crates/test-tee-verifier-types/src/lib.rs | 28 --- crates/test-tee-verifier/Cargo.toml | 30 --- crates/test-tee-verifier/src/lib.rs | 53 ---- 12 files changed, 199 insertions(+), 327 deletions(-) delete mode 100644 crates/test-tee-verifier-types/Cargo.toml delete mode 100644 crates/test-tee-verifier-types/src/lib.rs delete mode 100644 crates/test-tee-verifier/Cargo.toml delete mode 100644 crates/test-tee-verifier/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 6209d4ceb3..4b508c23c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5884,7 +5884,6 @@ dependencies = [ "sha2 0.10.9", "signature", "tee-verifier-interface", - "test-tee-verifier-types", "test-utils", "thiserror 2.0.18", "threshold-signatures", @@ -11377,25 +11376,6 @@ dependencies = [ "rand 0.8.6", ] -[[package]] -name = "test-tee-verifier" -version = "3.13.0" -dependencies = [ - "borsh", - "getrandom 0.2.17", - "near-sdk", - "tee-verifier-interface", - "test-tee-verifier-types", -] - -[[package]] -name = "test-tee-verifier-types" -version = "3.13.0" -dependencies = [ - "borsh", - "tee-verifier-interface", -] - [[package]] name = "test-utils" version = "3.13.0" diff --git a/Cargo.toml b/Cargo.toml index 81edf6dda3..11d47cf3df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,8 +37,6 @@ members = [ "crates/test-migration-contract", "crates/test-parallel-contract", "crates/test-port-allocator", - "crates/test-tee-verifier", - "crates/test-tee-verifier-types", "crates/test-utils", "crates/threshold-signatures", "crates/tls", @@ -78,7 +76,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-types = { path = "crates/test-tee-verifier-types" } test-utils = { path = "crates/test-utils" } threshold-signatures = { path = "crates/threshold-signatures" } diff --git a/crates/contract/Cargo.toml b/crates/contract/Cargo.toml index 86088004e6..8c2929c197 100644 --- a/crates/contract/Cargo.toml +++ b/crates/contract/Cargo.toml @@ -140,7 +140,6 @@ rand_core = { workspace = true } rstest = { workspace = true } sha2 = { workspace = true } signature = { workspace = true } -test-tee-verifier-types = { workspace = true } test-utils = { workspace = true } threshold-signatures = { workspace = true } tokio = { workspace = true } diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index b263bd9207..bc4d6d6ae0 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -2846,6 +2846,11 @@ mod tests { use rstest::rstest; use sha2::{Digest, Sha256}; + use crate::tee::verification_context::VerificationContext; + use test_utils::attestation::{ + VALID_ATTESTATION_TIMESTAMP, account_key, image_digest, launcher_image_hash, + mock_dstack_attestation, p2p_tls_key, verified_report, + }; use test_utils::contract_types::dummy_config; use threshold_signatures::confidential_key_derivation as ckd; use threshold_signatures::frost_core::Group as _; @@ -4542,6 +4547,87 @@ mod tests { .expect("Expected panic if predecessor != signer"); } + fn dstack_verification_setup() -> (MpcContract, VerificationContext) { + let (_, mut contract, _) = basic_setup(Curve::Edwards25519, &mut OsRng); + let contract_account_id = env::current_account_id(); + // Pin the clock to the fixture's validity window so the DCAP report verifies. + let context = VMContextBuilder::new() + .current_account_id(contract_account_id.clone()) + .predecessor_account_id(contract_account_id) + .attached_deposit(MINIMUM_ATTESTATION_STORAGE_DEPOSIT) + .block_timestamp(VALID_ATTESTATION_TIMESTAMP * 1_000_000_000) + .build(); + testing_env!(context); + + contract.tee_state = TeeState::default(); + contract + .tee_state + .whitelist_tee_proposal(image_digest(), Duration::MAX); + contract + .tee_state + .add_launcher_image(launcher_image_hash(), Duration::MAX); + for &measurements in default_measurements() { + contract + .tee_state + .add_measurement(ContractExpectedMeasurements::from(measurements)); + } + + let node_id = NodeId { + account_id: "alice.near".parse().unwrap(), + tls_public_key: Ed25519PublicKey(p2p_tls_key()), + account_public_key: Ed25519PublicKey(account_key()), + }; + let mpc_attestation::attestation::Attestation::Dstack(attestation) = + mock_dstack_attestation() + else { + panic!("fixture is a Dstack attestation"); + }; + ( + contract, + VerificationContext { + node_id, + attestation, + }, + ) + } + + #[test] + fn resolve_verification__should_store_on_verified_verdict() { + // Given + let (mut contract, context) = dstack_verification_setup(); + let node_id = context.node_id.clone(); + + // When + let result = contract + .resolve_verification(context, Ok(VerificationResult::Verified(verified_report()))); + + // Then + // assert_matches! requires Debug, which PromiseOrValue doesn't implement + assert!(matches!(result, PromiseOrValue::Value(()))); + assert_eq!(contract.tee_state.stored_attestations.len(), 1); + let stored = contract + .tee_state + .stored_attestations + .get(&node_id.tls_public_key) + .expect("attestation must be stored"); + assert_eq!(stored.node_id, node_id); + } + + #[test] + fn resolve_verification__should_return_fail_promise_and_store_nothing_on_verifier_unavailable() + { + // Given + let (mut contract, context) = dstack_verification_setup(); + + // When + let result = contract.resolve_verification(context, Err(PromiseError::Failed)); + + // Then + // assert_matches! requires Debug, which PromiseOrValue doesn't implement + assert!(matches!(result, PromiseOrValue::Promise(_))); + assert!(contract.tee_state.stored_attestations.is_empty()); + } + #[test] #[should_panic(expected = "Caller must be an attested participant")] fn test_attested_but_not_participant_panics() { diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 4df7c75145..d21d282918 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -581,13 +581,16 @@ mod tests { }; use crate::tee::test_utils::set_block_timestamp; use assert_matches::assert_matches; - use mpc_attestation::attestation::{Attestation, MockAttestation}; + use mpc_attestation::attestation::{Attestation, MockAttestation, default_measurements}; use mpc_primitives::hash::{LauncherImageHash, NodeImageHash}; use near_account_id::AccountId; use near_sdk::test_utils::VMContextBuilder; use near_sdk::testing_env; use std::time::Duration; - use test_utils::attestation::{mock_dstack_attestation, verified_report}; + use test_utils::attestation::{ + VALID_ATTESTATION_TIMESTAMP, account_key, image_digest, launcher_image_hash, + mock_dstack_attestation, p2p_tls_key, verified_report, + }; /// Helper to set up the testing environment with a specific signer fn set_signer(account_id: &AccountId, public_key: &near_sdk::PublicKey) { @@ -1434,6 +1437,45 @@ mod tests { assert!(tee_state.stored_attestations.is_empty()); } + #[test] + fn verify_and_store_dstack__should_store_when_all_post_dcap_checks_pass() { + // Given + set_block_timestamp(VALID_ATTESTATION_TIMESTAMP * 1_000_000_000); + let mut tee_state = TeeState::default(); + assert_eq!(tee_state.stored_attestations.len(), 0); + tee_state.whitelist_tee_proposal(image_digest(), Duration::MAX); + tee_state.add_launcher_image(launcher_image_hash(), Duration::MAX); + for &measurements in default_measurements() { + tee_state.add_measurement(ContractExpectedMeasurements::from(measurements)); + } + // NodeId keys must match what the fixture quote's report_data binds. + let node_id = NodeId { + account_id: "alice.near".parse().unwrap(), + tls_public_key: Ed25519PublicKey(p2p_tls_key()), + account_public_key: Ed25519PublicKey(account_key()), + }; + let Attestation::Dstack(dstack) = mock_dstack_attestation() else { + panic!("fixture is a Dstack attestation"); + }; + + // When + let result = tee_state.verify_and_store_dstack( + node_id.clone(), + &dstack, + &verified_report(), + Duration::MAX, + ); + + // Then + assert_matches!(result, Ok(ParticipantInsertion::NewlyInsertedParticipant)); + assert_eq!(tee_state.stored_attestations.len(), 1); + let stored = tee_state + .stored_attestations + .get(&node_id.tls_public_key) + .expect("attestation must be stored"); + assert_eq!(stored.node_id, node_id); + } + /// Stale CodeHashesVotes entries from removed participants must not count toward /// quorum after resharing. /// diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 155fb6107f..b719da784e 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -1,19 +1,18 @@ -//! Sandbox tests for the async [`submit_participant_info`] flow that offloads DCAP -//! verification to a separate tee-verifier contract. +//! Sandbox tests for the async [`submit_participant_info`] flow, driving the real +//! `tee-verifier` (or no verifier): +//! - Rejected: real verifier with a malformed quote. +//! - Unavailable: a verifier account that was never deployed. //! -//! Each test deploys the `test-tee-verifier` stub (returning a picked response -//! instead of running real `dcap-qvl`), votes it in as the trusted verifier, and -//! covers one branch of the promise chain: a Dstack submission spawns `verify_quote` -//! with [`MpcContract::resolve_verification`] chained as its callback, which settles -//! every outcome synchronously. When a submission fails, the top-level `submit` tx still -//! succeeds (it returned the chained promise); the error appears on one of the receipts. +//! The Verified verdict is covered in-process instead (`verify_and_store_dstack` under +//! a pinned clock): real `verify_quote` checks the quote against live block time, and the +//! sandbox clock can't be wound back to the fixture's validity window. #![allow(non_snake_case)] use crate::sandbox::{ common::SandboxTestSetup, utils::{ consts::{ALL_PROTOCOLS, SUBMIT_PARTICIPANT_INFO_DEPOSIT}, - contract_build::stub_tee_verifier_contract, + contract_build::tee_verifier_contract, mpc_contract::{ get_participant_attestation, submit_participant_info, submit_participant_info_with_deposit, total_gas_fee, vote_tee_verifier_change, @@ -23,64 +22,33 @@ use crate::sandbox::{ use mpc_contract::errors::TeeError; use near_mpc_contract_interface::types as dtos; use near_workspaces::{ - Account, Contract, Worker, network::Sandbox, result::ExecutionFinalResult, types::NearToken, + Account, AccountId, Contract, Worker, network::Sandbox, result::ExecutionFinalResult, + types::NearToken, }; -use test_tee_verifier_types::StubResponse; -use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key, verified_report}; +use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key}; /// Deposit attached to a Dstack submission: the flat storage fee, consumed on /// success and fully refunded on failure. const SUBMIT_DEPOSIT: NearToken = SUBMIT_PARTICIPANT_INFO_DEPOSIT; -/// 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, -) { - let stub = worker - .dev_deploy(stub_tee_verifier_contract()) - .await - .unwrap(); - stub.call("new") - .args_borsh(response) - .transact() - .await - .unwrap() - .into_result() - .unwrap(); - - // Unchecked against the stub; voters just need to agree on the same hash. +/// Votes `verifier` in as `mpc-contract`'s trusted verifier (all participants vote +/// so the change crosses threshold). +async fn trust_verifier(contract: &Contract, participants: &[Account], verifier: &AccountId) { let expected_code_hash = [7u8; 32]; for account in participants { - vote_tee_verifier_change(account, contract, stub.id(), expected_code_hash) + vote_tee_verifier_change(account, contract, verifier, expected_code_hash) .await .unwrap(); } } -async fn setup_with_stub( - response: StubResponse, - init_config: Option, -) -> (Worker, Contract, Account, NearToken) { - let mut builder = SandboxTestSetup::builder().with_protocols(ALL_PROTOCOLS); - if let Some(init_config) = init_config { - builder = builder.with_init_config(init_config); - } - let SandboxTestSetup { - worker, - mpc_signer_accounts, - contract, - .. - } = builder.build().await; - deploy_and_trust_stub(&worker, &contract, &mpc_signer_accounts, response).await; - - let submitter = mpc_signer_accounts[0].clone(); - let balance_before = submitter.view_account().await.unwrap().balance; - (worker, contract, submitter, balance_before) +async fn deploy_and_trust_verifier( + worker: &Worker, + contract: &Contract, + participants: &[Account], +) { + let verifier = worker.dev_deploy(tee_verifier_contract()).await.unwrap(); + trust_verifier(contract, participants, verifier.id()).await; } async fn submit_dstack(submitter: &Account, contract: &Contract) -> ExecutionFinalResult { @@ -182,11 +150,34 @@ async fn submit_participant_info__should_reject_dstack_when_verifier_not_configu #[tokio::test] async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_rejection() { // Given - let (_worker, contract, submitter, balance_before) = - setup_with_stub(StubResponse::Rejected("test rejection".to_string()), None).await; + let SandboxTestSetup { + worker, + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .build() + .await; + deploy_and_trust_verifier(&worker, &contract, &mpc_signer_accounts).await; + let submitter = mpc_signer_accounts[0].clone(); + let balance_before = submitter.view_account().await.unwrap().balance; + let mut attestation = mock_dto_dstack_attestation(); + let dtos::Attestation::Dstack(dstack) = &mut attestation else { + panic!("fixture must be a Dstack attestation"); + }; + dstack.quote = dtos::HexVec(vec![0u8; 16]); // When - let result = submit_dstack(&submitter, &contract).await; + let result = submit_participant_info_with_deposit( + &submitter, + &contract, + &attestation, + &p2p_tls_key().into(), + SUBMIT_DEPOSIT, + ) + .await + .unwrap(); // Then assert_submission_failed_cleanly( @@ -195,23 +186,32 @@ async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_re &submitter, balance_before, &TeeError::QuoteRejected { - reason: "dcap verification failed: test rejection".to_string(), + reason: String::new(), }, ) .await; } #[tokio::test] -async fn submit_participant_info__should_fail_and_store_nothing_on_verifier_crash() { - // Given: a verifier that panics, so the verify_quote promise fails. - let (_worker, contract, submitter, balance_before) = - setup_with_stub(StubResponse::Panic, None).await; +async fn submit_participant_info__should_fail_and_store_nothing_when_verifier_unreachable() { + // Given: a verifier account that was never deployed, so the verify_quote promise fails. + let SandboxTestSetup { + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .build() + .await; + let missing_verifier: AccountId = "nonexistent-verifier.near".parse().unwrap(); + trust_verifier(&contract, &mpc_signer_accounts, &missing_verifier).await; + let submitter = mpc_signer_accounts[0].clone(); + let balance_before = submitter.view_account().await.unwrap().balance; // When let result = submit_dstack(&submitter, &contract).await; - // Then: the callback sees a failed promise, resolves to VerifierUnavailable, - // refunds, and fails the submission in a separate receipt. + // Then assert_submission_failed_cleanly( &result, &contract, @@ -221,102 +221,3 @@ async fn submit_participant_info__should_fail_and_store_nothing_on_verifier_cras ) .await; } - -#[tokio::test] -async fn submit_participant_info__should_refund_and_store_nothing_when_post_dcap_checks_fail() { - // Given: a verifier that returns Verified, but an empty allowed-hash set, so the - // post-DCAP checks in resolve_verification reject the (genuinely verified) quote. - let (_worker, contract, submitter, balance_before) = - setup_with_stub(StubResponse::Verified(verified_report()), None).await; - - // When - let result = submit_dstack(&submitter, &contract).await; - - // Then: the callback's post-DCAP check rejects the (verified) quote. Asserted inline - // rather than via assert_submission_failed_cleanly since the error is not a TeeError. - let failures = result.failures(); - assert!( - !failures.is_empty(), - "expected the promise chain to fail on a receipt, got: {result:#?}" - ); - let rendered = format!("{failures:?}"); - assert!( - rendered.contains("the allowed mpc image hashes list is empty"), - "expected the empty-allowlist rejection, got: {rendered}" - ); - let stored = get_participant_attestation(&contract, &p2p_tls_key().into()) - .await - .unwrap(); - assert!(stored.is_none(), "nothing should be stored on failure"); - assert_deposit_refunded(&submitter, balance_before, &result).await; -} - -// TODO(#3787): un-ignore once the fixture allowlist setup lands; without it the -// post-DCAP check rejects the quote, so the store happy path can't run here yet. -#[ignore = "needs fixture allowlist setup to pass the post-DCAP checks; tracked in #3787"] -#[tokio::test] -async fn submit_participant_info__should_store_attestation_on_verified_quote() { - // Given - let (_worker, contract, submitter, balance_before) = - setup_with_stub(StubResponse::Verified(verified_report()), None).await; - - // When - let result = submit_dstack(&submitter, &contract).await; - - // Then: every receipt in the verify_quote -> resolve_verification chain - // succeeds, the attestation is stored, and the flat fee is consumed (not - // refunded), so net spend is the fee plus gas. - assert!( - result.failures().is_empty(), - "no receipt in the verify_quote -> resolve_verification promise chain may fail, got: {result:#?}" - ); - let stored = get_participant_attestation(&contract, &p2p_tls_key().into()) - .await - .unwrap(); - assert!(stored.is_some(), "a verified attestation must be stored"); - - // net spend is at least the fee (the rest is gas); a refund would drop it - // below the fee, proving the whole fee was consumed, not returned. - let balance_after = submitter.view_account().await.unwrap().balance; - let net_spent = balance_before.saturating_sub(balance_after); - assert!( - net_spent >= SUBMIT_DEPOSIT, - "flat fee must be consumed, not refunded: spent {net_spent}, fee {SUBMIT_DEPOSIT}" - ); -} - -// TODO(#3787): un-ignore once the fixture allowlist setup lands; without it the -// post-DCAP check fails fast and resolve_verification never reaches the heavy work -// needed to run it out of gas, so this re-tests the rejection path instead. -#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3787"] -#[tokio::test] -async fn submit_participant_info__should_fail_and_store_nothing_when_resolve_verification_runs_out_of_gas() - { - // Given: a Verified stub and a resolve gas budget too small for the post-DCAP - // work, so that callback OOGs and rolls back atomically. - let init_config = dtos::InitConfig { - resolve_verification_tera_gas: Some(1), - ..Default::default() - }; - let (_worker, contract, submitter, _balance_before) = - setup_with_stub(StubResponse::Verified(verified_report()), Some(init_config)).await; - - // When - let result = submit_dstack(&submitter, &contract).await; - - // Then: the OOG rolls the whole callback receipt back — the submission is reported - // as failed and nothing is stored. No deposit-refund assertion: on an OOG the callback - // dies before its `refund_to`, so the runtime returns the deposit to the predecessor - // (the contract), never to the submitter, and nothing returns it later. - assert!( - !result.failures().is_empty(), - "an OOG resolve_verification must fail the chain, got: {result:#?}" - ); - let stored = get_participant_attestation(&contract, &p2p_tls_key().into()) - .await - .unwrap(); - assert!( - stored.is_none(), - "nothing should be stored on an OOG resolve" - ); -} diff --git a/crates/contract/tests/sandbox/utils/contract_build.rs b/crates/contract/tests/sandbox/utils/contract_build.rs index 1f9361694e..f99c3f154b 100644 --- a/crates/contract/tests/sandbox/utils/contract_build.rs +++ b/crates/contract/tests/sandbox/utils/contract_build.rs @@ -4,7 +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 TEE_VERIFIER_MANIFEST: &str = "crates/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"; @@ -14,7 +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(); +static TEE_VERIFIER_CONTRACT: OnceLock> = OnceLock::new(); /// Returns the current contract WASM without benchmark utilities. /// Use this for most sandbox tests. @@ -57,7 +57,6 @@ pub fn parallel_contract() -> &'static [u8] { PARALLEL_CONTRACT.get_or_init(|| ContractBuilder::new(PARALLEL_CONTRACT_MANIFEST).build()) } -pub fn stub_tee_verifier_contract() -> &'static [u8] { - STUB_TEE_VERIFIER_CONTRACT - .get_or_init(|| ContractBuilder::new(STUB_TEE_VERIFIER_MANIFEST).build()) +pub fn tee_verifier_contract() -> &'static [u8] { + TEE_VERIFIER_CONTRACT.get_or_init(|| ContractBuilder::new(TEE_VERIFIER_MANIFEST).build()) } diff --git a/crates/tee-context/Cargo.toml b/crates/tee-context/Cargo.toml index 440f06ff31..63c9d9657e 100644 --- a/crates/tee-context/Cargo.toml +++ b/crates/tee-context/Cargo.toml @@ -7,9 +7,9 @@ license = { workspace = true } [dependencies] chain-gateway = { workspace = true } mpc-primitives = { workspace = true } -near-mpc-contract-interface = { workspace = true, features = ["call-args"] } near-account-id = { workspace = true } +near-mpc-contract-interface = { workspace = true, features = ["call-args"] } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/test-tee-verifier-types/Cargo.toml b/crates/test-tee-verifier-types/Cargo.toml deleted file mode 100644 index 28d34b9cdc..0000000000 --- a/crates/test-tee-verifier-types/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "test-tee-verifier-types" -version = { workspace = true } -license = { workspace = true } -edition = { workspace = true } - -# Wire types shared between the `test-tee-verifier` stub contract and the sandbox -# tests that drive it. Kept as a plain (non-`#[near]`) lib so a test crate can depend -# on it without pulling a contract's duplicate ABI symbol under `--all-features`. - -[features] -# Mirrors the stub's `abi` feature: derives the borsh schema on the wire types so -# the stub's ABI generation can include them. -abi = ["borsh/unstable__schema", "tee-verifier-interface/borsh-schema"] - -[dependencies] -borsh = { workspace = true } -tee-verifier-interface = { workspace = true } - -[lints] -workspace = true diff --git a/crates/test-tee-verifier-types/src/lib.rs b/crates/test-tee-verifier-types/src/lib.rs deleted file mode 100644 index c86372fc23..0000000000 --- a/crates/test-tee-verifier-types/src/lib.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Wire types shared between the `test-tee-verifier` stub contract and the -//! `mpc-contract` sandbox tests that drive it. -//! -//! Kept as a plain (non-`#[near]`) crate so a test crate can depend on it without -//! pulling the stub contract's duplicate ABI symbol under `cargo test --all-features`. - -use borsh::{BorshDeserialize, BorshSerialize}; - -/// What the stub verifier's verify-quote method should return, chosen by the -/// test at deploy time. -#[expect(clippy::large_enum_variant)] -#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] -#[cfg_attr( - all(feature = "abi", not(target_arch = "wasm32")), - derive(borsh::BorshSchema) -)] -pub enum StubResponse { - /// Return [`tee_verifier_interface::VerificationResult::Verified`] with this - /// exact report. Tests that want the post-DCAP checks to pass supply the - /// report obtained from the real fixture quote. - Verified(tee_verifier_interface::VerifiedReport), - /// Return [`tee_verifier_interface::VerificationResult::Rejected`] with this - /// reason. - Rejected(String), - /// Panic, simulating an unreachable or crashing verifier: the verify-quote - /// receipt fails, which mpc-contract reports as the verifier being unavailable. - Panic, -} diff --git a/crates/test-tee-verifier/Cargo.toml b/crates/test-tee-verifier/Cargo.toml deleted file mode 100644 index 9eb2ca223c..0000000000 --- a/crates/test-tee-verifier/Cargo.toml +++ /dev/null @@ -1,30 +0,0 @@ -[package] -name = "test-tee-verifier" -version = { workspace = true } -license = { workspace = true } -edition = { workspace = true } - -[package.metadata.cargo-shear] -ignored = ["borsh"] - -[lib] -crate-type = ["cdylib", "lib"] - -[features] -abi = [ - "borsh/unstable__schema", - "tee-verifier-interface/borsh-schema", - "test-tee-verifier-types/abi", -] - -[dependencies] -borsh = { workspace = true } -near-sdk = { workspace = true } -tee-verifier-interface = { workspace = true } -test-tee-verifier-types = { workspace = true } - -[target.'cfg(target_arch = "wasm32")'.dependencies] -getrandom = { workspace = true, features = ["custom"] } - -[lints] -workspace = true diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs deleted file mode 100644 index 502c6db571..0000000000 --- a/crates/test-tee-verifier/src/lib.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Test-only stub of the `tee-verifier` contract: [`TestTeeVerifier::verify_quote`] -//! returns a [`StubResponse`] chosen at init time instead of running DCAP quote -//! verification, letting tests drive any verifier outcome deterministically. - -use near_sdk::{env, near}; -use tee_verifier_interface::{Collateral, QuoteBytes, VerificationResult, VerifierError}; -use test_tee_verifier_types::StubResponse; - -#[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); - -#[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 } - } - - /// Ignores its inputs and returns the configured response, panicking on - /// [`StubResponse::Panic`]. - #[result_serializer(borsh)] - pub fn verify_quote( - &self, - #[serializer(borsh)] _quote: QuoteBytes, - #[serializer(borsh)] _collateral: Collateral, - ) -> VerificationResult { - match &self.response { - StubResponse::Verified(report) => VerificationResult::Verified(report.clone()), - StubResponse::Rejected(reason) => { - VerificationResult::Rejected(VerifierError::DcapVerification(reason.to_string())) - } - StubResponse::Panic => env::panic_str("stub verifier: simulated crash"), - } - } -} From fe68c93a9e16af723602dcdf69b65a6288c9ad48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 17 Jul 2026 17:44:17 +0200 Subject: [PATCH 25/26] test(contract): dedup attestation test setup via shared helpers Extract shared test helpers and reuse existing ones to cut duplication in the attestation test surface: - mock_dstack_attestation_inner() in test-utils returns the unwrapped DstackAttestation; mock_dstack_attestation and verified_report delegate to it, and the callers drop their destructure-or-panic boilerplate. - whitelist_dstack_measurements() in tee::test_utils seeds the image, launcher, and measurement allowlists a Dstack attestation is checked against. - The sandbox flat-fee tests reuse submit_participant_info[_with_deposit]; the overwrite test reuses try_submit_attestation_for_node; the tee_verifier tests share a setup() for the sandbox builder chain. --- crates/contract/src/lib.rs | 29 +++++---------- crates/contract/src/tee/tee_state.rs | 21 +++-------- crates/contract/src/tee/test_utils.rs | 19 +++++++++- .../tests/inprocess/attestation_submission.rs | 16 +------- crates/contract/tests/sandbox/tee.rs | 37 ++++++++----------- crates/contract/tests/sandbox/tee_verifier.rs | 22 +++++------ crates/test-utils/src/attestation.rs | 16 +++----- 7 files changed, 66 insertions(+), 94 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index bc4d6d6ae0..dc8ad7575d 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -2846,10 +2846,12 @@ mod tests { use rstest::rstest; use sha2::{Digest, Sha256}; - use crate::tee::verification_context::VerificationContext; + use crate::tee::{ + test_utils::whitelist_dstack_measurements, verification_context::VerificationContext, + }; use test_utils::attestation::{ VALID_ATTESTATION_TIMESTAMP, account_key, image_digest, launcher_image_hash, - mock_dstack_attestation, p2p_tls_key, verified_report, + mock_dstack_attestation_inner, p2p_tls_key, verified_report, }; use test_utils::contract_types::dummy_config; use threshold_signatures::confidential_key_derivation as ckd; @@ -4550,7 +4552,6 @@ mod tests { fn dstack_verification_setup() -> (MpcContract, VerificationContext) { let (_, mut contract, _) = basic_setup(Curve::Edwards25519, &mut OsRng); let contract_account_id = env::current_account_id(); - // Pin the clock to the fixture's validity window so the DCAP report verifies. let context = VMContextBuilder::new() .current_account_id(contract_account_id.clone()) .predecessor_account_id(contract_account_id) @@ -4560,28 +4561,18 @@ mod tests { testing_env!(context); contract.tee_state = TeeState::default(); - contract - .tee_state - .whitelist_tee_proposal(image_digest(), Duration::MAX); - contract - .tee_state - .add_launcher_image(launcher_image_hash(), Duration::MAX); - for &measurements in default_measurements() { - contract - .tee_state - .add_measurement(ContractExpectedMeasurements::from(measurements)); - } + whitelist_dstack_measurements( + &mut contract.tee_state, + image_digest(), + launcher_image_hash(), + ); let node_id = NodeId { account_id: "alice.near".parse().unwrap(), tls_public_key: Ed25519PublicKey(p2p_tls_key()), account_public_key: Ed25519PublicKey(account_key()), }; - let mpc_attestation::attestation::Attestation::Dstack(attestation) = - mock_dstack_attestation() - else { - panic!("fixture is a Dstack attestation"); - }; + let attestation = mock_dstack_attestation_inner(); ( contract, VerificationContext { diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index d21d282918..9d10d68f72 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -579,9 +579,9 @@ mod tests { authenticate_as, bogus_ed25519_near_public_key, bogus_ed25519_public_key, create_node_id, gen_participant, gen_participants, node_id_for, }; - use crate::tee::test_utils::set_block_timestamp; + use crate::tee::test_utils::{set_block_timestamp, whitelist_dstack_measurements}; use assert_matches::assert_matches; - use mpc_attestation::attestation::{Attestation, MockAttestation, default_measurements}; + use mpc_attestation::attestation::MockAttestation; use mpc_primitives::hash::{LauncherImageHash, NodeImageHash}; use near_account_id::AccountId; use near_sdk::test_utils::VMContextBuilder; @@ -589,7 +589,7 @@ mod tests { use std::time::Duration; use test_utils::attestation::{ VALID_ATTESTATION_TIMESTAMP, account_key, image_digest, launcher_image_hash, - mock_dstack_attestation, p2p_tls_key, verified_report, + mock_dstack_attestation_inner, p2p_tls_key, verified_report, }; /// Helper to set up the testing environment with a specific signer @@ -1420,9 +1420,7 @@ mod tests { fn verify_and_store_dstack__should_reject_and_store_nothing_when_post_dcap_checks_fail() { // Given let mut tee_state = TeeState::default(); - let Attestation::Dstack(dstack) = mock_dstack_attestation() else { - panic!("fixture is a Dstack attestation"); - }; + let dstack = mock_dstack_attestation_inner(); let node_id = node_id_for(&"alice.near".parse().unwrap()); // When @@ -1443,20 +1441,13 @@ mod tests { set_block_timestamp(VALID_ATTESTATION_TIMESTAMP * 1_000_000_000); let mut tee_state = TeeState::default(); assert_eq!(tee_state.stored_attestations.len(), 0); - tee_state.whitelist_tee_proposal(image_digest(), Duration::MAX); - tee_state.add_launcher_image(launcher_image_hash(), Duration::MAX); - for &measurements in default_measurements() { - tee_state.add_measurement(ContractExpectedMeasurements::from(measurements)); - } - // NodeId keys must match what the fixture quote's report_data binds. + whitelist_dstack_measurements(&mut tee_state, image_digest(), launcher_image_hash()); let node_id = NodeId { account_id: "alice.near".parse().unwrap(), tls_public_key: Ed25519PublicKey(p2p_tls_key()), account_public_key: Ed25519PublicKey(account_key()), }; - let Attestation::Dstack(dstack) = mock_dstack_attestation() else { - panic!("fixture is a Dstack attestation"); - }; + let dstack = mock_dstack_attestation_inner(); // When let result = tee_state.verify_and_store_dstack( diff --git a/crates/contract/src/tee/test_utils.rs b/crates/contract/src/tee/test_utils.rs index 5c671d702f..f0281434a6 100644 --- a/crates/contract/src/tee/test_utils.rs +++ b/crates/contract/src/tee/test_utils.rs @@ -4,10 +4,13 @@ //! attestation behavior, and general contract state management. use crate::primitives::test_utils::{gen_account_id, gen_seed}; +use crate::tee::{measurements::ContractExpectedMeasurements, tee_state::TeeState}; +use mpc_attestation::attestation::default_measurements; +use mpc_primitives::hash::{LauncherImageHash, NodeImageHash}; use near_account_id::AccountId; -use near_sdk::test_utils::VMContextBuilder; -use near_sdk::{BlockHeight, PublicKey, testing_env}; +use near_sdk::{BlockHeight, PublicKey, test_utils::VMContextBuilder, testing_env}; use rand::Rng; +use std::time::Duration; /// Test environment for managing VM context state. /// @@ -93,3 +96,15 @@ pub fn set_block_timestamp(timestamp_nanos: u64) { .build() ); } + +pub fn whitelist_dstack_measurements( + tee_state: &mut TeeState, + image: NodeImageHash, + launcher: LauncherImageHash, +) { + tee_state.whitelist_tee_proposal(image, Duration::MAX); + tee_state.add_launcher_image(launcher, Duration::MAX); + for &measurements in default_measurements() { + tee_state.add_measurement(ContractExpectedMeasurements::from(measurements)); + } +} diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index e1386aaa5f..3a790ab3db 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -249,26 +249,12 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { .expect("victim attestation should be stored"); // When: an unrelated account submits an attestation that targets the victim's TLS key. - // The attacker context attaches a deposit large enough to cover any storage charge, - // so the call can only fail due to the ownership check — not `InsufficientDeposit`. let attacker_node = create_node_id( &"attacker.near".parse().unwrap(), &victim_node.tls_public_key, ); - testing_env!( - VMContextBuilder::new() - .signer_account_id(attacker_node.account_id.clone()) - .predecessor_account_id(attacker_node.account_id.clone()) - .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) - .build() - ); let attack_result = setup - .contract - .submit_participant_info( - Attestation::Mock(MockAttestation::Valid), - attacker_node.tls_public_key.clone(), - ) - .map(|_| ()); + .try_submit_attestation_for_node(&attacker_node, Attestation::Mock(MockAttestation::Valid)); // 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.rs b/crates/contract/tests/sandbox/tee.rs index 030266f1de..f6ebf77aca 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -8,7 +8,7 @@ use crate::sandbox::{ mpc_contract::{ assert_running_return_participants, assert_running_return_threshold, get_participant_attestation, get_state, get_tee_accounts, submit_participant_info, - vote_add_launcher_hash, vote_for_hash, + submit_participant_info_with_deposit, vote_add_launcher_hash, vote_for_hash, }, resharing_utils::conclude_resharing, sign_utils::DomainResponseTest, @@ -1008,16 +1008,14 @@ async fn submit_participant_info__should_reject_new_attestation_below_flat_fee() let below_fee = SUBMIT_PARTICIPANT_INFO_DEPOSIT.saturating_sub(NearToken::from_yoctonear(1)); // When - let result = outsider - .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) - .args_json(( - Attestation::Mock(MockAttestation::Valid), - fresh_tls_key.clone(), - )) - .deposit(below_fee) - .max_gas() - .transact() - .await?; + let result = submit_participant_info_with_deposit( + &outsider, + &contract, + &Attestation::Mock(MockAttestation::Valid), + &fresh_tls_key, + below_fee, + ) + .await?; // Then assert!( @@ -1060,16 +1058,13 @@ async fn submit_participant_info__should_store_new_attestation_and_charge_the_fl let balance_before = outsider.view_account().await?.balance; // When - let result = outsider - .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) - .args_json(( - Attestation::Mock(MockAttestation::Valid), - fresh_tls_key.clone(), - )) - .deposit(SUBMIT_PARTICIPANT_INFO_DEPOSIT) - .max_gas() - .transact() - .await?; + let result = submit_participant_info( + &outsider, + &contract, + &Attestation::Mock(MockAttestation::Valid), + &fresh_tls_key, + ) + .await?; // Then assert!( diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index b719da784e..6409e92905 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -31,6 +31,13 @@ use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key}; /// success and fully refunded on failure. const SUBMIT_DEPOSIT: NearToken = SUBMIT_PARTICIPANT_INFO_DEPOSIT; +async fn setup() -> SandboxTestSetup { + SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .build() + .await +} + /// Votes `verifier` in as `mpc-contract`'s trusted verifier (all participants vote /// so the change crosses threshold). async fn trust_verifier(contract: &Contract, participants: &[Account], verifier: &AccountId) { @@ -112,10 +119,7 @@ async fn submit_participant_info__should_reject_dstack_when_verifier_not_configu mpc_signer_accounts, contract, .. - } = SandboxTestSetup::builder() - .with_protocols(ALL_PROTOCOLS) - .build() - .await; + } = setup().await; // When let result = submit_participant_info( @@ -155,10 +159,7 @@ async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_re mpc_signer_accounts, contract, .. - } = SandboxTestSetup::builder() - .with_protocols(ALL_PROTOCOLS) - .build() - .await; + } = setup().await; deploy_and_trust_verifier(&worker, &contract, &mpc_signer_accounts).await; let submitter = mpc_signer_accounts[0].clone(); let balance_before = submitter.view_account().await.unwrap().balance; @@ -199,10 +200,7 @@ async fn submit_participant_info__should_fail_and_store_nothing_when_verifier_un mpc_signer_accounts, contract, .. - } = SandboxTestSetup::builder() - .with_protocols(ALL_PROTOCOLS) - .build() - .await; + } = setup().await; let missing_verifier: AccountId = "nonexistent-verifier.near".parse().unwrap(); trust_verifier(&contract, &mpc_signer_accounts, &missing_verifier).await; let submitter = mpc_signer_accounts[0].clone(); diff --git a/crates/test-utils/src/attestation.rs b/crates/test-utils/src/attestation.rs index 105186314b..9898fde392 100644 --- a/crates/test-utils/src/attestation.rs +++ b/crates/test-utils/src/attestation.rs @@ -97,27 +97,23 @@ pub fn near_account_key() -> near_sdk::PublicKey { key_file.parse().expect("File contains a valid public key") } -pub fn mock_dstack_attestation() -> Attestation { +pub fn mock_dstack_attestation_inner() -> DstackAttestation { let quote = quote(); let collateral = mpc_attestation::collateral::collateral_from_str(TEST_COLLATERAL_STRING) .expect("collateral.json is valid collateral"); - let tcb_info: TcbInfo = serde_json::from_str(TEST_TCB_INFO_STRING).unwrap(); + DstackAttestation::new(quote, collateral, tcb_info) +} - Attestation::Dstack(DstackAttestation::new(quote, collateral, tcb_info)) +pub fn mock_dstack_attestation() -> Attestation { + Attestation::Dstack(mock_dstack_attestation_inner()) } /// The [`VerifiedReport`] the real `tee-verifier` would return for the fixture /// quote, produced by running the DCAP step at [`VALID_ATTESTATION_TIMESTAMP`] /// (when the fixture collateral is valid). pub fn verified_report() -> VerifiedReport { - let dstack = DstackAttestation::new( - quote(), - mpc_attestation::collateral::collateral_from_str(TEST_COLLATERAL_STRING) - .expect("collateral.json is valid collateral"), - serde_json::from_str(TEST_TCB_INFO_STRING).unwrap(), - ); - dstack + mock_dstack_attestation_inner() .verify_dcap_quote(VALID_ATTESTATION_TIMESTAMP) .expect("fixture quote verifies at VALID_ATTESTATION_TIMESTAMP") } From b21cc1ee2ee4e7484430729ec7ec1c216fd0b891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 17 Jul 2026 18:29:44 +0200 Subject: [PATCH 26/26] test(contract): assert the flat fee is consumed net of gas The flat-fee assertion compared the caller's whole balance delta against the fee, but that delta also includes burnt gas, so it passed even on a full refund whenever gas alone exceeded the fee. Subtract the burnt gas (total_gas_fee) and assert the non-gas spend equals the fee exactly; a refund would leave it at ~0. Keep the check only in should_store_new_attestation_and_charge_the_flat_fee, which submits from a fresh account whose balance reconciles to the yocto. Drop it from test_submit_participant_info_succeeds_with_mock_attestation, whose submitter has already transacted, so tokens_burnt (which folds in a gas-price deficit and refund penalty) leaves the non-gas spend a hair under the fee; that test only needs to assert the submission succeeds. --- crates/contract/tests/sandbox/tee.rs | 34 ++++++++-------------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index f6ebf77aca..b9df5231ca 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -8,7 +8,8 @@ use crate::sandbox::{ mpc_contract::{ assert_running_return_participants, assert_running_return_threshold, get_participant_attestation, get_state, get_tee_accounts, submit_participant_info, - submit_participant_info_with_deposit, vote_add_launcher_hash, vote_for_hash, + submit_participant_info_with_deposit, total_gas_fee, vote_add_launcher_hash, + vote_for_hash, }, resharing_utils::conclude_resharing, sign_utils::DomainResponseTest, @@ -267,26 +268,15 @@ async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result .with_protocols(ALL_PROTOCOLS) .build() .await; - let submitter = &mpc_signer_accounts[0]; - let balance_before = submitter.view_account().await?.balance; - - let result = submit_participant_info( - submitter, + let success = submit_participant_info( + &mpc_signer_accounts[0], &contract, &Attestation::Mock(MockAttestation::Valid), &p2p_tls_key().into(), ) - .await?; - assert!(result.is_success()); - - // The flat fee is consumed (not refunded), so net spend is at least the fee - // (the rest is gas). A refund would drop it below the fee. - let balance_after = submitter.view_account().await?.balance; - let net_spent = balance_before.saturating_sub(balance_after); - assert!( - net_spent >= SUBMIT_PARTICIPANT_INFO_DEPOSIT, - "flat fee must be consumed, not refunded: spent {net_spent}, fee {SUBMIT_PARTICIPANT_INFO_DEPOSIT}" - ); + .await? + .is_success(); + assert!(success); Ok(()) } @@ -1076,13 +1066,9 @@ async fn submit_participant_info__should_store_new_attestation_and_charge_the_fl stored.is_some(), "the attestation entry should be stored on-chain" ); - // The whole flat fee is consumed (no excess refund); `spent` also covers gas, - // so it must be at least the fee. let balance_after = outsider.view_account().await?.balance; - let spent = balance_before.saturating_sub(balance_after); - assert!( - spent >= SUBMIT_PARTICIPANT_INFO_DEPOSIT, - "caller must be charged the full flat fee ({SUBMIT_PARTICIPANT_INFO_DEPOSIT}), spent {spent}" - ); + let net_spent = balance_before.saturating_sub(balance_after); + let non_gas_spent = net_spent.saturating_sub(total_gas_fee(&result)); + assert_eq!(non_gas_spent, SUBMIT_PARTICIPANT_INFO_DEPOSIT); Ok(()) }