diff --git a/Cargo.lock b/Cargo.lock index 0b449b693f..dbc02c9305 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5845,6 +5845,7 @@ version = "3.13.0" dependencies = [ "anyhow", "assert_matches", + "attestation", "blstrs", "borsh", "cargo-near-build", @@ -5879,6 +5880,8 @@ dependencies = [ "serde_with", "sha2 0.10.9", "signature", + "tee-verifier-interface", + "test-tee-verifier-types", "test-utils", "thiserror 2.0.18", "threshold-signatures", @@ -11361,6 +11364,25 @@ 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" @@ -11374,6 +11396,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.10.9", + "tee-verifier-interface", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ede93a9e3c..31ee3427b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,8 @@ 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", @@ -76,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/attestation/src/attestation.rs b/crates/attestation/src/attestation.rs index 457cc14ab8..a595e183cd 100644 --- a/crates/attestation/src/attestation.rs +++ b/crates/attestation/src/attestation.rs @@ -35,6 +35,7 @@ pub(crate) const KEY_PROVIDER_EVENT: &str = "key-provider"; const RTMR3_INDEX: u32 = 3; #[derive(Clone, Constructor, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] +#[cfg_attr(feature = "borsh-schema", derive(borsh::BorshSchema))] pub struct DstackAttestation { pub quote: QuoteBytes, pub collateral: Collateral, diff --git a/crates/attestation/src/tcb_info.rs b/crates/attestation/src/tcb_info.rs index 06acde3dd3..4d8fb4c2fc 100644 --- a/crates/attestation/src/tcb_info.rs +++ b/crates/attestation/src/tcb_info.rs @@ -1,4 +1,6 @@ use alloc::string::String; +#[cfg(feature = "borsh-schema")] +use alloc::string::ToString; use alloc::vec::Vec; use borsh::{BorshDeserialize, BorshSerialize}; #[cfg(any(test, feature = "dstack-conversions"))] @@ -16,6 +18,7 @@ pub enum ParsingError { #[serde_as] #[derive(Debug, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[cfg_attr(feature = "borsh-schema", derive(borsh::BorshSchema))] pub struct TcbInfo { pub mrtd: HexBytes<48>, pub rtmr0: HexBytes<48>, @@ -32,6 +35,7 @@ pub struct TcbInfo { #[serde_as] #[derive(Debug, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[cfg_attr(feature = "borsh-schema", derive(borsh::BorshSchema))] pub struct EventLog { pub imr: u32, pub event_type: u32, @@ -60,6 +64,33 @@ pub struct EventLog { #[serde(transparent)] pub struct HexBytes(#[serde_as(as = "Hex")] [u8; N]); +/// Manual impl because the derive drops the const parameter from the +/// declaration, so `HexBytes<48>` and `HexBytes<32>` in one schema collide +/// ("Redefining type schema for HexBytes"). +#[cfg(feature = "borsh-schema")] +impl borsh::BorshSchema for HexBytes { + fn declaration() -> borsh::schema::Declaration { + alloc::format!("HexBytes<{N}>") + } + + fn add_definitions_recursively( + definitions: &mut alloc::collections::BTreeMap< + borsh::schema::Declaration, + borsh::schema::Definition, + >, + ) { + let fields = borsh::schema::Fields::UnnamedFields(alloc::vec![ + <[u8; N] as borsh::BorshSchema>::declaration() + ]); + borsh::schema::add_definition( + Self::declaration(), + borsh::schema::Definition::Struct { fields }, + definitions, + ); + <[u8; N] as borsh::BorshSchema>::add_definitions_recursively(definitions); + } +} + #[serde_as] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum HexBytesOrEmpty { @@ -198,6 +229,17 @@ mod tests { use rstest::rstest; use serde_json; + /// `TcbInfo` holds both `HexBytes<48>` and `HexBytes<32>`; schema + /// generation panics if their declarations collide. + #[cfg(feature = "borsh-schema")] + #[test] + fn TcbInfo__should_generate_borsh_schema() { + let container = borsh::schema_container_of::(); + + assert!(container.get_definition("HexBytes<48>").is_some()); + assert!(container.get_definition("HexBytes<32>").is_some()); + } + #[test] fn TcbInfo__should_deserialize_from_real_test_data() { // Given diff --git a/crates/chain-gateway/src/transaction_sender/signer.rs b/crates/chain-gateway/src/transaction_sender/signer.rs index c955f5d18f..a7741d4f6a 100644 --- a/crates/chain-gateway/src/transaction_sender/signer.rs +++ b/crates/chain-gateway/src/transaction_sender/signer.rs @@ -144,7 +144,7 @@ mod tests { method_name: "do_something".to_string(), args: b"test args".to_vec(), gas: TEST_GAS, - deposit: NearToken::from_yoctonear(0), + deposit: NearToken::from_near(0), } } diff --git a/crates/chain-gateway/src/transaction_sender/traits.rs b/crates/chain-gateway/src/transaction_sender/traits.rs index 99d52c1744..4b173903e7 100644 --- a/crates/chain-gateway/src/transaction_sender/traits.rs +++ b/crates/chain-gateway/src/transaction_sender/traits.rs @@ -86,7 +86,7 @@ mod tests { let mut args = vec![0u8; 16]; rng.fill(&mut args[..]); let gas = NearGas::from_gas(300); - let deposit = NearToken::from_yoctonear(0); + let deposit = NearToken::from_near(0); ( receiver_id, FunctionCallArgs { diff --git a/crates/contract/Cargo.toml b/crates/contract/Cargo.toml index 8a2f7c4eb2..5eee20af17 100644 --- a/crates/contract/Cargo.toml +++ b/crates/contract/Cargo.toml @@ -65,6 +65,7 @@ abi = [ "near-mpc-contract-interface/abi", "mpc-attestation/abi", "mpc-primitives/abi", + "tee-verifier-interface/borsh-schema", "schemars", ] # This is used when running `cargo clippy --all-features`, because otherwise `abi` feat will break compilation. @@ -74,6 +75,7 @@ __abi-generate = ["abi", "near-sdk/__abi-generate"] [dependencies] assert_matches = { workspace = true } +attestation = { workspace = true } blstrs = { workspace = true } borsh = { workspace = true } curve25519-dalek = { workspace = true } @@ -87,7 +89,7 @@ k256 = { workspace = true, features = [ "arithmetic", "expose-field", ] } -mpc-attestation = { workspace = true, features = ["local-verify"] } +mpc-attestation = { workspace = true } mpc-primitives = { workspace = true } near-account-id = { workspace = true, features = ["serde"] } near-mpc-bounded-collections = { workspace = true } @@ -102,6 +104,7 @@ rand = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true } +tee-verifier-interface = { workspace = true } thiserror = { workspace = true } threshold-signatures = { workspace = true, optional = true } @@ -136,6 +139,7 @@ rand_core = { workspace = true } rstest = { workspace = true } sha2 = { workspace = true } signature = { workspace = true } +test-tee-verifier-types = { workspace = true } test-utils = { workspace = true } threshold-signatures = { workspace = true } tokio = { workspace = true } diff --git a/crates/contract/src/config.rs b/crates/contract/src/config.rs index 9acf2a28ca..ef952f116e 100644 --- a/crates/contract/src/config.rs +++ b/crates/contract/src/config.rs @@ -21,6 +21,8 @@ const DEFAULT_RETURN_SIGNATURE_AND_CLEAN_STATE_ON_SUCCESS_CALL_TERA_GAS: u64 = 7 const DEFAULT_RETURN_CK_AND_CLEAN_STATE_ON_SUCCESS_CALL_TERA_GAS: u64 = 7; /// Prepaid gas for a `fail_on_timeout` call const DEFAULT_FAIL_ON_TIMEOUT_TERA_GAS: u64 = 2; +/// Prepaid gas for a `fail_attestation_submission` call +const DEFAULT_FAIL_ATTESTATION_SUBMISSION_TERA_GAS: u64 = 2; /// Prepaid gas for a `clean_tee_status` call const DEFAULT_CLEAN_TEE_STATUS_TERA_GAS: u64 = 10; /// Prepaid gas for the reshare-time `clean_invalid_attestations` promise. @@ -34,6 +36,11 @@ const DEFAULT_REMOVE_NON_PARTICIPANT_UPDATE_VOTES_TERA_GAS: u64 = 5; const DEFAULT_CLEAN_FOREIGN_CHAIN_DATA_TERA_GAS: u64 = 5; /// Prepaid gas for a `remove_non_participant_tee_verifier_votes` call const DEFAULT_REMOVE_NON_PARTICIPANT_TEE_VERIFIER_VOTES_TERA_GAS: u64 = 5; +/// Gas attached to the cross-contract `verify_quote` call on the TEE verifier. +const DEFAULT_VERIFIER_TERA_GAS: u64 = 100; +/// Prepaid gas for the `resolve_verification` callback. Carries the bulk of the +/// post-DCAP work (allowlist match, RTMR3 replay, app-compose validation, store). +const DEFAULT_RESOLVE_VERIFICATION_TERA_GAS: u64 = 60; /// Config for V2 of the contract. #[near(serializers=[borsh, json])] @@ -56,6 +63,8 @@ pub(crate) struct Config { pub(crate) return_ck_and_clean_state_on_success_call_tera_gas: u64, /// Prepaid gas for a `fail_on_timeout` call. pub(crate) fail_on_timeout_tera_gas: u64, + /// Prepaid gas for a `fail_attestation_submission` call. + pub(crate) fail_attestation_submission_tera_gas: u64, /// Prepaid gas for a `clean_tee_status` call. pub(crate) clean_tee_status_tera_gas: u64, /// Prepaid gas for the reshare-time `clean_invalid_attestations` promise. @@ -68,6 +77,10 @@ pub(crate) struct Config { pub(crate) clean_foreign_chain_data_tera_gas: u64, /// Prepaid gas for a `remove_non_participant_tee_verifier_votes` call. pub(crate) remove_non_participant_tee_verifier_votes_tera_gas: u64, + /// Gas attached to the cross-contract `verify_quote` call on the verifier. + pub(crate) verifier_tera_gas: u64, + /// Prepaid gas for the `resolve_verification` callback. + pub(crate) resolve_verification_tera_gas: u64, } impl Default for Config { @@ -85,6 +98,7 @@ impl Default for Config { return_ck_and_clean_state_on_success_call_tera_gas: DEFAULT_RETURN_CK_AND_CLEAN_STATE_ON_SUCCESS_CALL_TERA_GAS, fail_on_timeout_tera_gas: DEFAULT_FAIL_ON_TIMEOUT_TERA_GAS, + fail_attestation_submission_tera_gas: DEFAULT_FAIL_ATTESTATION_SUBMISSION_TERA_GAS, clean_tee_status_tera_gas: DEFAULT_CLEAN_TEE_STATUS_TERA_GAS, clean_invalid_attestations_tera_gas: DEFAULT_CLEAN_INVALID_ATTESTATIONS_TERA_GAS, cleanup_orphaned_node_migrations_tera_gas: @@ -94,6 +108,8 @@ impl Default for Config { clean_foreign_chain_data_tera_gas: DEFAULT_CLEAN_FOREIGN_CHAIN_DATA_TERA_GAS, remove_non_participant_tee_verifier_votes_tera_gas: DEFAULT_REMOVE_NON_PARTICIPANT_TEE_VERIFIER_VOTES_TERA_GAS, + verifier_tera_gas: DEFAULT_VERIFIER_TERA_GAS, + resolve_verification_tera_gas: DEFAULT_RESOLVE_VERIFICATION_TERA_GAS, } } } diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index 104870e261..38d277ce00 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -472,6 +472,9 @@ impl From for Config { if let Some(v) = config_ext.fail_on_timeout_tera_gas { config.fail_on_timeout_tera_gas = v; } + if let Some(v) = config_ext.fail_attestation_submission_tera_gas { + config.fail_attestation_submission_tera_gas = v; + } if let Some(v) = config_ext.clean_tee_status_tera_gas { config.clean_tee_status_tera_gas = v; } @@ -490,6 +493,12 @@ impl From for Config { if let Some(v) = config_ext.remove_non_participant_tee_verifier_votes_tera_gas { config.remove_non_participant_tee_verifier_votes_tera_gas = v; } + if let Some(v) = config_ext.verifier_tera_gas { + config.verifier_tera_gas = v; + } + if let Some(v) = config_ext.resolve_verification_tera_gas { + config.resolve_verification_tera_gas = v; + } config } @@ -510,6 +519,7 @@ impl From<&Config> for near_mpc_contract_interface::types::Config { return_ck_and_clean_state_on_success_call_tera_gas: value .return_ck_and_clean_state_on_success_call_tera_gas, fail_on_timeout_tera_gas: value.fail_on_timeout_tera_gas, + fail_attestation_submission_tera_gas: value.fail_attestation_submission_tera_gas, clean_tee_status_tera_gas: value.clean_tee_status_tera_gas, clean_invalid_attestations_tera_gas: value.clean_invalid_attestations_tera_gas, cleanup_orphaned_node_migrations_tera_gas: value @@ -519,6 +529,8 @@ impl From<&Config> for near_mpc_contract_interface::types::Config { clean_foreign_chain_data_tera_gas: value.clean_foreign_chain_data_tera_gas, remove_non_participant_tee_verifier_votes_tera_gas: value .remove_non_participant_tee_verifier_votes_tera_gas, + verifier_tera_gas: value.verifier_tera_gas, + resolve_verification_tera_gas: value.resolve_verification_tera_gas, } } } @@ -538,6 +550,7 @@ impl From for Config { return_ck_and_clean_state_on_success_call_tera_gas: value .return_ck_and_clean_state_on_success_call_tera_gas, fail_on_timeout_tera_gas: value.fail_on_timeout_tera_gas, + fail_attestation_submission_tera_gas: value.fail_attestation_submission_tera_gas, clean_tee_status_tera_gas: value.clean_tee_status_tera_gas, clean_invalid_attestations_tera_gas: value.clean_invalid_attestations_tera_gas, cleanup_orphaned_node_migrations_tera_gas: value @@ -547,6 +560,8 @@ impl From for Config { clean_foreign_chain_data_tera_gas: value.clean_foreign_chain_data_tera_gas, remove_non_participant_tee_verifier_votes_tera_gas: value .remove_non_participant_tee_verifier_votes_tera_gas, + verifier_tera_gas: value.verifier_tera_gas, + resolve_verification_tera_gas: value.resolve_verification_tera_gas, } } } diff --git a/crates/contract/src/errors.rs b/crates/contract/src/errors.rs index 7c67f0c100..a6fdc25201 100644 --- a/crates/contract/src/errors.rs +++ b/crates/contract/src/errors.rs @@ -1,6 +1,7 @@ use crate::crypto_shared::kdf::TweakNotOnCurve; use crate::primitives::domain::MIN_RECONSTRUCTION_THRESHOLD; use crate::primitives::key_state::{EpochId, Keyset}; +use crate::tee::tee_state::AttestationSubmissionError; use near_account_id::AccountId; use near_mpc_contract_interface::types as dtos; use near_mpc_contract_interface::types::{DomainId, DomainPurpose, ForeignChain, Protocol}; @@ -28,6 +29,14 @@ pub enum TeeError { "Due to previously failed TEE validation, the network is not accepting new requests at this point in time. Try again later." )] TeeValidationFailed, + #[error( + "No TEE verifier is configured yet. Participants must vote one in via vote_tee_verifier_change before Dstack attestations can be submitted." + )] + VerifierNotConfigured, + #[error("The TEE verifier rejected the quote: {reason}")] + QuoteRejected { reason: String }, + #[error("The TEE verifier did not answer the verify_quote call.")] + VerifierUnavailable, } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] @@ -318,6 +327,9 @@ pub enum Error { // Tee errors #[error(transparent)] NodeMigrationError(#[from] NodeMigrationError), + // Tee attestation submission errors + #[error(transparent)] + AttestationSubmission(#[from] AttestationSubmissionError), } impl near_sdk::FunctionError for Error { diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 7e10d9859b..8e64af06a3 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -49,6 +49,7 @@ use crate::{ }, storage_keys::StorageKey, tee::tee_state::{TeeQuoteStatus, TeeState}, + tee::verification_context::VerificationContext, tee::verifier_votes::{TeeVerifierVotes, VerifierChangeProposal}, update::{ProposeUpdateArgs, ProposedUpdates, Update, UpdateId}, }; @@ -71,6 +72,7 @@ use near_mpc_contract_interface::types::{ use near_mpc_contract_interface::{method_names, types::CKDRequestArgs}; use dtos::{Curve, DomainConfig, DomainId, DomainPurpose, Protocol}; +use mpc_attestation::attestation::{Attestation, DstackAttestation}; use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash, TeeVerifierCodeHash}; use near_sdk::{ AccountId, CryptoHash, Gas, GasWeight, NearToken, Promise, PromiseError, PromiseOrValue, env, @@ -86,11 +88,12 @@ use primitives::{ }; use tee::measurements::{ContractExpectedMeasurements, MeasurementVoteAction, MeasurementVotes}; use tee::proposal::{CodeHashesVotes, LauncherHashVotes}; +use tee_verifier_interface::{VerificationResult, VerifiedReport}; use state::{ProtocolContractState, running::RunningContractState}; use tee::{ proposal::{LauncherVoteAction, NodeImageHash}, - tee_state::{AttestationSubmissionError, NodeId, ParticipantInsertion, TeeValidationResult}, + tee_state::{NodeId, TeeValidationResult}, }; /// Register used to receive data id from `promise_await_data`. @@ -131,12 +134,15 @@ fn require_deposit(minimum_deposit: NearToken, predecessor: &AccountId) { .to_string(), ); } - Some(diff) => { - if diff > NearToken::from_yoctonear(0) { - log!("refund excess deposit {diff} to {predecessor}"); - Promise::new(predecessor.clone()).transfer(diff).detach(); - } - } + Some(diff) => refund_to(predecessor, diff), + } +} + +/// Transfers `amount` to `account_id` via a detached promise; no-op when zero. +fn refund_to(account_id: &AccountId, amount: NearToken) { + if amount > NearToken::from_near(0) { + log!("refund {amount} to {account_id}"); + Promise::new(account_id.clone()).transfer(amount).detach(); } } @@ -165,7 +171,9 @@ pub struct MpcContract { metrics: Metrics, foreign_chains: Lazy, /// The verifier contract account trusted for DCAP verification, or [`None`] - /// until participants vote one in. Not yet used to dispatch verification. + /// until participants vote one in. An [`Attestation::Dstack`] submission + /// offloads quote verification to this account; while it is [`None`], such + /// submissions are rejected with [`TeeError::VerifierNotConfigured`]. // TODO(#3639): once participants have voted a verifier in, make this // non-optional via a migration that requires it be set. tee_verifier_account_id: Option, @@ -753,15 +761,21 @@ impl MpcContract { ) } - /// (Prospective) Participants can submit their tee participant information through this - /// endpoint. + /// Submit a TEE attestation for a current or prospective participant. + /// + /// - [`Attestation::Mock`] is verified synchronously. + /// - [`Attestation::Dstack`] is verified asynchronously via a cross-contract + /// `verify_quote` call, with [`Self::resolve_verification`] chained as its + /// callback to run the post-DCAP checks and settle the deposit. + /// + /// The attached deposit pays for storage on success, and is refunded on failure. #[payable] #[handle_result] pub fn submit_participant_info( &mut self, proposed_participant_attestation: dtos::Attestation, tls_public_key: dtos::Ed25519PublicKey, - ) -> Result<(), Error> { + ) -> Result, Error> { let proposed_participant_attestation = proposed_participant_attestation.try_into_contract_type()?; @@ -775,13 +789,6 @@ impl MpcContract { account_key ); - // Save the initial storage usage to know how much to charge the proposer for the storage - // used - let initial_storage = env::storage_usage(); - - let tee_upgrade_deadline_duration = - Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); - // The node always signs submissions with an Ed25519 key // (`near_signer_key`), so the signer key here is Ed25519 in practice. // Reject non-Ed25519 signer keys rather than silently storing a value @@ -792,62 +799,87 @@ impl MpcContract { } })?; - // Add the participant information to the contract state - let attestation_insertion_result = self - .tee_state - .add_participant( - NodeId { - account_id: account_id.clone(), - tls_public_key, - account_public_key, - }, - proposed_participant_attestation, - tee_upgrade_deadline_duration, - ) - .map_err(|err| { - let reason = match &err { - AttestationSubmissionError::InvalidAttestation(_) => { - format!("TeeQuoteStatus is invalid: {err}") - } - AttestationSubmissionError::TlsKeyOwnedByOtherAccount => err.to_string(), - }; - InvalidParameters::InvalidTeeRemoteAttestation { reason } - })?; + let node_id = NodeId { + account_id: account_id.clone(), + tls_public_key, + account_public_key, + }; - let caller_is_not_participant = self.voter_account().is_err(); - let is_new_attestation = matches!( - attestation_insertion_result, - ParticipantInsertion::NewlyInsertedParticipant - ); + match proposed_participant_attestation { + Attestation::Mock(mock) => { + let tee_upgrade_deadline_duration = + Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); + let initial_storage = env::storage_usage(); + self.tee_state.verify_and_store_mock( + node_id, + mock, + tee_upgrade_deadline_duration, + )?; + self.charge_attestation_storage(&account_id, initial_storage)?; + Ok(PromiseOrValue::Value(())) + } + Attestation::Dstack(attestation) => Ok(PromiseOrValue::Promise( + self.submit_dstack_attestation(node_id, attestation)?, + )), + } + } - let attestation_storage_must_be_paid_by_caller = - is_new_attestation || caller_is_not_participant; + /// Async [`Attestation::Dstack`] submission: spawns a promise calling + /// `verify_quote` on the trusted verifier contract, with + /// [`Self::resolve_verification`] chained as its callback. + fn submit_dstack_attestation( + &mut self, + node_id: NodeId, + attestation: DstackAttestation, + ) -> Result { + let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { + return Err(TeeError::VerifierNotConfigured.into()); + }; - if attestation_storage_must_be_paid_by_caller { - // `saturating_sub`: if a re-submission shrinks the entry, charge nothing - // rather than underflow. Intentional asymmetry: we do not refund freed bytes - // either — the caller already paid for the larger entry, and we'd rather - // accept that asymmetry than open a refund path for payload-shrinking games. - let storage_used = env::storage_usage().saturating_sub(initial_storage); - let cost = env::storage_byte_cost().saturating_mul(storage_used as u128); - let attached = env::attached_deposit(); + Ok(Promise::new(verifier_account_id) + .function_call( + method_names::VERIFY_QUOTE.to_string(), + borsh::to_vec(&(&attestation.quote, &attestation.collateral)) + .expect("borsh serialization of verify_quote args must succeed"), + NearToken::from_near(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, + }), + )) + } - if attached < cost { - return Err(InvalidParameters::InsufficientDeposit { - attached: attached.as_yoctonear(), - required: cost.as_yoctonear(), - } - .into()); - } + fn charge_attestation_storage( + &self, + account_id: &AccountId, + initial_storage: u64, + ) -> Result<(), Error> { + // `saturating_sub`: if a re-submission shrinks the entry, charge nothing + // rather than underflow. Intentional asymmetry: we do not refund freed + // bytes either, since the caller already paid for the larger entry. + let attached = env::attached_deposit(); + // Relies on the attestation store having flushed its insert already; it + // defers writes to flush-on-Drop, so an unflushed insert reads as a zero delta + let storage_used = env::storage_usage().saturating_sub(initial_storage); + let cost = env::storage_byte_cost().saturating_mul(u128::from(storage_used)); - // Refund the difference if the proposer attached more than required - if let Some(diff) = attached.checked_sub(cost) - && diff > NearToken::from_yoctonear(0) - { - Promise::new(account_id).transfer(diff).detach(); + if attached < cost { + return Err(InvalidParameters::InsufficientDeposit { + attached: attached.as_yoctonear(), + required: cost.as_yoctonear(), } + .into()); } + if let Some(diff) = attached.checked_sub(cost) { + refund_to(account_id, diff); + } Ok(()) } @@ -1178,7 +1210,7 @@ impl MpcContract { .function_call( method_names::REMOVE_NON_PARTICIPANT_UPDATE_VOTES.to_string(), vec![], - NearToken::from_yoctonear(0), + NearToken::from_near(0), Gas::from_tgas(self.config.remove_non_participant_update_votes_tera_gas), ) .detach(); @@ -1187,7 +1219,7 @@ impl MpcContract { .function_call( method_names::CLEAN_TEE_STATUS.to_string(), vec![], - NearToken::from_yoctonear(0), + NearToken::from_near(0), Gas::from_tgas(self.config.clean_tee_status_tera_gas), ) .detach(); @@ -1199,7 +1231,7 @@ impl MpcContract { "max_scan": RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN })) .unwrap(), - NearToken::from_yoctonear(0), + NearToken::from_near(0), Gas::from_tgas(self.config.clean_invalid_attestations_tera_gas), ) .detach(); @@ -1208,7 +1240,7 @@ impl MpcContract { .function_call( method_names::CLEANUP_ORPHANED_NODE_MIGRATIONS.to_string(), vec![], - NearToken::from_yoctonear(0), + NearToken::from_near(0), Gas::from_tgas(self.config.cleanup_orphaned_node_migrations_tera_gas), ) .detach(); @@ -1217,7 +1249,7 @@ impl MpcContract { .function_call( method_names::CLEAN_FOREIGN_CHAIN_DATA.to_string(), vec![], - NearToken::from_yoctonear(0), + NearToken::from_near(0), Gas::from_tgas(self.config.clean_foreign_chain_data_tera_gas), ) .detach(); @@ -1226,7 +1258,7 @@ impl MpcContract { .function_call( method_names::REMOVE_NON_PARTICIPANT_TEE_VERIFIER_VOTES.to_string(), vec![], - NearToken::from_yoctonear(0), + NearToken::from_near(0), Gas::from_tgas( self.config .remove_non_participant_tee_verifier_votes_tera_gas, @@ -1324,10 +1356,8 @@ impl MpcContract { ); // Refund the difference if the proposer attached more than required. - if let Some(diff) = attached.checked_sub(required) - && diff > NearToken::from_yoctonear(0) - { - Promise::new(proposer).transfer(diff).detach(); + if let Some(diff) = attached.checked_sub(required) { + refund_to(&proposer, diff); } Ok(id) @@ -2270,6 +2300,99 @@ impl MpcContract { } } + /// Verify-quote callback: on a verifier verdict it runs the post-DCAP + /// checks, stores the attestation, and settles the deposit. + #[private] + #[payable] + pub fn resolve_verification( + &mut self, + #[serializer(borsh)] context: VerificationContext, + #[serializer(borsh)] + #[callback_result] + result: Result, + ) -> PromiseOrValue<()> { + let account_id = context.node_id.account_id.clone(); + log!("resolve_verification: account_id={account_id}"); + + let attestation_result = match result { + Ok(VerificationResult::Verified(report)) => { + self.verify_post_dcap_and_store(&context, &report) + } + Ok(VerificationResult::Rejected(reason)) => { + log!("verifier rejected quote for {account_id}: {reason}"); + Err(TeeError::QuoteRejected { + reason: reason.to_string(), + } + .into()) + } + // No verdict (verifier unreachable, panicked, or out of gas) + Err(promise_err) => { + log!("verifier did not answer for {account_id}: {promise_err:?}"); + Err(TeeError::VerifierUnavailable.into()) + } + }; + + match attestation_result { + Ok(()) => PromiseOrValue::Value(()), + Err(err) => { + refund_to(&account_id, env::attached_deposit()); + // Fail the submitter's transaction from a separate receipt so + // the refund above commits (a panic here would roll it back) + let promise = Promise::new(env::current_account_id()).function_call( + method_names::FAIL_ATTESTATION_SUBMISSION.to_string(), + borsh::to_vec(&err.to_string()) + .expect("borsh serialization of reason must succeed"), + NearToken::from_near(0), + Gas::from_tgas(self.config.fail_attestation_submission_tera_gas), + ); + PromiseOrValue::Promise(promise.as_return()) + } + } + } + + /// Runs the post-DCAP checks and stores the attestation for a + /// [`VerificationResult::Verified`] response. On failure it reverts the + /// store explicitly, since the callback receipt commits regardless + /// (unlike the synchronous path). + fn verify_post_dcap_and_store( + &mut self, + context: &VerificationContext, + report: &VerifiedReport, + ) -> Result<(), Error> { + let account_id = &context.node_id.account_id; + let tee_upgrade_deadline_duration = + Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); + + let initial_storage = env::storage_usage(); + let insertion = match self.tee_state.verify_and_store_dstack( + context.node_id.clone(), + &context.attestation, + report, + tee_upgrade_deadline_duration, + ) { + Ok(insertion) => insertion, + Err(err) => { + log!("post-DCAP check failed for {account_id}: {err}"); + return Err(err.into()); + } + }; + + // The charge is the measured storage delta, so it is only known after + // the store; an insufficient deposit reverts the store below. + match self.charge_attestation_storage(account_id, initial_storage) { + Ok(()) => Ok(()), + Err(err) => { + // This receipt commits even though we return an error, so the + // store above is NOT rolled back automatically (unlike the + // synchronous path). Undo it explicitly, or the caller would + // get storage for free plus a full refund. + self.tee_state + .revert_dstack_store(&context.node_id.tls_public_key, insertion); + Err(err) + } + } + } + /// Yield-resume callback for a single queued CKD request. /// /// On success, returns the confidential key to the original caller. On timeout, @@ -2336,6 +2459,12 @@ impl MpcContract { } } + #[private] + pub fn fail_attestation_submission(#[serializer(borsh)] reason: String) { + log!("fail_attestation_submission: {reason}"); + env::panic_str(&reason); + } + #[private] pub fn fail_on_timeout() { // To stay consistent with the old version of the timeout error @@ -2692,7 +2821,7 @@ mod tests { KeyProviderEventDigest, MrtdHash, Rtmr0Hash, Rtmr1Hash, Rtmr2Hash, }; use crate::tee::proposal::{LauncherVoteAction, get_docker_compose_hash}; - use crate::tee::tee_state::{NodeAttestation, NodeId}; + use crate::tee::tee_state::{NodeAttestation, NodeId, ParticipantInsertion}; use assert_matches::assert_matches; use dtos::{Attestation, Ed25519PublicKey, ForeignTxSignPayload, MockAttestation}; use dtos::{Curve, DomainConfig, DomainId, Payload, Protocol, ReconstructionThreshold, Tweak}; @@ -2700,9 +2829,8 @@ mod tests { use elliptic_curve::Group; use k256::{self, Secp256k1, ecdsa::SigningKey, elliptic_curve}; use mpc_attestation::attestation::{ - Attestation as MpcAttestation, MockAttestation as MpcMockAttestation, VerifiedAttestation, + MockAttestation as MpcMockAttestation, VerifiedAttestation, }; - use mpc_primitives::hash::DockerImageHash; use near_mpc_bounded_collections::{NonEmptyBTreeMap, NonEmptyBTreeSet}; use near_mpc_contract_interface::types::BackupServiceInfo; use near_mpc_contract_interface::types::CKDAppPublicKey; @@ -2720,10 +2848,6 @@ mod tests { use rstest::rstest; use sha2::{Digest, Sha256}; - use test_utils::attestation::{ - VALID_ATTESTATION_TIMESTAMP, image_digest, launcher_image_hash, - mock_dto_dstack_attestation, near_account_key, p2p_tls_key, - }; use test_utils::contract_types::dummy_config; use threshold_signatures::confidential_key_derivation as ckd; use threshold_signatures::frost_core::Group as _; @@ -3549,7 +3673,7 @@ mod tests { #[should_panic(expected = "Attached deposit is lower than required")] fn check_request_preconditions__panics_when_attached_deposit_is_insufficient() { let (_, contract, _) = basic_setup(Curve::Secp256k1, &mut OsRng); - override_context_for_preconditions(NearToken::from_yoctonear(0), Gas::from_tgas(300)); + override_context_for_preconditions(NearToken::from_near(0), Gas::from_tgas(300)); contract.check_request_preconditions( DomainId::default(), DomainPurpose::Sign, @@ -3996,7 +4120,9 @@ mod tests { .build(); testing_env!(participant_context); - contract.submit_participant_info(Attestation::Mock(attestation), dto_public_key) + contract + .submit_participant_info(Attestation::Mock(attestation), dto_public_key) + .map(|_| ()) } fn submit_valid_attestations( @@ -4025,7 +4151,7 @@ mod tests { let voting_context = VMContextBuilder::new() .signer_account_id(first_participant_id.clone()) .predecessor_account_id(first_participant_id.clone()) - .attached_deposit(NearToken::from_yoctonear(0)) + .attached_deposit(NearToken::from_near(0)) .build(); testing_env!(voting_context); @@ -4108,8 +4234,8 @@ mod tests { if let Err(error) = result { let error_string = error.to_string(); assert!( - error_string.contains("TeeQuoteStatus is invalid"), - "Error should mention invalid TEE status, got: {}", + error_string.contains("failed verification"), + "Error should mention attestation verification failure, got: {}", error_string ); } @@ -4182,7 +4308,7 @@ mod tests { VMContextBuilder::new() .signer_account_id(signer.clone()) .predecessor_account_id(signer.clone()) - .attached_deposit(NearToken::from_yoctonear(0)) + .attached_deposit(NearToken::from_near(0)) .build() ); contract.vote_new_parameters(EpochId::new(1), proposal.into_dto_type()) @@ -4323,7 +4449,7 @@ mod tests { let ctx = VMContextBuilder::new() .signer_account_id(first_participant_id) .predecessor_account_id("forwarder.near".parse().unwrap()) - .attached_deposit(NearToken::from_yoctonear(0)) + .attached_deposit(NearToken::from_near(0)) .build(); testing_env!(ctx); @@ -4413,7 +4539,7 @@ mod tests { .build(); testing_env!(ctx); - contract + let _ = contract .submit_participant_info(valid_attestation, participant_info.tls_public_key.clone()) .expect("Expected panic if predecessor != signer"); } @@ -4440,7 +4566,7 @@ mod tests { .build(); testing_env!(ctx); - contract + let _ = contract .submit_participant_info(valid_attestation, dto_public_key) .expect("Outsider attestation submission should succeed"); @@ -4502,7 +4628,7 @@ mod tests { .build() ); - contract + let _ = contract .submit_participant_info(Attestation::Mock(MockAttestation::Valid), dto_public_key) .unwrap(); @@ -5035,14 +5161,12 @@ mod tests { destination_node_info, ); } - let valid_participant_attestation = mpc_attestation::attestation::Attestation::Mock( - mpc_attestation::attestation::MockAttestation::Valid, - ); + let valid_participant_attestation = MpcMockAttestation::Valid; let tee_upgrade_duration = Duration::from_secs(contract.config.tee_upgrade_deadline_duration_seconds); - let insertion_result = contract.tee_state.add_participant( + let insertion_result = contract.tee_state.verify_and_store_mock( NodeId { account_id: self.signer_account_id.clone(), tls_public_key: self.attestation_tls_key.clone(), @@ -5696,15 +5820,15 @@ mod tests { tls_public_key: target_participant_info.tls_public_key.clone(), account_public_key: bogus_ed25519_public_key(), }; - let expiring_attestation = MpcAttestation::Mock(MpcMockAttestation::WithConstraints { + let expiring_attestation = MpcMockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(ATTESTATION_EXPIRY_SECONDS), expected_measurements: None, - }); + }; contract .tee_state - .add_participant(node_id, expiring_attestation, TEE_UPGRADE_DURATION) + .verify_and_store_mock(node_id, expiring_attestation, TEE_UPGRADE_DURATION) .expect("mock attestation is not yet expired and valid"); // Capture the running state before verify_tee for comparison @@ -5815,15 +5939,15 @@ mod tests { tls_public_key: target_participant_info.tls_public_key.clone(), account_public_key: bogus_ed25519_public_key(), }; - let expiring_attestation = MpcAttestation::Mock(MpcMockAttestation::WithConstraints { + let expiring_attestation = MpcMockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(ATTESTATION_EXPIRY_SECONDS), expected_measurements: None, - }); + }; contract .tee_state - .add_participant(node_id, expiring_attestation, TEE_UPGRADE_DURATION) + .verify_and_store_mock(node_id, expiring_attestation, TEE_UPGRADE_DURATION) .expect("mock attestation is not yet expired and valid"); let (first_account_id, _, _) = &participant_list[0]; @@ -5846,247 +5970,6 @@ mod tests { assert!(!contract.accept_requests); } - /// Sets up a complete TEE test environment with contract, accounts, mock dstack attestation, TLS key and the node's near public key. - /// This is a helper function that provides all the common components needed for TEE-related tests. - fn setup_tee_test() -> ( - MpcContract, - Vec, - Attestation, - dtos::Ed25519PublicKey, - DockerImageHash, - near_sdk::PublicKey, - ) { - let (_context, contract, _secret_key) = basic_setup(Curve::Bls12381, &mut OsRng); - - let participant_account_ids: Vec<_> = contract - .protocol_state - .threshold_parameters() - .unwrap() - .participants() - .participants() - .iter() - .map(|(account_id, _, _)| account_id.clone()) - .collect(); - - let attestation = mock_dto_dstack_attestation(); - let tls_key = p2p_tls_key().into(); - let mpc_hash = image_digest(); - let near_public_key = near_account_key(); - - ( - contract, - participant_account_ids, - attestation, - tls_key, - mpc_hash, - near_public_key, - ) - } - - /// Sets up a contract with an approved MPC hash by having the participants vote for it. - /// Also adds the legacy launcher image hash so that compose hashes are derived correctly. - /// This is a helper function commonly used in tests that require pre-approved hashes. - fn setup_approved_mpc_hash( - contract: &mut MpcContract, - participant_account_ids: &[near_sdk::AccountId], - mpc_hash: &DockerImageHash, - block_timestamp_ns: u64, - ) { - // Add the legacy launcher image first, so that compose hashes are derived - // when the MPC hash is voted in. - setup_approved_launcher_hash(contract, participant_account_ids, block_timestamp_ns); - - for participant_account_id in participant_account_ids { - testing_env!( - VMContextBuilder::new() - .signer_account_id(participant_account_id.clone()) - .predecessor_account_id(participant_account_id.clone()) - .block_timestamp(block_timestamp_ns) - .build() - ); - - contract.vote_code_hash(*mpc_hash).expect("vote succeeds"); - } - } - - /// Adds the launcher image hash from test attestation assets. - /// The hash is extracted from `test-utils/assets/launcher_image_compose.yaml`. - fn setup_approved_launcher_hash( - contract: &mut MpcContract, - participant_account_ids: &[near_sdk::AccountId], - block_timestamp_ns: u64, - ) { - let launcher_hash = launcher_image_hash(); - - for participant_account_id in participant_account_ids { - testing_env!( - VMContextBuilder::new() - .signer_account_id(participant_account_id.clone()) - .predecessor_account_id(participant_account_id.clone()) - .block_timestamp(block_timestamp_ns) - .build() - ); - - contract - .vote_add_launcher_hash(launcher_hash) - .expect("launcher vote succeeds"); - } - } - - /// Adds the default OS measurements so that Dstack attestation verification passes. - fn setup_approved_measurements( - contract: &mut MpcContract, - participant_account_ids: &[near_sdk::AccountId], - block_timestamp_ns: u64, - ) { - for measurement in mpc_attestation::attestation::default_measurements() { - let contract_measurement = ContractExpectedMeasurements::from(*measurement); - for participant_account_id in participant_account_ids { - testing_env!( - VMContextBuilder::new() - .signer_account_id(participant_account_id.clone()) - .predecessor_account_id(participant_account_id.clone()) - .block_timestamp(block_timestamp_ns) - .build() - ); - - contract - .vote_add_os_measurement(contract_measurement.clone()) - .expect("measurement vote succeeds"); - } - } - } - - /// **Test method with matching measurements** - Tests that participant info submission succeeds with the test-only method. - /// Unlike the test above, this one has an approved MPC hash. It uses the test method with custom measurements that match - /// the attestation data. - #[test] - fn test_submit_participant_info_succeeds_with_valid_dstack_attestation() { - // given - let ( - mut contract, - participant_account_ids, - attestation, - tls_key, - mpc_hash, - near_public_key, - ) = setup_tee_test(); - - let block_timestamp_ns = VALID_ATTESTATION_TIMESTAMP * 1_000_000_000; - - // when - setup_approved_mpc_hash( - &mut contract, - &participant_account_ids, - &mpc_hash, - block_timestamp_ns, - ); - setup_approved_measurements(&mut contract, &participant_account_ids, block_timestamp_ns); - - let account_id = participant_account_ids[0].clone(); - testing_env!( - VMContextBuilder::new() - .signer_account_id(account_id.clone()) - .predecessor_account_id(account_id.clone()) - .signer_account_pk(near_public_key.clone()) - .attached_deposit(NearToken::from_near(1)) - .block_timestamp(block_timestamp_ns) - .build() - ); - let result = contract.submit_participant_info(attestation, tls_key); - - // then - assert_matches::assert_matches!(result, Ok(())); - } - - /// Note - this test uses attestation data from a real MPC node. After Any change to the expected contract measurement, /test-utils/assets need to be updated. - /// see crates/test-utils/assets/README.md for details. - /// **No MPC hash approval** - Tests that participant info submission fails when no MPC hash has been approved yet. - /// This verifies the prerequisite step: the contract requires MPC hash approval before accepting any participant TEE information. - #[test] - fn test_submit_participant_info_fails_without_approved_mpc_hash() { - // given - let ( - mut contract, - participant_account_ids, - attestation, - tls_key, - _mpc_hash, - near_public_key, - ) = setup_tee_test(); - - let block_timestamp_ns = VALID_ATTESTATION_TIMESTAMP * 1_000_000_000; - - // when - - let account_id = participant_account_ids[0].clone(); - testing_env!( - VMContextBuilder::new() - .signer_account_id(account_id.clone()) - .predecessor_account_id(account_id.clone()) - .signer_account_pk(near_public_key.clone()) - .attached_deposit(NearToken::from_near(1)) - .block_timestamp(block_timestamp_ns) - .build() - ); - let result = contract.submit_participant_info(attestation, tls_key); - - // then - let error_string = result.unwrap_err().to_string(); - assert!(error_string - .contains("Invalid TEE Remote Attestation: TeeQuoteStatus is invalid: the submitted attestation failed verification, reason: Custom(\"the allowed mpc image hashes list is empty\")"), "Got error: {}", &error_string); - } - - /// **TLS key validation** - Tests that TEE attestation fails when TLS key doesn't match the one in report data. - /// Similar to the successful test method case above, but uses a deliberately corrupted TLS key to verify - /// that attestation validation properly checks the TLS key embedded in the attestation report. - #[test] - fn test_tee_attestation_fails_with_invalid_tls_key() { - let ( - mut contract, - participant_account_ids, - attestation, - tls_key, - mpc_hash, - near_public_key, - ) = setup_tee_test(); - - let block_timestamp_ns = VALID_ATTESTATION_TIMESTAMP * 1_000_000_000; - - // when - setup_approved_mpc_hash( - &mut contract, - &participant_account_ids, - &mpc_hash, - block_timestamp_ns, - ); - setup_approved_measurements(&mut contract, &participant_account_ids, block_timestamp_ns); - - // Create invalid TLS key by flipping the last bit - let mut invalid_tls_key_bytes = *tls_key.as_bytes(); - let last_byte_idx = invalid_tls_key_bytes.len() - 1; - invalid_tls_key_bytes[last_byte_idx] ^= 0x01; - let invalid_tls_key = Ed25519PublicKey::from(invalid_tls_key_bytes); - - let account_id = participant_account_ids[0].clone(); - testing_env!( - VMContextBuilder::new() - .signer_account_id(account_id.clone()) - .predecessor_account_id(account_id.clone()) - .signer_account_pk(near_public_key.clone()) - .attached_deposit(NearToken::from_near(1)) - .block_timestamp(block_timestamp_ns) - .build() - ); - - let result = contract.submit_participant_info(attestation, invalid_tls_key); - - // then - let error_string = result.unwrap_err().to_string(); - assert!(error_string - .contains("Invalid TEE Remote Attestation: TeeQuoteStatus is invalid: the submitted attestation failed verification, reason: WrongHash { name: \"report_data\""), "Got error: {}", &error_string); - } - fn make_launcher_hash(byte: u8) -> LauncherImageHash { LauncherImageHash::from([byte; 32]) } @@ -7503,13 +7386,13 @@ mod tests { // Add attestation for the new node (mirrors what ConcludeNodeMigrationTestSetup::setup does). contract .tee_state - .add_participant( + .verify_and_store_mock( NodeId { account_id: operator4.clone(), tls_public_key: new_tls_key.clone(), account_public_key: new_signer_pk.clone(), }, - mpc_attestation::attestation::Attestation::Mock(MpcMockAttestation::Valid), + MpcMockAttestation::Valid, Duration::from_secs(contract.config.tee_upgrade_deadline_duration_seconds), ) .expect("attestation insertion should succeed"); diff --git a/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap b/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap index a92c342dd9..73ee9018ba 100644 --- a/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap +++ b/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap @@ -210,6 +210,10 @@ BorshSchemaContainer { "fail_on_timeout_tera_gas", "u64", ), + ( + "fail_attestation_submission_tera_gas", + "u64", + ), ( "clean_tee_status_tera_gas", "u64", @@ -234,6 +238,14 @@ BorshSchemaContainer { "remove_non_participant_tee_verifier_votes_tera_gas", "u64", ), + ( + "verifier_tera_gas", + "u64", + ), + ( + "resolve_verification_tera_gas", + "u64", + ), ], ), }, diff --git a/crates/contract/src/tee.rs b/crates/contract/src/tee.rs index 9fafd439f9..00b42f33b4 100644 --- a/crates/contract/src/tee.rs +++ b/crates/contract/src/tee.rs @@ -3,4 +3,5 @@ pub mod proposal; pub mod tee_state; #[cfg(any(test, feature = "test-utils"))] pub mod test_utils; +pub mod verification_context; pub mod verifier_votes; diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 01a302f8d4..e4f9089e4b 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -11,13 +11,17 @@ use crate::{ }; use borsh::{BorshDeserialize, BorshSerialize}; use mpc_attestation::{ - attestation::{self, AcceptedAttestation, Attestation, VerifiedAttestation}, + attestation::{ + self, AcceptedAttestation, DstackAttestation, DstackVerify, MockAttestation, + VerifiedAttestation, + }, report_data::{ReportData, ReportDataV1}, }; use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash}; use near_mpc_contract_interface::types::{self as dtos, Ed25519PublicKey}; use near_sdk::{env, near, store::IterableMap}; use std::time::Duration; +use tee_verifier_interface::VerifiedReport; pub use near_mpc_contract_interface::types::NodeId; @@ -33,8 +37,8 @@ pub enum TeeQuoteStatus { Invalid(String), } -#[derive(Debug, Clone, thiserror::Error)] -pub(crate) enum AttestationSubmissionError { +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum AttestationSubmissionError { #[error("the submitted attestation failed verification, reason: {:?}", .0)] InvalidAttestation(#[from] attestation::VerificationError), #[error( @@ -43,10 +47,13 @@ pub(crate) enum AttestationSubmissionError { TlsKeyOwnedByOtherAccount, } -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] +#[expect(clippy::large_enum_variant)] pub(crate) enum ParticipantInsertion { NewlyInsertedParticipant, - UpdatedExistingParticipant, + /// Holds the overwritten entry so [`TeeState::revert_dstack_store`] can put + /// it back if the async store is rolled back. + UpdatedExistingParticipant(NodeAttestation), } #[derive(Debug)] @@ -59,7 +66,7 @@ pub enum TeeValidationResult { }, } -#[derive(Debug, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) @@ -143,31 +150,47 @@ impl TeeState { } fn current_time_seconds() -> u64 { - let current_time_milliseconds = env::block_timestamp_ms(); - current_time_milliseconds / 1_000 + env::block_timestamp_ms() / 1_000 } - /// Adds a participant attestation for the given node iff the attestation succeeds verification. - pub(crate) fn add_participant( + pub(crate) fn verify_and_store_mock( &mut self, node_id: NodeId, - attestation: Attestation, + mock: MockAttestation, tee_upgrade_deadline_duration: Duration, ) -> Result { - let expected_report_data: ReportData = ReportDataV1::new( - *node_id.tls_public_key.as_bytes(), - *node_id.account_public_key.as_bytes(), - ) - .into(); + let AcceptedAttestation { + attestation: verified_attestation, + advisory_ids, + } = mock.verify( + Self::current_time_seconds(), + &self.get_allowed_mpc_docker_image_hashes(tee_upgrade_deadline_duration), + &self.get_allowed_launcher_compose_hashes(), + &self.get_accepted_measurements(), + )?; + + log_informational_advisory_ids(&advisory_ids); + + self.store_verified_attestation(node_id, verified_attestation) + } + /// Runs the post-DCAP checks for a [`DstackAttestation`] against the + /// [`VerifiedReport`] the verifier returned, then stores the result. + pub(crate) fn verify_and_store_dstack( + &mut self, + node_id: NodeId, + dstack: &DstackAttestation, + report: &VerifiedReport, + tee_upgrade_deadline_duration: Duration, + ) -> Result { + let expected_report_data = Self::expected_report_data(&node_id); let accepted_measurements = self.get_accepted_measurements(); - // TODO(#3264): run DCAP in the verifier contract (Promise + callback) and - // do the post-DCAP checks here, instead of verifying locally in-WASM. let AcceptedAttestation { attestation: verified_attestation, advisory_ids, - } = attestation.verify_locally( - expected_report_data.into(), + } = dstack.verify( + report, + expected_report_data, Self::current_time_seconds(), &self.get_allowed_mpc_docker_image_hashes(tee_upgrade_deadline_duration), &self.get_allowed_launcher_compose_hashes(), @@ -175,7 +198,27 @@ impl TeeState { )?; log_informational_advisory_ids(&advisory_ids); + self.store_verified_attestation(node_id, verified_attestation) + } + + fn expected_report_data(node_id: &NodeId) -> ::attestation::report_data::ReportData { + let report_data: ReportData = ReportDataV1::new( + *node_id.tls_public_key.as_bytes(), + *node_id.account_public_key.as_bytes(), + ) + .into(); + report_data.into() + } + /// Stores `verified_attestation` under `node_id`'s TLS key and flushes the write to storage + /// before returning, so a caller's subsequent [`env::storage_usage`] reflects the insert + /// (used to charge the storage delta). Rejects a submission whose TLS key is already registered + /// to a different account with [`AttestationSubmissionError::TlsKeyOwnedByOtherAccount`]. + fn store_verified_attestation( + &mut self, + node_id: NodeId, + verified_attestation: VerifiedAttestation, + ) -> Result { let tls_pk = node_id.tls_public_key.clone(); // Authorization: a TLS key registered to one account must not be @@ -188,7 +231,7 @@ impl TeeState { return Err(AttestationSubmissionError::TlsKeyOwnedByOtherAccount); } - let insertion = self.stored_attestations.insert( + let previous = self.stored_attestations.insert( tls_pk, NodeAttestation { node_id, @@ -196,12 +239,35 @@ impl TeeState { }, ); - Ok(match insertion { - Some(_previous_attestation) => ParticipantInsertion::UpdatedExistingParticipant, + // `IterableMap` defers the write to flush-on-Drop; force it now + self.stored_attestations.flush(); + + Ok(match previous { + Some(previous) => ParticipantInsertion::UpdatedExistingParticipant(previous), None => ParticipantInsertion::NewlyInsertedParticipant, }) } + /// Undoes a [`Self::verify_and_store_dstack`] store: restores the displaced + /// entry, or removes the newly-inserted one if there was none. Used by the + /// async flow when the storage charge fails after the store, so a caller + /// can't get storage for free in a receipt that still commits. + pub(crate) fn revert_dstack_store( + &mut self, + tls_public_key: &Ed25519PublicKey, + insertion: ParticipantInsertion, + ) { + match insertion { + ParticipantInsertion::UpdatedExistingParticipant(previous) => { + self.stored_attestations + .insert(tls_public_key.clone(), previous); + } + ParticipantInsertion::NewlyInsertedParticipant => { + self.stored_attestations.remove(tls_public_key); + } + } + } + /// reverifies stored participant attestations. pub(crate) fn reverify_participants( &self, @@ -535,18 +601,22 @@ pub(crate) enum AttestationCheckError { #[expect(non_snake_case)] mod tests { use super::*; - use crate::primitives::key_state::AuthenticatedParticipantId; - use crate::primitives::test_utils::{ - bogus_ed25519_near_public_key, bogus_ed25519_public_key, gen_participant, gen_participants, + use crate::primitives::{ + key_state::AuthenticatedParticipantId, + participants::{ParticipantId, ParticipantInfo}, + test_utils::{bogus_ed25519_near_public_key, bogus_ed25519_public_key, gen_participants}, }; use crate::tee::test_utils::set_block_timestamp; use assert_matches::assert_matches; - use mpc_attestation::attestation::{Attestation, MockAttestation}; + use mpc_attestation::attestation::{Attestation, MockAttestation, VerificationError}; use mpc_primitives::hash::{LauncherImageHash, NodeImageHash}; use near_account_id::AccountId; use near_sdk::test_utils::VMContextBuilder; use near_sdk::testing_env; use std::time::Duration; + use test_utils::attestation::{mock_dstack_attestation, verified_report}; + + const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); /// Helper to set up the testing environment with a specific signer fn set_signer(account_id: &AccountId, public_key: &near_sdk::PublicKey) { @@ -557,11 +627,61 @@ mod tests { testing_env!(builder.build()); } + fn create_node_id(account_id: &AccountId, tls_public_key: &Ed25519PublicKey) -> NodeId { + NodeId { + account_id: account_id.clone(), + tls_public_key: tls_public_key.clone(), + account_public_key: bogus_ed25519_public_key(), + } + } + + fn node_id_for(account_id: &AccountId) -> NodeId { + create_node_id(account_id, &bogus_ed25519_public_key()) + } + + fn store_valid_attestations( + tee_state: &mut TeeState, + participants: &[(AccountId, ParticipantId, ParticipantInfo)], + upgrade_duration: Duration, + ) { + for (account_id, _, participant_info) in participants { + let node_id = create_node_id(account_id, &participant_info.tls_public_key); + tee_state + .verify_and_store_mock(node_id, MockAttestation::Valid, upgrade_duration) + .expect("mock attestation is valid"); + } + } + + fn mock_attestation_with_expiry(expiry_secs: u64) -> MockAttestation { + MockAttestation::WithConstraints { + mpc_docker_image_hash: None, + launcher_docker_compose_hash: None, + expiry_timestamp_seconds: Some(expiry_secs), + expected_measurements: None, + } + } + + fn mock_valid_attestation(node_id: NodeId) -> NodeAttestation { + NodeAttestation { + node_id, + verified_attestation: VerifiedAttestation::Mock(MockAttestation::Valid), + } + } + + /// Sets [`AccountId`] as the signer and authenticates it against [`Participants`]. + fn authenticate_as( + account_id: &AccountId, + participants: &Participants, + ) -> AuthenticatedParticipantId { + let mut ctx = VMContextBuilder::new(); + ctx.signer_account_id(account_id.clone()); + testing_env!(ctx.build()); + AuthenticatedParticipantId::new(participants).unwrap() + } + #[test] fn clean_non_participant_votes__should_not_touch_attestations() { // Given - const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10000); - let mut tee_state = TeeState::default(); // Create some test participants using test utils @@ -572,25 +692,17 @@ mod tests { let participant_nodes: Vec = participants .participants() .iter() - .map(|(account_id, _, p_info)| NodeId { - account_id: account_id.clone(), - tls_public_key: p_info.tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - }) + .map(|(account_id, _, p_info)| create_node_id(account_id, &p_info.tls_public_key)) .collect(); // Add TEE information for all participants and non-participant - let local_attestation = Attestation::Mock(MockAttestation::Valid); + let local_attestation = MockAttestation::Valid; - let non_participant_uid = NodeId { - account_id: non_participant.clone(), - account_public_key: bogus_ed25519_public_key(), - tls_public_key: bogus_ed25519_public_key(), - }; + let non_participant_uid = node_id_for(&non_participant); for node_id in &participant_nodes { tee_state - .add_participant( + .verify_and_store_mock( node_id.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, @@ -598,7 +710,7 @@ mod tests { .unwrap(); } tee_state - .add_participant( + .verify_and_store_mock( non_participant_uid.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, @@ -638,35 +750,19 @@ mod tests { let mut tee_state = TeeState::default(); - let fresh_node = NodeId { - account_id: "fresh.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; - let stale_node = NodeId { - account_id: "stale.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; + let fresh_account: AccountId = "fresh.near".parse().unwrap(); + let stale_account: AccountId = "stale.near".parse().unwrap(); + let fresh_node = node_id_for(&fresh_account); + let stale_node = node_id_for(&stale_account); - let fresh = Attestation::Mock(MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(FRESH_EXPIRY_SECONDS), - expected_measurements: None, - }); - let stale = Attestation::Mock(MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(STALE_EXPIRY_SECONDS), - expected_measurements: None, - }); + let fresh = mock_attestation_with_expiry(FRESH_EXPIRY_SECONDS); + let stale = mock_attestation_with_expiry(STALE_EXPIRY_SECONDS); tee_state - .add_participant(fresh_node.clone(), fresh, Duration::from_secs(0)) + .verify_and_store_mock(fresh_node.clone(), fresh, Duration::from_secs(0)) .unwrap(); tee_state - .add_participant(stale_node.clone(), stale, Duration::from_secs(0)) + .verify_and_store_mock(stale_node.clone(), stale, Duration::from_secs(0)) .unwrap(); assert_eq!(tee_state.stored_attestations.len(), 2); @@ -699,21 +795,13 @@ mod tests { let mut tee_state = TeeState::default(); - let expired = Attestation::Mock(MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(EXPIRY_SECONDS), - expected_measurements: None, - }); + let expired = mock_attestation_with_expiry(EXPIRY_SECONDS); for idx in 0..10 { - let node_id = NodeId { - account_id: format!("node{idx}.near").parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; + let account_id: AccountId = format!("node{idx}.near").parse().unwrap(); + let node_id = node_id_for(&account_id); tee_state - .add_participant(node_id, expired.clone(), Duration::from_secs(0)) + .verify_and_store_mock(node_id, expired.clone(), Duration::from_secs(0)) .unwrap(); } assert_eq!(tee_state.stored_attestations.len(), 10); @@ -744,19 +832,11 @@ mod tests { testing_env!(VMContextBuilder::new().block_timestamp(0).build()); let mut tee_state = TeeState::default(); - let node_id = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; - let attestation = Attestation::Mock(MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(FUTURE_EXPIRY_SECONDS), - expected_measurements: None, - }); + let account_id: AccountId = "alice.near".parse().unwrap(); + let node_id = node_id_for(&account_id); + let attestation = mock_attestation_with_expiry(FUTURE_EXPIRY_SECONDS); tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // When: cleanup runs while the attestation is still valid. @@ -774,19 +854,14 @@ mod tests { #[test] fn updating_existing_participant_returns_existing_participant() { // given - const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10000); let mut tee_state = TeeState::default(); let participant: AccountId = "dave.near".parse().unwrap(); - let local_attestation = Attestation::Mock(MockAttestation::Valid); + let local_attestation = MockAttestation::Valid; - let participant_id = NodeId { - account_id: participant.clone(), - account_public_key: bogus_ed25519_public_key(), - tls_public_key: bogus_ed25519_public_key(), - }; + let participant_id = node_id_for(&participant); - let insertion_result = tee_state.add_participant( + let insertion_result = tee_state.verify_and_store_mock( participant_id.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, @@ -797,33 +872,32 @@ mod tests { ); // when - let re_insertion_result = tee_state.add_participant( + let re_insertion_result = tee_state.verify_and_store_mock( participant_id.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, ); // then - assert_matches!( + assert_eq!( re_insertion_result, - Ok(ParticipantInsertion::UpdatedExistingParticipant) + Ok(ParticipantInsertion::UpdatedExistingParticipant( + mock_valid_attestation(participant_id) + )) ); } #[test] - fn add_participant_increases_storage_size() { + fn verify_and_store_mock__should_increase_storage_size() { // given let mut tee_state = TeeState::default(); - let node_id = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; - let attestation = Attestation::Mock(MockAttestation::Valid); + let account_id: AccountId = "alice.near".parse().unwrap(); + let node_id = node_id_for(&account_id); + let attestation = MockAttestation::Valid; // when tee_state - .add_participant(node_id, attestation, Duration::from_secs(0)) + .verify_and_store_mock(node_id, attestation, Duration::from_secs(0)) .unwrap(); // then @@ -835,55 +909,46 @@ mod tests { } #[test] - fn add_participant_indexes_by_tls_key() { - // given + fn verify_and_store_mock__should_flush_so_storage_usage_grows() { + // Given + testing_env!(VMContextBuilder::new().build()); let mut tee_state = TeeState::default(); - let node_id = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; - let attestation = Attestation::Mock(MockAttestation::Valid); + let account_id: AccountId = "alice.near".parse().unwrap(); + let node_id = node_id_for(&account_id); + let storage_before = env::storage_usage(); - // when + // When tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .verify_and_store_mock(node_id, MockAttestation::Valid, Duration::from_secs(0)) .unwrap(); - // then + // Then: the store must flush the insert so the charged storage delta is + // nonzero. Without the flush the write defers to drop and this reads zero, + // letting a caller store an attestation without paying for it. + let storage_after = env::storage_usage(); assert!( - tee_state - .stored_attestations - .contains_key(&node_id.tls_public_key), - "Entry should be strictly retrievable using the TLS public key" + storage_after > storage_before, + "env::storage_usage() should grow after the store ({storage_before} -> {storage_after})" ); } #[test] - fn add_participant_preserves_node_id_integrity() { + fn verify_and_store_mock__should_preserve_node_id_integrity() { // given let mut tee_state = TeeState::default(); - let node_id = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; - let attestation = Attestation::Mock(MockAttestation::Valid); + let account_id: AccountId = "alice.near".parse().unwrap(); + let node_id = node_id_for(&account_id); + let attestation = MockAttestation::Valid; // when tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // then - let stored_entry = tee_state - .stored_attestations - .get(&node_id.tls_public_key) - .unwrap(); - assert_eq!( - stored_entry.node_id, node_id, - "The stored NodeId struct must exactly match the inserted one" + tee_state.stored_attestations.get(&node_id.tls_public_key), + Some(&mock_valid_attestation(node_id)) ); } @@ -892,30 +957,23 @@ mod tests { // given let mut tee_state = TeeState::default(); - let node_1 = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; - - let node_2 = NodeId { - account_id: "bob.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; + let alice: AccountId = "alice.near".parse().unwrap(); + let bob: AccountId = "bob.near".parse().unwrap(); + let node_1 = node_id_for(&alice); + let node_2 = node_id_for(&bob); // when tee_state - .add_participant( + .verify_and_store_mock( node_1.clone(), - Attestation::Mock(MockAttestation::Valid), + MockAttestation::Valid, Duration::from_secs(0), ) .unwrap(); tee_state - .add_participant( + .verify_and_store_mock( node_2.clone(), - Attestation::Mock(MockAttestation::Valid), + MockAttestation::Valid, Duration::from_secs(0), ) .unwrap(); @@ -938,25 +996,17 @@ mod tests { fn re_verify_validates_fresh_attestation() { // given let mut tee_state = TeeState::default(); - let node_id = NodeId { - account_id: "fresh.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; + let account_id: AccountId = "fresh.near".parse().unwrap(); + let node_id = node_id_for(&account_id); const NOW_SECONDS: u64 = 1000; testing_env!(VMContextBuilder::new().block_timestamp(NOW_SECONDS).build()); - let attestation = Attestation::Mock(MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(NOW_SECONDS), - expected_measurements: None, - }); + let attestation = mock_attestation_with_expiry(NOW_SECONDS); tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // when @@ -970,26 +1020,18 @@ mod tests { fn test_re_verify_rejects_expired_attestation() { // given let mut tee_state = TeeState::default(); - let node_id = NodeId { - account_id: "about_to_be_expired.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; + let account_id: AccountId = "about_to_be_expired.near".parse().unwrap(); + let node_id = node_id_for(&account_id); const EXPIRY_TIMESTAMP_SECONDS: u64 = 1000; const ELAPSED_SECONDS: u64 = 200; testing_env!(VMContextBuilder::new().block_timestamp(0).build()); - let attestation = Attestation::Mock(MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(EXPIRY_TIMESTAMP_SECONDS), - expected_measurements: None, - }); + let attestation = mock_attestation_with_expiry(EXPIRY_TIMESTAMP_SECONDS); tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // when @@ -1005,18 +1047,15 @@ mod tests { let status = tee_state.reverify_participants(&node_id, Duration::from_secs(0)); // then - assert_matches!(status, TeeQuoteStatus::Invalid(_)); + assert_matches!(status, TeeQuoteStatus::Invalid(msg) if msg.contains("has expired")); } #[test] fn re_verify_succeeds_within_expiry_time() { // given let mut tee_state = TeeState::default(); - let node_id = NodeId { - account_id: "valid_check.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; + let account_id: AccountId = "valid_check.near".parse().unwrap(); + let node_id = node_id_for(&account_id); const EXPIRY_TIMESTAMP_SECONDS: u64 = 1000; @@ -1026,15 +1065,10 @@ mod tests { .build() ); - let attestation = Attestation::Mock(MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(EXPIRY_TIMESTAMP_SECONDS), - expected_measurements: None, - }); + let attestation = mock_attestation_with_expiry(EXPIRY_TIMESTAMP_SECONDS); tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .verify_and_store_mock(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // when @@ -1052,11 +1086,8 @@ mod tests { fn test_re_verify_returns_invalid_for_missing_node() { // given let tee_state = TeeState::default(); - let node_id = NodeId { - account_id: "ghost.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; + let account_id: AccountId = "ghost.near".parse().unwrap(); + let node_id = node_id_for(&account_id); // when let status = tee_state.reverify_participants(&node_id, Duration::from_secs(0)); @@ -1087,11 +1118,7 @@ mod tests { account_public_key: Ed25519PublicKey::try_from(&signer_pk).unwrap(), }; tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .verify_and_store_mock(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("Attestation is valid on insertion"); // 4. Verify check passes @@ -1152,11 +1179,7 @@ mod tests { account_public_key: Ed25519PublicKey::try_from(&signer_pk).unwrap(), }; tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .verify_and_store_mock(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("Attestation is valid on insertion"); let result = tee_state.is_caller_an_attested_participant(&participants); @@ -1188,11 +1211,7 @@ mod tests { account_public_key: old_signer_pk, // Mismatch here }; tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .verify_and_store_mock(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("Attestation is valid on insertion"); // when @@ -1207,15 +1226,6 @@ mod tests { /// Grace period for TEE upgrade deadline used in validate_tee() tests const TEST_GRACE_PERIOD: Duration = Duration::from_secs(10); - /// Helper to create a NodeId from participant data - fn create_node_id(account_id: &AccountId, tls_public_key: &Ed25519PublicKey) -> NodeId { - NodeId { - account_id: account_id.clone(), - tls_public_key: tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - } - } - /// Helper to extract account IDs from participants for assertion comparisons fn account_ids(participants: &Participants) -> Vec { participants @@ -1232,16 +1242,11 @@ mod tests { let tee_upgrade_duration = Duration::MAX; // Add valid attestations for all participants - for (account_id, _, participant_info) in participants.participants().iter() { - let node_id = create_node_id(account_id, &participant_info.tls_public_key); - tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) - .expect("mock attestation is valid"); - } + store_valid_attestations( + &mut tee_state, + participants.participants(), + tee_upgrade_duration, + ); let validation_result = tee_state.reverify_and_cleanup_participants(&participants, TEST_GRACE_PERIOD); @@ -1257,16 +1262,7 @@ mod tests { let tee_upgrade_duration = Duration::MAX; // Add valid attestations for only first 2 participants - for (account_id, _, participant_info) in participant_list.iter().take(2) { - let node_id = create_node_id(account_id, &participant_info.tls_public_key); - tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) - .expect("mock attestation is valid"); - } + store_valid_attestations(&mut tee_state, &participant_list[..2], tee_upgrade_duration); // Third participant has no attestation let validation_result = @@ -1291,28 +1287,14 @@ mod tests { let participant_list: Vec<_> = participants.participants().to_vec(); // Add valid attestations for first 2 participants - for (account_id, _, participant_info) in participant_list.iter().take(2) { - let node_id = create_node_id(account_id, &participant_info.tls_public_key); - tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) - .expect("mock attestation is valid"); - } + store_valid_attestations(&mut tee_state, &participant_list[..2], tee_upgrade_duration); // Add expiring attestation for third participant let (account_id, _, participant_info) = &participant_list[2]; let node_id = create_node_id(account_id, &participant_info.tls_public_key); - let expiring_attestation = Attestation::Mock(MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(expiry_time_secs), - expected_measurements: None, - }); + let expiring_attestation = mock_attestation_with_expiry(expiry_time_secs); tee_state - .add_participant(node_id, expiring_attestation, tee_upgrade_duration) + .verify_and_store_mock(node_id, expiring_attestation, tee_upgrade_duration) .expect("mock attestation is valid"); // Advance time to exact expiry boundary @@ -1345,17 +1327,12 @@ mod tests { for (i, (account_id, _, participant_info)) in participant_list.iter().enumerate() { let node_id = create_node_id(account_id, &participant_info.tls_public_key); let attestation = if i == 2 { - Attestation::Mock(MockAttestation::WithConstraints { - mpc_docker_image_hash: None, - launcher_docker_compose_hash: None, - expiry_timestamp_seconds: Some(expiry_time_secs), - expected_measurements: None, - }) + mock_attestation_with_expiry(expiry_time_secs) } else { - Attestation::Mock(MockAttestation::Valid) + MockAttestation::Valid }; tee_state - .add_participant(node_id, attestation, tee_upgrade_duration) + .verify_and_store_mock(node_id, attestation, tee_upgrade_duration) .expect("mock attestation is valid"); } @@ -1373,35 +1350,27 @@ mod tests { } #[test] - fn add_participant__should_reject_tls_key_owned_by_other_account() { + fn verify_and_store_mock__should_reject_tls_key_owned_by_other_account() { // Given: an existing attestation registered to `alice.near` under some TLS key. - const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); - let mut tee_state = TeeState::default(); let tls_public_key = bogus_ed25519_public_key(); - let alice_node = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - }; + let alice: AccountId = "alice.near".parse().unwrap(); + let alice_node = create_node_id(&alice, &tls_public_key); tee_state - .add_participant( + .verify_and_store_mock( alice_node.clone(), - Attestation::Mock(MockAttestation::Valid), + MockAttestation::Valid, TEE_UPGRADE_DURATION, ) .expect("initial insertion should succeed"); // When: a different account submits an attestation for the same TLS key. - let attacker_node = NodeId { - account_id: "attacker.near".parse().unwrap(), - tls_public_key: tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - }; - let result = tee_state.add_participant( + let attacker: AccountId = "attacker.near".parse().unwrap(); + let attacker_node = create_node_id(&attacker, &tls_public_key); + let result = tee_state.verify_and_store_mock( attacker_node, - Attestation::Mock(MockAttestation::Valid), + MockAttestation::Valid, TEE_UPGRADE_DURATION, ); @@ -1410,89 +1379,160 @@ mod tests { result, Err(AttestationSubmissionError::TlsKeyOwnedByOtherAccount) ); - let stored = tee_state - .stored_attestations - .get(&tls_public_key) - .expect("entry must still be present"); - assert_eq!(stored.node_id, alice_node); + assert_eq!( + tee_state.stored_attestations.get(&tls_public_key), + Some(&mock_valid_attestation(alice_node)) + ); } #[test] - fn add_participant__should_allow_same_account_to_update_its_own_entry() { + fn verify_and_store_mock__should_allow_same_account_to_update_its_own_entry() { // Given: an existing attestation registered to `alice.near`. - const TEE_UPGRADE_DURATION: Duration = Duration::from_secs(10_000); - let mut tee_state = TeeState::default(); let tls_public_key = bogus_ed25519_public_key(); - let initial_node = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key: tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - }; + let alice: AccountId = "alice.near".parse().unwrap(); + let initial_node = create_node_id(&alice, &tls_public_key); tee_state - .add_participant( - initial_node, - Attestation::Mock(MockAttestation::Valid), - TEE_UPGRADE_DURATION, - ) + .verify_and_store_mock(initial_node, MockAttestation::Valid, TEE_UPGRADE_DURATION) .expect("initial insertion should succeed"); // When: the same account resubmits with a rotated account_public_key. - let rotated_node = NodeId { - account_id: "alice.near".parse().unwrap(), - tls_public_key, - account_public_key: bogus_ed25519_public_key(), - }; - let result = tee_state.add_participant( + let rotated_node = create_node_id(&alice, &tls_public_key); + let result = tee_state.verify_and_store_mock( rotated_node.clone(), - Attestation::Mock(MockAttestation::Valid), + MockAttestation::Valid, TEE_UPGRADE_DURATION, ); // Then: the update is accepted and the stored entry reflects the new key. - assert_matches!(result, Ok(ParticipantInsertion::UpdatedExistingParticipant)); - let stored = tee_state - .stored_attestations - .get(&rotated_node.tls_public_key) - .expect("entry must be present"); - assert_eq!(stored.node_id, rotated_node); + assert_matches!( + result, + Ok(ParticipantInsertion::UpdatedExistingParticipant(_)) + ); + assert_eq!( + tee_state.stored_attestations.get(&tls_public_key), + Some(&mock_valid_attestation(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`). + 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.clone(), + MockAttestation::Valid, + TEE_UPGRADE_DURATION, + ) + .expect("update should succeed"); + + let original_entry = mock_valid_attestation(original_node); + let updated_entry = mock_valid_attestation(updated_node); + assert_eq!( + insertion, + ParticipantInsertion::UpdatedExistingParticipant(original_entry.clone()) + ); + assert_eq!( + tee_state.stored_attestations.get(&tls_public_key), + Some(&updated_entry) + ); + + // When: the store is reverted. + tee_state.revert_dstack_store(&tls_public_key, insertion); + + // Then: the whole original entry is back in place. + assert_eq!( + tee_state.stored_attestations.get(&tls_public_key), + Some(&original_entry) + ); + } + + #[test] + fn revert_dstack_store__should_remove_the_newly_inserted_entry() { + // Given: a brand-new attestation for `alice` (no prior entry displaced). + 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 add_participant_rejects_invalid_attestations() { + fn verify_and_store_mock__should_reject_invalid_attestations() { let mut tee_state = TeeState::default(); let participants = gen_participants(3); let participant_list: Vec<_> = participants.participants().to_vec(); let tee_upgrade_duration = Duration::MAX; // Add valid attestations for first 2 participants - for (account_id, _, participant_info) in participant_list.iter().take(2) { - let node_id = create_node_id(account_id, &participant_info.tls_public_key); - tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) - .expect("mock attestation is valid"); - } + store_valid_attestations(&mut tee_state, &participant_list[..2], tee_upgrade_duration); // Add invalid attestation for third participant let (account_id, _, participant_info) = &participant_list[2]; let node_id = create_node_id(account_id, &participant_info.tls_public_key); - let add_participant_result = tee_state.add_participant( + let add_participant_result = tee_state.verify_and_store_mock( node_id, - Attestation::Mock(MockAttestation::Invalid), + MockAttestation::Invalid, tee_upgrade_duration, ); assert_matches!( add_participant_result, - Err(AttestationSubmissionError::InvalidAttestation(_)) + Err(AttestationSubmissionError::InvalidAttestation( + VerificationError::InvalidMockAttestation + )) ) } + #[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( + VerificationError::Custom(msg) + )) if msg.contains("allowed mpc image hashes list is empty") + ); + assert!(tee_state.stored_attestations.is_empty()); + } + /// Stale CodeHashesVotes entries from removed participants must not count toward /// quorum after resharing. /// @@ -1504,23 +1544,19 @@ mod tests { #[test] fn test_clean_non_participant_votes_removes_stale_votes() { // Build 5 participants - let mut all_participants = Participants::new(); - let mut account_ids = Vec::new(); - for i in 0..5 { - let (account_id, info) = gen_participant(i); - account_ids.push(account_id.clone()); - all_participants.insert(account_id, info).unwrap(); - } + let all_participants = gen_participants(5); + let account_ids: Vec = all_participants + .participants() + .iter() + .map(|(account_id, _, _)| account_id.clone()) + .collect(); let mut tee_state = TeeState::default(); // P0 and P1 vote for a malicious hash before resharing let malicious_hash = NodeImageHash::from([0xAA; 32]); for account_id in &account_ids[0..2] { - let mut ctx = VMContextBuilder::new(); - ctx.signer_account_id(account_id.clone()); - testing_env!(ctx.build()); - let auth_id = AuthenticatedParticipantId::new(&all_participants).unwrap(); + let auth_id = authenticate_as(account_id, &all_participants); tee_state.votes.vote(malicious_hash, &auth_id); } assert_eq!(tee_state.votes.proposal_by_account.len(), 2); @@ -1536,10 +1572,7 @@ mod tests { // P2 votes for the same malicious hash — should be only 1 vote, not 3 let p2_account = &account_ids[2]; - let mut ctx = VMContextBuilder::new(); - ctx.signer_account_id(p2_account.clone()); - testing_env!(ctx.build()); - let auth_id = AuthenticatedParticipantId::new(&new_participants).unwrap(); + let auth_id = authenticate_as(p2_account, &new_participants); let vote_count = tee_state.votes.vote(malicious_hash, &auth_id); assert_eq!(vote_count, 1, "Only the fresh vote from P2 should count"); } @@ -1547,21 +1580,17 @@ mod tests { /// Verifies that clean_non_participants also removes stale launcher and measurement votes. #[test] fn test_clean_non_participants_removes_stale_launcher_and_measurement_votes() { - let mut all_participants = Participants::new(); - let mut account_ids = Vec::new(); - for i in 0..3 { - let (account_id, info) = gen_participant(i); - account_ids.push(account_id.clone()); - all_participants.insert(account_id, info).unwrap(); - } + let all_participants = gen_participants(3); + let account_ids: Vec = all_participants + .participants() + .iter() + .map(|(account_id, _, _)| account_id.clone()) + .collect(); let mut tee_state = TeeState::default(); // P0 votes for a launcher hash - let mut ctx = VMContextBuilder::new(); - ctx.signer_account_id(account_ids[0].clone()); - testing_env!(ctx.build()); - let auth_id = AuthenticatedParticipantId::new(&all_participants).unwrap(); + let auth_id = authenticate_as(&account_ids[0], &all_participants); let launcher_action = LauncherVoteAction::Add(LauncherImageHash::from([0xBB; 32])); tee_state.launcher_votes.vote(launcher_action, &auth_id); diff --git a/crates/contract/src/tee/verification_context.rs b/crates/contract/src/tee/verification_context.rs new file mode 100644 index 0000000000..6450f60c04 --- /dev/null +++ b/crates/contract/src/tee/verification_context.rs @@ -0,0 +1,14 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use mpc_attestation::attestation::DstackAttestation; + +use super::tee_state::NodeId; + +#[derive(Debug, BorshSerialize, BorshDeserialize)] +#[cfg_attr( + all(feature = "abi", not(target_arch = "wasm32")), + derive(borsh::BorshSchema) +)] +pub struct VerificationContext { + pub(crate) node_id: NodeId, + pub(crate) attestation: DstackAttestation, +} diff --git a/crates/contract/src/v3_13_0_state.rs b/crates/contract/src/v3_13_0_state.rs index 83f727d076..9fc2fd10fd 100644 --- a/crates/contract/src/v3_13_0_state.rs +++ b/crates/contract/src/v3_13_0_state.rs @@ -15,7 +15,8 @@ use near_sdk::{ }; use crate::{ - Config, SupportedForeignChainsByNode, + SupportedForeignChainsByNode, + config::Config, foreign_chains_metadata::ForeignChainsMetadata, node_migrations::NodeMigrations, primitives::{ @@ -27,6 +28,60 @@ use crate::{ update::ProposedUpdates, }; +/// Shadow of the `3.13.0` [`Config`]: the deployed layout predates the async +/// attestation gas fields (`fail_attestation_submission_tera_gas`, +/// `verifier_tera_gas`, `resolve_verification_tera_gas`), so migrating state +/// written by `3.13.0` must deserialize the old field set and then default the +/// new ones. +#[derive(Debug, BorshSerialize, BorshDeserialize)] +struct OldConfig { + key_event_timeout_blocks: u64, + tee_upgrade_deadline_duration_seconds: u64, + contract_upgrade_deposit_tera_gas: u64, + sign_call_gas_attachment_requirement_tera_gas: u64, + ckd_call_gas_attachment_requirement_tera_gas: u64, + return_signature_and_clean_state_on_success_call_tera_gas: u64, + return_ck_and_clean_state_on_success_call_tera_gas: u64, + fail_on_timeout_tera_gas: u64, + clean_tee_status_tera_gas: u64, + clean_invalid_attestations_tera_gas: u64, + cleanup_orphaned_node_migrations_tera_gas: u64, + remove_non_participant_update_votes_tera_gas: u64, + clean_foreign_chain_data_tera_gas: u64, + remove_non_participant_tee_verifier_votes_tera_gas: u64, +} + +impl From for Config { + fn from(old: OldConfig) -> Self { + // Carry the deployed values; the async attestation gas fields are new in + // this release, so take their defaults. + Config { + key_event_timeout_blocks: old.key_event_timeout_blocks, + tee_upgrade_deadline_duration_seconds: old.tee_upgrade_deadline_duration_seconds, + contract_upgrade_deposit_tera_gas: old.contract_upgrade_deposit_tera_gas, + sign_call_gas_attachment_requirement_tera_gas: old + .sign_call_gas_attachment_requirement_tera_gas, + ckd_call_gas_attachment_requirement_tera_gas: old + .ckd_call_gas_attachment_requirement_tera_gas, + return_signature_and_clean_state_on_success_call_tera_gas: old + .return_signature_and_clean_state_on_success_call_tera_gas, + return_ck_and_clean_state_on_success_call_tera_gas: old + .return_ck_and_clean_state_on_success_call_tera_gas, + fail_on_timeout_tera_gas: old.fail_on_timeout_tera_gas, + clean_tee_status_tera_gas: old.clean_tee_status_tera_gas, + clean_invalid_attestations_tera_gas: old.clean_invalid_attestations_tera_gas, + cleanup_orphaned_node_migrations_tera_gas: old + .cleanup_orphaned_node_migrations_tera_gas, + remove_non_participant_update_votes_tera_gas: old + .remove_non_participant_update_votes_tera_gas, + clean_foreign_chain_data_tera_gas: old.clean_foreign_chain_data_tera_gas, + remove_non_participant_tee_verifier_votes_tera_gas: old + .remove_non_participant_tee_verifier_votes_tera_gas, + ..Config::default() + } + } +} + /// Keep this module in sync with [`crate::MpcContract`]: the moment a field's borsh /// layout diverges, shadow the old type here (see this module's history for examples) so /// state written by the `3.13.0` contract still deserializes during migration. @@ -38,7 +93,7 @@ pub struct MpcContract { pending_verify_foreign_tx_requests: LookupMap>, proposed_updates: ProposedUpdates, node_foreign_chain_support: SupportedForeignChainsByNode, - config: Config, + config: OldConfig, tee_state: TeeState, accept_requests: bool, node_migrations: NodeMigrations, @@ -61,7 +116,7 @@ impl From for crate::MpcContract { pending_verify_foreign_tx_requests: old.pending_verify_foreign_tx_requests, proposed_updates: old.proposed_updates, node_foreign_chain_support: old.node_foreign_chain_support, - config: old.config, + config: old.config.into(), tee_state: old.tee_state, accept_requests: old.accept_requests, node_migrations: old.node_migrations, diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index fdd29f0517..f457198ab1 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -3,20 +3,22 @@ use mpc_contract::{ MpcContract, crypto_shared::types::PublicKeyExtended, - errors::{Error, InvalidParameters}, + errors::{Error, InvalidParameters, InvalidState, TeeError}, primitives::{ key_state::{AttemptId, EpochId, KeyForDomain, Keyset}, participants::{ParticipantId, ParticipantInfo}, test_utils::{bogus_ed25519_public_key, gen_participants}, thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters}, }, - tee::tee_state::NodeId, + tee::tee_state::{AttestationSubmissionError, NodeId}, }; -use near_mpc_contract_interface::types::{ - Attestation, InitConfig, MockAttestation, Protocol, ProtocolContractState, - ReconstructionThreshold, +use near_mpc_contract_interface::{ + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR, + types::{ + Attestation, DomainConfig, DomainId, DomainPurpose, InitConfig, MockAttestation, Protocol, + ProtocolContractState, ReconstructionThreshold, + }, }; -use near_mpc_contract_interface::types::{DomainConfig, DomainId, DomainPurpose}; use std::collections::BTreeMap; use assert_matches::assert_matches; @@ -24,13 +26,17 @@ use near_account_id::AccountId; use near_sdk::{NearToken, VMContext, test_utils::VMContextBuilder, testing_env}; use rstest::rstest; use std::{str::FromStr, time::Duration}; +use test_utils::attestation::mock_dto_dstack_attestation; const SECOND: Duration = Duration::from_secs(1); const NANOS_IN_SECOND: u64 = SECOND.as_nanos() as u64; +const ATTESTATION_STORAGE_DEPOSIT: NearToken = + NearToken::from_near(SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR); + 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, @@ -55,7 +61,7 @@ impl TestSetupBuilder { } } - fn with_partcipant_count(mut self, participant_count: usize) -> Self { + fn with_participant_count(mut self, participant_count: usize) -> Self { self.participant_count = Some(participant_count); self } @@ -70,6 +76,13 @@ impl TestSetupBuilder { self } + fn with_tee_upgrade_grace_period_seconds(self, seconds: u64) -> Self { + self.with_init_config(InitConfig { + tee_upgrade_deadline_duration_seconds: Some(seconds), + ..Default::default() + }) + } + fn with_contract_protocol_state( mut self, contract_protocol_state: ContractProtocolState, @@ -84,7 +97,7 @@ impl TestSetupBuilder { let threshold = self.threshold.unwrap_or(DEFAULT_THRESHOLD_SIZE); let contract_protocol_state = self .contract_protocol_state - .unwrap_or(DEFAUTL_CONTRACT_PROTOCOL_STATE); + .unwrap_or(DEFAULT_CONTRACT_PROTOCOL_STATE); // 2. Data Generation let participants = gen_participants(participant_count); @@ -223,6 +236,7 @@ impl TestSetup { testing_env!(context); self.contract .submit_participant_info(attestation, node_id.tls_public_key.clone()) + .map(|_| ()) } /// Switches testing context to a given participant at a specific timestamp @@ -273,6 +287,7 @@ fn create_context_for_participant(account_id: &AccountId) -> VMContext { .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) .block_timestamp(near_sdk::env::block_timestamp()) + .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) .build() } @@ -295,14 +310,8 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { const PARTICIPANT_COUNT: usize = 2; const THRESHOLD: u64 = 2; - testing_env!( - VMContextBuilder::new() - .attached_deposit(NearToken::from_near(1)) - .build() - ); - let mut setup = TestSetupBuilder::new() - .with_partcipant_count(PARTICIPANT_COUNT) + .with_participant_count(PARTICIPANT_COUNT) .with_threshold(THRESHOLD) .build(); @@ -329,20 +338,24 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { VMContextBuilder::new() .signer_account_id(attacker_node.account_id.clone()) .predecessor_account_id(attacker_node.account_id.clone()) - .attached_deposit(NearToken::from_near(1)) + .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) .build() ); - let attack_result = setup.contract.submit_participant_info( - Attestation::Mock(MockAttestation::Valid), - attacker_node.tls_public_key.clone(), - ); + let attack_result = setup + .contract + .submit_participant_info( + Attestation::Mock(MockAttestation::Valid), + attacker_node.tls_public_key.clone(), + ) + .map(|_| ()); // Then: the contract rejects the call with the TLS-ownership error and the victim's // entry is unchanged. assert_matches!( &attack_result, - Err(Error::InvalidParameters(InvalidParameters::InvalidTeeRemoteAttestation { reason })) - if reason.contains("TLS public key is already registered") + Err(Error::AttestationSubmission( + AttestationSubmissionError::TlsKeyOwnedByOtherAccount + )) ); let stored_after = setup .contract @@ -352,6 +365,59 @@ 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(); + 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(attached_deposit) + .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 *attached == attached_deposit.as_yoctonear() && 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() { + // 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 + let result = setup.try_submit_attestation_for_node(&node, mock_dto_dstack_attestation()); + + // Then + 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. @@ -361,30 +427,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(NearToken::from_near(1)) - .build() - ); - // Create contract in Running state with 2 current participants let mut setup = TestSetupBuilder::new() - .with_partcipant_count(PARTICIPANT_COUNT) + .with_participant_count(PARTICIPANT_COUNT) .with_threshold(THRESHOLD) .build(); // Submit TEE info for current 2 participants (all have valid attestations) let valid_attestation = Attestation::Mock(MockAttestation::Valid); - let participant_nodes: Vec = setup - .participants_list - .iter() - .take(PARTICIPANT_COUNT) - .map(|(account_id, _, participant_info)| NodeId { - account_id: account_id.clone(), - tls_public_key: participant_info.tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - }) - .collect(); + let participant_nodes = setup.get_participant_node_ids(); for node_id in &participant_nodes { setup.submit_attestation_for_node(node_id, valid_attestation.clone()); } @@ -404,12 +455,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(); @@ -421,17 +471,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 ); } @@ -446,15 +489,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(NearToken::from_near(1)) - .block_timestamp(0) - .build() - ); - let mut setup = TestSetupBuilder::new() - .with_partcipant_count(PARTICIPANT_COUNT) + .with_participant_count(PARTICIPANT_COUNT) .with_threshold(THRESHOLD) .build(); @@ -468,14 +504,7 @@ fn clean_invalid_attestations__should_remove_expired_entries() { // init_running seeds one mock `Valid` attestation per participant. Overwrite the // first participant's entry with an expiring one, and add a brand-new entry for an // outsider account. - let participant_node = { - let (account_id, _, info) = &setup.participants_list[0]; - NodeId { - account_id: account_id.clone(), - tls_public_key: info.tls_public_key.clone(), - account_public_key: bogus_ed25519_public_key(), - } - }; + let participant_node = setup.get_participant_node_ids()[0].clone(); setup.submit_attestation_for_node(&participant_node, expiring_attestation.clone()); let stale_node = NodeId { @@ -508,12 +537,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(NearToken::from_near(1)) - .block_timestamp(0) - .build() - ); let mut setup = TestSetupBuilder::new() .with_contract_protocol_state(ContractProtocolState::Initializing) @@ -523,7 +546,10 @@ fn clean_invalid_attestations__should_reject_when_not_running() { let result = setup.contract.clean_invalid_attestations(100); // Then: the call errors without mutating state. - assert_matches!(result, Err(_)); + assert_matches!( + result, + Err(Error::InvalidState(InvalidState::ProtocolStateNotRunning)) + ); } macro_rules! assert_allowed_docker_image_hashes { @@ -555,13 +581,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]; @@ -600,12 +621,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]; @@ -643,13 +660,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]; @@ -723,12 +735,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]; @@ -782,12 +790,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 diff --git a/crates/contract/tests/sandbox/common.rs b/crates/contract/tests/sandbox/common.rs index e900bc444d..2ce03f7e12 100644 --- a/crates/contract/tests/sandbox/common.rs +++ b/crates/contract/tests/sandbox/common.rs @@ -145,6 +145,7 @@ pub async fn init_contract_running( next_domain_id: u64, keyset: Keyset, params: ThresholdParameters, + init_config: Option, ) -> ExecutionSuccess { let result = contract .call(method_names::INIT_RUNNING) @@ -153,6 +154,7 @@ pub async fn init_contract_running( "next_domain_id": next_domain_id, "keyset": keyset, "parameters": params, + "init_config": init_config, })) .gas(GAS_FOR_INIT) .transact() @@ -311,6 +313,7 @@ impl SandboxTestSetupBuilder { next_domain_id, keyset, threshold_parameters, + self.init_config, ) .await; } else { diff --git a/crates/contract/tests/sandbox/contract_configuration.rs b/crates/contract/tests/sandbox/contract_configuration.rs index 83e370a2f0..b33c8db8cb 100644 --- a/crates/contract/tests/sandbox/contract_configuration.rs +++ b/crates/contract/tests/sandbox/contract_configuration.rs @@ -97,12 +97,15 @@ async fn contract_configuration_can_be_set_on_initialization() { return_signature_and_clean_state_on_success_call_tera_gas: Some(66), return_ck_and_clean_state_on_success_call_tera_gas: Some(77), fail_on_timeout_tera_gas: Some(88), + fail_attestation_submission_tera_gas: Some(89), clean_tee_status_tera_gas: Some(99), clean_invalid_attestations_tera_gas: Some(101), cleanup_orphaned_node_migrations_tera_gas: Some(11), remove_non_participant_update_votes_tera_gas: Some(12), clean_foreign_chain_data_tera_gas: Some(13), remove_non_participant_tee_verifier_votes_tera_gas: Some(14), + verifier_tera_gas: Some(15), + resolve_verification_tera_gas: Some(16), }; let SandboxTestSetup { contract, .. } = SandboxTestSetup::builder() diff --git a/crates/contract/tests/sandbox/mod.rs b/crates/contract/tests/sandbox/mod.rs index c4e4cbaf3d..e0f25b397a 100644 --- a/crates/contract/tests/sandbox/mod.rs +++ b/crates/contract/tests/sandbox/mod.rs @@ -6,6 +6,7 @@ pub mod participants_gas; pub mod sign; pub mod tee; pub mod tee_cleanup_after_resharing; +pub mod tee_verifier; pub mod update_votes_cleanup_after_resharing; pub mod upgrade_from_current_contract; pub mod upgrade_to_current_contract; diff --git a/crates/contract/tests/sandbox/participants_gas.rs b/crates/contract/tests/sandbox/participants_gas.rs index bd747b99f2..f07e1a743a 100644 --- a/crates/contract/tests/sandbox/participants_gas.rs +++ b/crates/contract/tests/sandbox/participants_gas.rs @@ -289,7 +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).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.rs b/crates/contract/tests/sandbox/tee.rs index 1ab3d32a7d..fe7f105d6d 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -8,7 +8,7 @@ use crate::sandbox::{ mpc_contract::{ assert_running_return_participants, assert_running_return_threshold, get_participant_attestation, get_state, get_tee_accounts, submit_participant_info, - vote_add_launcher_hash, vote_for_hash, + total_gas_fee, vote_add_launcher_hash, vote_for_hash, }, resharing_utils::conclude_resharing, sign_utils::DomainResponseTest, @@ -260,6 +260,7 @@ pub async fn get_participants(contract: &Contract) -> Result { #[tokio::test] async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result<()> { let SandboxTestSetup { + worker, contract, mpc_signer_accounts, .. @@ -267,17 +268,32 @@ 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 success = submit_participant_info( - &mpc_signer_accounts[0], + 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, &contract, - &mock_attestation, - &tls_key, + &Attestation::Mock(MockAttestation::Valid), + &p2p_tls_key().into(), ) - .await? - .is_success(); - assert!(success); + .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); + 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)) + ); Ok(()) } @@ -306,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(()) } @@ -984,3 +997,106 @@ async fn verify_tee__should_keep_participants_and_stop_signing_when_kickout_drop Ok(()) } + +/// Regression test for the lazy-collection storage-charge bug: a brand-new +/// attestation submitted with zero deposit must be rejected and not persisted. +#[tokio::test] +async fn submit_participant_info__should_reject_new_attestation_with_zero_deposit() -> Result<()> { + // Given + let SandboxTestSetup { + worker, contract, .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .build() + .await; + let outsider = worker.dev_create_account().await?; + let fresh_tls_key = bogus_ed25519_public_key(); + let storage_before = worker.view_account(contract.id()).await?.storage_usage; + + // When + let result = outsider + .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) + .args_json(( + Attestation::Mock(MockAttestation::Valid), + fresh_tls_key.clone(), + )) + .deposit(NearToken::from_near(0)) + .max_gas() + .transact() + .await?; + + // Then + assert!( + !result.is_success(), + "zero-deposit submission of a new attestation must fail: {result:?}" + ); + let error_msg = format!("{:?}", result.into_result()); + assert!( + error_msg.contains("Attached deposit is lower than required"), + "expected an insufficient-deposit error, got: {error_msg}" + ); + let stored = get_participant_attestation(&contract, &fresh_tls_key).await?; + assert!( + stored.is_none(), + "no attestation should be stored when the deposit is rejected" + ); + let storage_after = worker.view_account(contract.id()).await?.storage_usage; + assert_eq!( + storage_after, storage_before, + "contract storage must not grow when the submission is rejected" + ); + Ok(()) +} + +/// Mirror of the zero-deposit test with a funded submission: the attestation is +/// stored, storage grows, and the caller is charged a non-zero amount for it (the +/// bug charged zero). +#[tokio::test] +async fn submit_participant_info__should_store_new_attestation_and_charge_with_sufficient_deposit() +-> Result<()> { + const ATTACHED_DEPOSIT: NearToken = NearToken::from_near(1); + let SandboxTestSetup { + worker, contract, .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .build() + .await; + let outsider = worker.dev_create_account().await?; + let fresh_tls_key = bogus_ed25519_public_key(); + let storage_before = worker.view_account(contract.id()).await?.storage_usage; + let balance_before = outsider.view_account().await?.balance; + + // When + let result = outsider + .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) + .args_json(( + Attestation::Mock(MockAttestation::Valid), + fresh_tls_key.clone(), + )) + .deposit(ATTACHED_DEPOSIT) + .max_gas() + .transact() + .await?; + + // Then + assert!( + result.is_success(), + "funded submission of a new attestation should succeed: {result:?}" + ); + let stored = get_participant_attestation(&contract, &fresh_tls_key).await?; + assert!( + stored.is_some(), + "the attestation entry should be stored on-chain" + ); + let storage_after = worker.view_account(contract.id()).await?.storage_usage; + let bytes_grown = storage_after - storage_before; + assert!(bytes_grown > 0); + + // The caller's net spend must be exactly the measured storage stake plus the fee + // actually burnt; the rest of the attached deposit is refunded. + let storage_stake = near_sdk::env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); + let balance_after = outsider.view_account().await?.balance; + let spent = balance_before.saturating_sub(balance_after); + assert_eq!(spent, storage_stake.saturating_add(total_gas_fee(&result))); + Ok(()) +} diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs new file mode 100644 index 0000000000..0bb55aa85b --- /dev/null +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -0,0 +1,336 @@ +//! 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 (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::{ + common::SandboxTestSetup, + utils::{ + consts::ALL_PROTOCOLS, + contract_build::stub_tee_verifier_contract, + mpc_contract::{ + get_participant_attestation, submit_participant_info, + submit_participant_info_with_deposit, total_gas_fee, 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, result::ExecutionFinalResult, types::NearToken, +}; +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); + +/// 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. + let expected_code_hash = [7u8; 32]; + for account in participants { + 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); + 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 submit_dstack(submitter: &Account, contract: &Contract) -> ExecutionFinalResult { + submit_participant_info_with_deposit( + submitter, + contract, + &mock_dto_dstack_attestation(), + &p2p_tls_key().into(), + SUBMIT_DEPOSIT, + ) + .await + .unwrap() +} + +/// 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, + 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:#?}" + ); + // 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!( + 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_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] +async fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() { + // Given: no verifier voted in. + let SandboxTestSetup { + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .build() + .await; + + // When: a Dstack attestation is submitted. + let result = submit_participant_info( + &mpc_signer_accounts[0], + &contract, + &mock_dto_dstack_attestation(), + &p2p_tls_key().into(), + ) + .await + .unwrap(); + + // 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") + .to_string(); + let expected_panic = format!( + "Smart contract panicked: {}", + TeeError::VerifierNotConfigured + ); + assert!( + err.contains(&expected_panic), + "expected {expected_panic:?}, got: {err}" + ); + let stored = get_participant_attestation(&contract, &p2p_tls_key().into()) + .await + .unwrap(); + assert!(stored.is_none(), "no attestation should be stored"); +} + +#[tokio::test] +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 Dstack attestation is submitted. + let result = submit_dstack(&submitter, &contract).await; + + // Then: the submission fails cleanly, reporting the verifier's rejection reason. + 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_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 Dstack attestation is submitted. + 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. + assert_submission_failed_cleanly( + &result, + &contract, + &submitter, + balance_before, + &TeeError::VerifierUnavailable, + ) + .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 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: 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; + 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. + 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"); + + // 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.saturating_sub(balance_after); + assert_eq!( + net_spent, + storage_stake.saturating_add(total_gas_fee(&result)) + ); +} + +// 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: 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. + 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" + ); + assert_deposit_refunded(&submitter, balance_before, &result).await; +} diff --git a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs index ef9d4e712b..8456600b0c 100644 --- a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs +++ b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs @@ -113,12 +113,15 @@ async fn test_propose_update_config() { return_signature_and_clean_state_on_success_call_tera_gas: 66, return_ck_and_clean_state_on_success_call_tera_gas: 77, fail_on_timeout_tera_gas: 88, + fail_attestation_submission_tera_gas: 89, clean_tee_status_tera_gas: 99, clean_invalid_attestations_tera_gas: 101, cleanup_orphaned_node_migrations_tera_gas: 11, remove_non_participant_update_votes_tera_gas: 12, clean_foreign_chain_data_tera_gas: 13, remove_non_participant_tee_verifier_votes_tera_gas: 14, + verifier_tera_gas: 15, + resolve_verification_tera_gas: 16, }; let mut proposals = Vec::with_capacity(mpc_signer_accounts.len()); diff --git a/crates/contract/tests/sandbox/utils/consts.rs b/crates/contract/tests/sandbox/utils/consts.rs index b122dad2f6..ad577ee425 100644 --- a/crates/contract/tests/sandbox/utils/consts.rs +++ b/crates/contract/tests/sandbox/utils/consts.rs @@ -1,6 +1,8 @@ use std::time::Duration; -use near_mpc_contract_interface::types::Protocol; +use near_mpc_contract_interface::{ + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR, types::Protocol, +}; use near_sdk::{Gas, NearToken}; /* --- Protocol defaults --- */ @@ -45,4 +47,9 @@ pub const MAX_GAS_FOR_THRESHOLD_VOTE: Gas = Gas::from_tgas(190); /// TODO(#2756): Reduce this to the minimal value possible pub const CURRENT_CONTRACT_DEPLOY_DEPOSIT: NearToken = NearToken::from_millinear(17000); +/// Attached to `submit_participant_info`; the contract charges the measured storage +/// cost and refunds the excess. +pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT: NearToken = + NearToken::from_near(SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR); + pub const DEFAULT_MAX_TIMEOUT_TX_INCLUDED: Duration = Duration::from_secs(3); 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 ce9690131e..a529256a0e 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -1,13 +1,27 @@ 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, Contract, result::ExecutionFinalResult}; +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 @@ -40,21 +54,55 @@ 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(deposit) .max_gas() .transact() - .await?; - dbg!(&result); - Ok(result) + .await?) +} + +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/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 9039a8e858..35799f42f8 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -566,6 +566,39 @@ expression: abi } } }, + { + "name": "fail_attestation_submission", + "kind": "view", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "borsh", + "args": [ + { + "name": "reason", + "type_schema": { + "declaration": "String", + "definitions": { + "String": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "u8": { + "Primitive": 1 + } + } + } + } + ] + } + }, { "name": "fail_on_timeout", "kind": "view", @@ -991,6 +1024,10 @@ expression: abi "fail_on_timeout_tera_gas", "u64" ], + [ + "fail_attestation_submission_tera_gas", + "u64" + ], [ "clean_tee_status_tera_gas", "u64" @@ -1014,6 +1051,14 @@ expression: abi [ "remove_non_participant_tee_verifier_votes_tera_gas", "u64" + ], + [ + "verifier_tera_gas", + "u64" + ], + [ + "resolve_verification_tera_gas", + "u64" ] ] }, @@ -1267,6 +1312,753 @@ expression: abi ] } }, + { + "name": "resolve_verification", + "doc": " Verify-quote callback: on a verifier verdict it runs the post-DCAP\n checks, stores the attestation, and settles the deposit.", + "kind": "call", + "modifiers": [ + "payable", + "private" + ], + "params": { + "serialization_type": "borsh", + "args": [ + { + "name": "context", + "type_schema": { + "declaration": "VerificationContext", + "definitions": { + "()": { + "Primitive": 0 + }, + "AccountId": { + "Struct": [ + "String" + ] + }, + "Collateral": { + "Struct": [ + [ + "pck_crl_issuer_chain", + "String" + ], + [ + "root_ca_crl", + "Vec" + ], + [ + "pck_crl", + "Vec" + ], + [ + "tcb_info_issuer_chain", + "String" + ], + [ + "tcb_info", + "String" + ], + [ + "tcb_info_signature", + "Vec" + ], + [ + "qe_identity_issuer_chain", + "String" + ], + [ + "qe_identity", + "String" + ], + [ + "qe_identity_signature", + "Vec" + ], + [ + "pck_certificate_chain", + "Option" + ] + ] + }, + "DstackAttestation": { + "Struct": [ + [ + "quote", + "QuoteBytes" + ], + [ + "collateral", + "Collateral" + ], + [ + "tcb_info", + "TcbInfo" + ] + ] + }, + "Ed25519PublicKey": { + "Struct": [ + "[u8; 32]" + ] + }, + "EventLog": { + "Struct": [ + [ + "imr", + "u32" + ], + [ + "event_type", + "u32" + ], + [ + "digest", + "HexBytes<48>" + ], + [ + "event", + "String" + ], + [ + "event_payload", + "String" + ] + ] + }, + "HexBytes<32>": { + "Struct": [ + "[u8; 32]" + ] + }, + "HexBytes<48>": { + "Struct": [ + "[u8; 48]" + ] + }, + "NodeId": { + "Struct": [ + [ + "account_id", + "AccountId" + ], + [ + "tls_public_key", + "Ed25519PublicKey" + ], + [ + "account_public_key", + "Ed25519PublicKey" + ] + ] + }, + "Option>": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "None", + "()" + ], + [ + 1, + "Some", + "HexBytes<32>" + ] + ] + } + }, + "Option": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "None", + "()" + ], + [ + 1, + "Some", + "String" + ] + ] + } + }, + "QuoteBytes": { + "Struct": [ + "Vec" + ] + }, + "String": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "TcbInfo": { + "Struct": [ + [ + "mrtd", + "HexBytes<48>" + ], + [ + "rtmr0", + "HexBytes<48>" + ], + [ + "rtmr1", + "HexBytes<48>" + ], + [ + "rtmr2", + "HexBytes<48>" + ], + [ + "rtmr3", + "HexBytes<48>" + ], + [ + "os_image_hash", + "Option>" + ], + [ + "compose_hash", + "HexBytes<32>" + ], + [ + "device_id", + "HexBytes<32>" + ], + [ + "app_compose", + "String" + ], + [ + "event_log", + "Vec" + ] + ] + }, + "Vec": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "EventLog" + } + }, + "Vec": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "VerificationContext": { + "Struct": [ + [ + "node_id", + "NodeId" + ], + [ + "attestation", + "DstackAttestation" + ] + ] + }, + "[u8; 32]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 32, + "end": 32 + }, + "elements": "u8" + } + }, + "[u8; 48]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 48, + "end": 48 + }, + "elements": "u8" + } + }, + "u32": { + "Primitive": 4 + }, + "u8": { + "Primitive": 1 + } + } + } + } + ] + }, + "callbacks": [ + { + "serialization_type": "borsh", + "type_schema": { + "declaration": "VerificationResult", + "definitions": { + "EnclaveReport": { + "Struct": [ + [ + "cpu_svn", + "[u8; 16]" + ], + [ + "misc_select", + "u32" + ], + [ + "reserved1", + "[u8; 28]" + ], + [ + "attributes", + "[u8; 16]" + ], + [ + "mr_enclave", + "[u8; 32]" + ], + [ + "reserved2", + "[u8; 32]" + ], + [ + "mr_signer", + "[u8; 32]" + ], + [ + "reserved3", + "[u8; 96]" + ], + [ + "isv_prod_id", + "u16" + ], + [ + "isv_svn", + "u16" + ], + [ + "reserved4", + "[u8; 60]" + ], + [ + "report_data", + "[u8; 64]" + ] + ] + }, + "Report": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "SgxEnclave", + "Report__SgxEnclave" + ], + [ + 1, + "TD10", + "Report__TD10" + ], + [ + 2, + "TD15", + "Report__TD15" + ] + ] + } + }, + "Report__SgxEnclave": { + "Struct": [ + "EnclaveReport" + ] + }, + "Report__TD10": { + "Struct": [ + "TDReport10" + ] + }, + "Report__TD15": { + "Struct": [ + "TDReport15" + ] + }, + "String": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "TDReport10": { + "Struct": [ + [ + "tee_tcb_svn", + "[u8; 16]" + ], + [ + "mr_seam", + "[u8; 48]" + ], + [ + "mr_signer_seam", + "[u8; 48]" + ], + [ + "seam_attributes", + "[u8; 8]" + ], + [ + "td_attributes", + "[u8; 8]" + ], + [ + "xfam", + "[u8; 8]" + ], + [ + "mr_td", + "[u8; 48]" + ], + [ + "mr_config_id", + "[u8; 48]" + ], + [ + "mr_owner", + "[u8; 48]" + ], + [ + "mr_owner_config", + "[u8; 48]" + ], + [ + "rt_mr0", + "[u8; 48]" + ], + [ + "rt_mr1", + "[u8; 48]" + ], + [ + "rt_mr2", + "[u8; 48]" + ], + [ + "rt_mr3", + "[u8; 48]" + ], + [ + "report_data", + "[u8; 64]" + ] + ] + }, + "TDReport15": { + "Struct": [ + [ + "base", + "TDReport10" + ], + [ + "tee_tcb_svn2", + "[u8; 16]" + ], + [ + "mr_service_td", + "[u8; 48]" + ] + ] + }, + "TcbStatus": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "UpToDate", + "TcbStatus__UpToDate" + ], + [ + 1, + "OutOfDateConfigurationNeeded", + "TcbStatus__OutOfDateConfigurationNeeded" + ], + [ + 2, + "OutOfDate", + "TcbStatus__OutOfDate" + ], + [ + 3, + "ConfigurationAndSWHardeningNeeded", + "TcbStatus__ConfigurationAndSWHardeningNeeded" + ], + [ + 4, + "ConfigurationNeeded", + "TcbStatus__ConfigurationNeeded" + ], + [ + 5, + "SWHardeningNeeded", + "TcbStatus__SWHardeningNeeded" + ], + [ + 6, + "Revoked", + "TcbStatus__Revoked" + ] + ] + } + }, + "TcbStatusWithAdvisory": { + "Struct": [ + [ + "status", + "TcbStatus" + ], + [ + "advisory_ids", + "Vec" + ] + ] + }, + "TcbStatus__ConfigurationAndSWHardeningNeeded": { + "Struct": null + }, + "TcbStatus__ConfigurationNeeded": { + "Struct": null + }, + "TcbStatus__OutOfDate": { + "Struct": null + }, + "TcbStatus__OutOfDateConfigurationNeeded": { + "Struct": null + }, + "TcbStatus__Revoked": { + "Struct": null + }, + "TcbStatus__SWHardeningNeeded": { + "Struct": null + }, + "TcbStatus__UpToDate": { + "Struct": null + }, + "Vec": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "String" + } + }, + "Vec": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "VerificationResult": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "Verified", + "VerificationResult__Verified" + ], + [ + 1, + "Rejected", + "VerificationResult__Rejected" + ] + ] + } + }, + "VerificationResult__Rejected": { + "Struct": [ + "VerifierError" + ] + }, + "VerificationResult__Verified": { + "Struct": [ + "VerifiedReport" + ] + }, + "VerifiedReport": { + "Struct": [ + [ + "status", + "String" + ], + [ + "advisory_ids", + "Vec" + ], + [ + "report", + "Report" + ], + [ + "ppid", + "Vec" + ], + [ + "qe_status", + "TcbStatusWithAdvisory" + ], + [ + "platform_status", + "TcbStatusWithAdvisory" + ] + ] + }, + "VerifierError": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "DcapVerification", + "VerifierError__DcapVerification" + ] + ] + } + }, + "VerifierError__DcapVerification": { + "Struct": [ + "String" + ] + }, + "[u8; 16]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 16, + "end": 16 + }, + "elements": "u8" + } + }, + "[u8; 28]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 28, + "end": 28 + }, + "elements": "u8" + } + }, + "[u8; 32]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 32, + "end": 32 + }, + "elements": "u8" + } + }, + "[u8; 48]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 48, + "end": 48 + }, + "elements": "u8" + } + }, + "[u8; 60]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 60, + "end": 60 + }, + "elements": "u8" + } + }, + "[u8; 64]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 64, + "end": 64 + }, + "elements": "u8" + } + }, + "[u8; 8]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 8, + "end": 8 + }, + "elements": "u8" + } + }, + "[u8; 96]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 96, + "end": 96 + }, + "elements": "u8" + } + }, + "u16": { + "Primitive": 2 + }, + "u32": { + "Primitive": 4 + }, + "u8": { + "Primitive": 1 + } + } + } + } + ], + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/PromiseOrValueNull" + } + } + }, { "name": "respond", "kind": "call", @@ -1544,7 +2336,7 @@ expression: abi }, { "name": "submit_participant_info", - "doc": " (Prospective) Participants can submit their tee participant information through this\n endpoint.", + "doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously via a cross-contract\n `verify_quote` call, with [`Self::resolve_verification`] chained as its\n callback to run the post-DCAP checks and settle the deposit.\n\n The attached deposit pays for storage on success, and is refunded on failure.", "kind": "call", "modifiers": [ "payable" @@ -1569,7 +2361,7 @@ expression: abi "result": { "serialization_type": "json", "type_schema": { - "type": "null" + "$ref": "#/definitions/PromiseOrValueNull" } } }, @@ -2750,14 +3542,17 @@ expression: abi "clean_tee_status_tera_gas", "cleanup_orphaned_node_migrations_tera_gas", "contract_upgrade_deposit_tera_gas", + "fail_attestation_submission_tera_gas", "fail_on_timeout_tera_gas", "key_event_timeout_blocks", "remove_non_participant_tee_verifier_votes_tera_gas", "remove_non_participant_update_votes_tera_gas", + "resolve_verification_tera_gas", "return_ck_and_clean_state_on_success_call_tera_gas", "return_signature_and_clean_state_on_success_call_tera_gas", "sign_call_gas_attachment_requirement_tera_gas", - "tee_upgrade_deadline_duration_seconds" + "tee_upgrade_deadline_duration_seconds", + "verifier_tera_gas" ], "properties": { "ckd_call_gas_attachment_requirement_tera_gas": { @@ -2796,6 +3591,12 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "fail_attestation_submission_tera_gas": { + "description": "Prepaid gas for a `fail_attestation_submission` call.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, "fail_on_timeout_tera_gas": { "description": "Prepaid gas for a `fail_on_timeout` call.", "type": "integer", @@ -2820,6 +3621,12 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "resolve_verification_tera_gas": { + "description": "Prepaid gas for the `resolve_verification` callback.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, "return_ck_and_clean_state_on_success_call_tera_gas": { "description": "Prepaid gas for a `return_ck_and_clean_state_on_success` call.", "type": "integer", @@ -2843,6 +3650,12 @@ expression: abi "type": "integer", "format": "uint64", "minimum": 0.0 + }, + "verifier_tera_gas": { + "description": "Gas attached to the cross-contract `verify_quote` call on the verifier.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 } } }, @@ -3423,6 +4236,15 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "fail_attestation_submission_tera_gas": { + "description": "Prepaid gas for a `fail_attestation_submission` call.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, "fail_on_timeout_tera_gas": { "description": "Prepaid gas for a `fail_on_timeout` call.", "type": [ @@ -3459,6 +4281,15 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "resolve_verification_tera_gas": { + "description": "Prepaid gas for the `resolve_verification` callback.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, "return_ck_and_clean_state_on_success_call_tera_gas": { "description": "Prepaid gas for a `return_ck_and_clean_state_on_success` call.", "type": [ @@ -3494,6 +4325,15 @@ expression: abi ], "format": "uint64", "minimum": 0.0 + }, + "verifier_tera_gas": { + "description": "Gas attached to the cross-contract `verify_quote` call on the verifier.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 } } }, @@ -4100,6 +4940,9 @@ expression: abi } } }, + "PromiseOrValueNull": { + "type": "null" + }, "PromiseOrValueSignatureResponse": { "oneOf": [ { diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index a7967d50f0..30b6e4929b 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -6,12 +6,15 @@ use anyhow::Context; use backon::{ConstantBuilder, Retryable}; use ed25519_dalek::SigningKey; use near_kit::AccountId; -use near_mpc_contract_interface::method_names; -use near_mpc_contract_interface::types::{ - AccountId as ContractAccountId, CKDAppPublicKey, DomainConfig, DomainId, DomainPurpose, - Ed25519PublicKey, EpochId, ParticipantId, ParticipantInfo, Participants, - ProposedThresholdParameters, Protocol, ProtocolContractState, ReconstructionThreshold, - Threshold, ThresholdParameters, +use near_mpc_contract_interface::{ + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR, + method_names, + types::{ + AccountId as ContractAccountId, CKDAppPublicKey, DomainConfig, DomainId, DomainPurpose, + Ed25519PublicKey, EpochId, ParticipantId, ParticipantInfo, Participants, + ProposedThresholdParameters, Protocol, ProtocolContractState, ReconstructionThreshold, + Threshold, ThresholdParameters, + }, }; use rand::SeedableRng; use rand::rngs::StdRng; @@ -46,6 +49,9 @@ const SIGN_DEPOSIT: near_kit::NearToken = near_kit::NearToken::from_yoctonear(1) const KEY_EVENT_TIMEOUT_BLOCKS: u64 = 240; const CONTRACT_UPDATE_DEPOSIT: near_kit::NearToken = near_kit::NearToken::from_millinear(17_000); const CONTRACT_UPDATE_GAS: near_kit::Gas = near_kit::Gas::from_tgas(300); +const SUBMIT_PARTICIPANT_INFO_DEPOSIT: near_kit::NearToken = + near_kit::NearToken::from_near(SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR); +const SUBMIT_PARTICIPANT_INFO_GAS: near_kit::Gas = near_kit::Gas::from_tgas(300); const CONTRACT_DEPLOY_TIMEOUT: Duration = Duration::from_secs(15); const PROPOSER_NODE_INDEX: usize = 0; @@ -1186,13 +1192,15 @@ async fn init_contract( let pubkey = near_mpc_crypto_types::Ed25519PublicKey::from(p2p_keys[i].verifying_key().to_bytes()); contract - .call_from( + .call_from_with_deposit( &client, method_names::SUBMIT_PARTICIPANT_INFO, json!({ "proposed_participant_attestation": { "Mock": "Valid" }, "tls_public_key": pubkey, }), + SUBMIT_PARTICIPANT_INFO_GAS, + SUBMIT_PARTICIPANT_INFO_DEPOSIT, ) .await .with_context(|| format!("failed to submit attestation for node {i}"))?; diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 25585d68ea..a8ea3b12f0 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -34,7 +34,7 @@ pub enum Attestation { Mock(MockAttestation), } -#[derive(Clone, Debug, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) @@ -91,7 +91,9 @@ impl AcceptedAttestation { } #[expect(clippy::large_enum_variant)] -#[derive(Debug, Default, Clone, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] +#[derive( + Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, BorshDeserialize, BorshSerialize, +)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) @@ -195,7 +197,7 @@ impl MockAttestation { } } -#[derive(Clone, Debug, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) @@ -346,10 +348,9 @@ impl Attestation { } } - /// Full local verification: runs DCAP (`dcap_qvl::verify::verify`) and then - /// the post-DCAP checks. Behind the `local-verify` feature, which pulls in - /// `dcap-qvl`. Used by off-chain callers and, today, by `mpc-contract`. - // TODO(#3264): contract drops this once DCAP moves to the verifier contract. + /// Full local verification: runs the DCAP quote verification and then the + /// post-DCAP checks. Behind the `local-verify` feature, which pulls in + /// `dcap-qvl`. Used by off-chain callers (node, tee-authority, attestation-cli). #[cfg(feature = "local-verify")] pub fn verify_locally( &self, diff --git a/crates/near-mpc-contract-interface/src/deposits.rs b/crates/near-mpc-contract-interface/src/deposits.rs new file mode 100644 index 0000000000..c29eba3701 --- /dev/null +++ b/crates/near-mpc-contract-interface/src/deposits.rs @@ -0,0 +1,6 @@ +//! Deposit amounts to attach to contract methods, in NEAR. One shared value +//! for node, tests, and e2e. + +/// Deposit for `submit_participant_info`. The contract charges the actual storage +/// cost and refunds the excess. +pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR: u128 = 1; diff --git a/crates/near-mpc-contract-interface/src/lib.rs b/crates/near-mpc-contract-interface/src/lib.rs index bc09356bc1..e14fd44e4a 100644 --- a/crates/near-mpc-contract-interface/src/lib.rs +++ b/crates/near-mpc-contract-interface/src/lib.rs @@ -2,6 +2,7 @@ #[cfg(feature = "call-args")] pub mod call_args; +pub mod deposits; pub mod method_names; pub mod types { pub use attestation::{ diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index a6ae25a80d..c2e14008e5 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -64,6 +64,11 @@ pub const RETURN_SIGNATURE_AND_CLEAN_STATE_ON_SUCCESS: &str = pub const RETURN_CK_AND_CLEAN_STATE_ON_SUCCESS: &str = "return_ck_and_clean_state_on_success"; pub const RETURN_VERIFY_FOREIGN_TX_AND_CLEAN_STATE_ON_SUCCESS: &str = "return_verify_foreign_tx_and_clean_state_on_success"; +pub const RESOLVE_VERIFICATION: &str = "resolve_verification"; +pub const FAIL_ATTESTATION_SUBMISSION: &str = "fail_attestation_submission"; + +// TEE verifier contract (the method `mpc-contract` calls cross-contract) +pub const VERIFY_QUOTE: &str = "verify_quote"; // View methods pub const STATE: &str = "state"; diff --git a/crates/near-mpc-contract-interface/src/types/config.rs b/crates/near-mpc-contract-interface/src/types/config.rs index 75646b5a5a..d1f2a3a1e1 100644 --- a/crates/near-mpc-contract-interface/src/types/config.rs +++ b/crates/near-mpc-contract-interface/src/types/config.rs @@ -37,6 +37,8 @@ pub struct InitConfig { pub return_ck_and_clean_state_on_success_call_tera_gas: Option, /// Prepaid gas for a `fail_on_timeout` call. pub fail_on_timeout_tera_gas: Option, + /// Prepaid gas for a `fail_attestation_submission` call. + pub fail_attestation_submission_tera_gas: Option, /// Prepaid gas for a `clean_tee_status` call. pub clean_tee_status_tera_gas: Option, /// Prepaid gas for the reshare-time `clean_invalid_attestations` promise. @@ -49,6 +51,10 @@ pub struct InitConfig { pub clean_foreign_chain_data_tera_gas: Option, /// Prepaid gas for a `remove_non_participant_tee_verifier_votes` call. pub remove_non_participant_tee_verifier_votes_tera_gas: Option, + /// Gas attached to the cross-contract `verify_quote` call on the verifier. + pub verifier_tera_gas: Option, + /// Prepaid gas for the `resolve_verification` callback. + pub resolve_verification_tera_gas: Option, } /// Configuration parameters of the contract. @@ -87,6 +93,8 @@ pub struct Config { pub return_ck_and_clean_state_on_success_call_tera_gas: u64, /// Prepaid gas for a `fail_on_timeout` call. pub fail_on_timeout_tera_gas: u64, + /// Prepaid gas for a `fail_attestation_submission` call. + pub fail_attestation_submission_tera_gas: u64, /// Prepaid gas for a `clean_tee_status` call. pub clean_tee_status_tera_gas: u64, /// Prepaid gas for the reshare-time `clean_invalid_attestations` promise. @@ -99,6 +107,10 @@ pub struct Config { pub clean_foreign_chain_data_tera_gas: u64, /// Prepaid gas for a `remove_non_participant_tee_verifier_votes` call. pub remove_non_participant_tee_verifier_votes_tera_gas: u64, + /// Gas attached to the cross-contract `verify_quote` call on the verifier. + pub verifier_tera_gas: u64, + /// Prepaid gas for the `resolve_verification` callback. + pub resolve_verification_tera_gas: u64, } #[cfg(test)] @@ -117,12 +129,15 @@ mod tests { return_signature_and_clean_state_on_success_call_tera_gas: Some(7), return_ck_and_clean_state_on_success_call_tera_gas: Some(7), fail_on_timeout_tera_gas: Some(2), + fail_attestation_submission_tera_gas: Some(2), clean_tee_status_tera_gas: Some(10), clean_invalid_attestations_tera_gas: Some(10), cleanup_orphaned_node_migrations_tera_gas: Some(3), remove_non_participant_update_votes_tera_gas: Some(5), clean_foreign_chain_data_tera_gas: Some(5), remove_non_participant_tee_verifier_votes_tera_gas: Some(5), + verifier_tera_gas: Some(100), + resolve_verification_tera_gas: Some(60), }; let json = serde_json::to_string(&original_config).unwrap(); let serialized_and_deserialized_config: InitConfig = serde_json::from_str(&json).unwrap(); @@ -167,12 +182,15 @@ mod tests { return_signature_and_clean_state_on_success_call_tera_gas: None, return_ck_and_clean_state_on_success_call_tera_gas: None, fail_on_timeout_tera_gas: None, + fail_attestation_submission_tera_gas: None, clean_tee_status_tera_gas: None, clean_invalid_attestations_tera_gas: None, cleanup_orphaned_node_migrations_tera_gas: None, remove_non_participant_update_votes_tera_gas: None, clean_foreign_chain_data_tera_gas: None, remove_non_participant_tee_verifier_votes_tera_gas: None, + verifier_tera_gas: None, + resolve_verification_tera_gas: None, }; assert_eq!(default_config, config_with_all_values_as_none); diff --git a/crates/node/src/indexer/tx_sender.rs b/crates/node/src/indexer/tx_sender.rs index f566815500..e94e7a4000 100644 --- a/crates/node/src/indexer/tx_sender.rs +++ b/crates/node/src/indexer/tx_sender.rs @@ -10,7 +10,7 @@ use crate::types::{ use anyhow::Context; use ed25519_dalek::SigningKey; use near_account_id::AccountId; -use near_indexer_primitives::types::Gas; +use near_indexer_primitives::types::{Balance, Gas}; use near_mpc_contract_interface::types::{Attestation, Ed25519PublicKey, VerifiedAttestation}; use near_time::Clock; use std::future::Future; @@ -148,6 +148,7 @@ async fn submit_tx( method: String, params_ser: String, gas: Gas, + deposit: Balance, ) -> anyhow::Result { let block = indexer_state.view_client.latest_final_block().await?; @@ -156,6 +157,7 @@ async fn submit_tx( method, params_ser.into(), gas, + deposit, block.header.hash, block.header.height, ); @@ -340,6 +342,7 @@ async fn ensure_send_transaction( method.to_string(), params_ser.clone(), request.gas_required(), + request.deposit_required(), ) .await; diff --git a/crates/node/src/indexer/tx_signer.rs b/crates/node/src/indexer/tx_signer.rs index 5d6de6b842..1152fc6d72 100644 --- a/crates/node/src/indexer/tx_signer.rs +++ b/crates/node/src/indexer/tx_signer.rs @@ -36,12 +36,14 @@ impl TransactionSigner { new_nonce } + #[expect(clippy::too_many_arguments)] pub(crate) fn create_and_sign_function_call_tx( &self, receiver_id: AccountId, method_name: String, args: Vec, gas: Gas, + deposit: Balance, block_hash: CryptoHash, block_height: u64, ) -> SignedTransaction { @@ -49,7 +51,7 @@ impl TransactionSigner { method_name, args, gas, - deposit: Balance::from_near(0), + deposit, }; let verifying_key = self.signing_key.verifying_key(); diff --git a/crates/node/src/indexer/types.rs b/crates/node/src/indexer/types.rs index 1448a32a03..a9ae116ff8 100644 --- a/crates/node/src/indexer/types.rs +++ b/crates/node/src/indexer/types.rs @@ -6,14 +6,17 @@ use k256::{ ecdsa::RecoveryId, elliptic_curve::{Curve, CurveArithmetic, ops::Reduce, point::AffineCoordinates}, }; -use near_indexer_primitives::types::Gas; -use near_mpc_contract_interface::call_args as contract_args; -use near_mpc_contract_interface::method_names::{ - CONCLUDE_NODE_MIGRATION, RESPOND, RESPOND_CKD, RESPOND_VERIFY_FOREIGN_TX, - START_KEYGEN_INSTANCE, START_RESHARE_INSTANCE, SUBMIT_PARTICIPANT_INFO, VERIFY_TEE, - VOTE_ABORT_KEY_EVENT_INSTANCE, VOTE_PK, VOTE_RESHARED, +use near_indexer_primitives::types::{Balance, Gas}; +use near_mpc_contract_interface::{ + call_args as contract_args, + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR, + method_names::{ + CONCLUDE_NODE_MIGRATION, RESPOND, RESPOND_CKD, RESPOND_VERIFY_FOREIGN_TX, + START_KEYGEN_INSTANCE, START_RESHARE_INSTANCE, SUBMIT_PARTICIPANT_INFO, VERIFY_TEE, + VOTE_ABORT_KEY_EVENT_INSTANCE, VOTE_PK, VOTE_RESHARED, + }, + types::{self as dtos}, }; -use near_mpc_contract_interface::types::{self as dtos}; use serde::Serialize; use threshold_signatures::ecdsa::Signature; use threshold_signatures::frost_ed25519; @@ -121,6 +124,15 @@ impl ChainSendTransactionRequest { | Self::VerifyForeignTransactionRespond(_) => MAX_GAS, } } + + pub fn deposit_required(&self) -> Balance { + match self { + Self::SubmitParticipantInfo { .. } => { + Balance::from_near(SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR) + } + _ => Balance::from_near(0), + } + } } /// Extension trait for constructing SignatureRespond arguments from node-internal types. diff --git a/crates/tee-context/src/lib.rs b/crates/tee-context/src/lib.rs index 0088af5cda..23dbe320bd 100644 --- a/crates/tee-context/src/lib.rs +++ b/crates/tee-context/src/lib.rs @@ -11,13 +11,14 @@ use chain_gateway::{ types::FunctionCallArgs, }; use near_account_id::AccountId; -use near_mpc_contract_interface::call_args as contract_args; -use near_mpc_contract_interface::method_names::{ - ALLOWED_DOCKER_IMAGE_HASHES, ALLOWED_LAUNCHER_COMPOSE_HASHES, SUBMIT_PARTICIPANT_INFO, - VERIFY_TEE, -}; -use near_mpc_contract_interface::types::{ - AllowedMpcDockerImageHash, Attestation, Ed25519PublicKey, +use near_mpc_contract_interface::{ + call_args as contract_args, + deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR, + method_names::{ + ALLOWED_DOCKER_IMAGE_HASHES, ALLOWED_LAUNCHER_COMPOSE_HASHES, SUBMIT_PARTICIPANT_INFO, + VERIFY_TEE, + }, + types::{AllowedMpcDockerImageHash, Attestation, Ed25519PublicKey}, }; use serde::Deserialize; use tokio::sync::watch; @@ -133,7 +134,7 @@ where method_name: SUBMIT_PARTICIPANT_INFO.to_string(), args: args_json, gas: SUBMIT_ATTESTATION_GAS, - deposit: NearToken::from_yoctonear(0), + deposit: NearToken::from_near(SUBMIT_PARTICIPANT_INFO_DEPOSIT_NEAR), }, ) .await @@ -151,7 +152,7 @@ where method_name: VERIFY_TEE.to_string(), args: b"{}".to_vec(), gas: VERIFY_TEE_GAS, - deposit: NearToken::from_yoctonear(0), + deposit: NearToken::from_near(0), }, ) .await diff --git a/crates/test-tee-verifier-types/Cargo.toml b/crates/test-tee-verifier-types/Cargo.toml new file mode 100644 index 0000000000..28d34b9cdc --- /dev/null +++ b/crates/test-tee-verifier-types/Cargo.toml @@ -0,0 +1,21 @@ +[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 new file mode 100644 index 0000000000..c86372fc23 --- /dev/null +++ b/crates/test-tee-verifier-types/src/lib.rs @@ -0,0 +1,28 @@ +//! 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 new file mode 100644 index 0000000000..9eb2ca223c --- /dev/null +++ b/crates/test-tee-verifier/Cargo.toml @@ -0,0 +1,30 @@ +[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 new file mode 100644 index 0000000000..502c6db571 --- /dev/null +++ b/crates/test-tee-verifier/src/lib.rs @@ -0,0 +1,53 @@ +//! 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"), + } + } +} diff --git a/crates/test-utils/Cargo.toml b/crates/test-utils/Cargo.toml index 340e26f674..94ee48200c 100644 --- a/crates/test-utils/Cargo.toml +++ b/crates/test-utils/Cargo.toml @@ -7,13 +7,14 @@ 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"] } 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 diff --git a/crates/test-utils/src/attestation.rs b/crates/test-utils/src/attestation.rs index 3c4af6c72e..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(); @@ -107,10 +107,24 @@ 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, 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 + .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"); - 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(); diff --git a/crates/test-utils/src/contract_types.rs b/crates/test-utils/src/contract_types.rs index 6334ab1536..95809b87d6 100644 --- a/crates/test-utils/src/contract_types.rs +++ b/crates/test-utils/src/contract_types.rs @@ -15,5 +15,8 @@ pub fn dummy_config(value: u64) -> near_mpc_contract_interface::types::Config { remove_non_participant_update_votes_tera_gas: value + 11, clean_foreign_chain_data_tera_gas: value + 12, remove_non_participant_tee_verifier_votes_tera_gas: value + 13, + verifier_tera_gas: value + 14, + resolve_verification_tera_gas: value + 15, + fail_attestation_submission_tera_gas: value + 16, } } diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 0346ce6c69..3ddb42ca58 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -59,92 +59,93 @@ flowchart LR ### Submission flow -`mpc-contract`'s [`submit_participant_info`][submit-participant-info] becomes asynchronous for Dstack attestations and uses the same yield-resume pattern already in place for `sign`, `request_app_private_key`, and `request_verify_foreign_tx`. The method registers a yielded promise via [`env::promise_yield_create`][promise-yield-create] (going through the contract's existing [`enqueue_yield_request`][enqueue-yield-request] helper), stashes the resulting `data_id` in `pending_attestations`, and fires a cross-contract call to `tee-verifier::verify_quote` whose own `.then` callback (`resolve_verification`) bridges the verifier's response into a `promise_yield_resume`. Because `verify_quote` returns a `VerificationResult` value rather than failing the receipt (see [§Why a rejection is a value](#why-a-rejection-is-a-value-not-a-failed-receipt)), `resolve_verification`'s `#[callback_result]` distinguishes three outcomes: `Verified` runs the post-DCAP checks (RTMR3 replay, app-compose validation, measurement allowlist matching, report-data binding) and resumes; `Rejected` refunds and resumes **immediately** with the reason; and only `Err(PromiseError::Failed)` — the verifier unreachable or crashed — returns early, leaving cleanup to the ~200-block timeout. The yield-callback `on_attestation_verified` stays trivial, the same shape as the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]: it returns the resumed outcome to the caller, and only on the timeout (`Err(PromiseError::Failed)`) does it remove the pending entry and schedule a refund. Keeping the heavy work in `resolve_verification` makes an OOG in the yield-callback implausible, and an OOG in `resolve_verification` rolls back its whole receipt so the timeout still fires the trivial callback for cleanup. The exact branch-by-branch behavior is in [§Handling failures](#handling-failures) below. +`mpc-contract`'s [`submit_participant_info`][submit-participant-info] becomes asynchronous for Dstack attestations, but without yield-resume: it settles the submission entirely inside a single cross-contract promise chain. The method returns `Result, Error>`. A `Mock` attestation is verified and stored synchronously and returns `Ok(PromiseOrValue::Value(()))`. A `Dstack` attestation is handed to `submit_dstack_attestation`, which builds a `Promise` that calls `tee-verifier::verify_quote` and chains `resolve_verification` as its `.then` callback; the method returns `Ok(PromiseOrValue::Promise(...))`. The attached deposit is forwarded onto the callback via `.with_attached_deposit(env::attached_deposit())` rather than stashed in contract state, so `resolve_verification` can charge storage or refund from it directly. -The post-DCAP policy inputs are the same fields `mpc-contract` already holds today — the allowed-image-hash list, the per-account TLS / account public-key binding, and the stored-attestation map. No new policy state is introduced; the only state addition is the `pending_attestations` map described below, which is bookkeeping for the in-flight yield. +Because `verify_quote` returns a `VerificationResult` value rather than failing the receipt (see [§Why a rejection is a value](#why-a-rejection-is-a-value-not-a-failed-receipt)), `resolve_verification`'s `#[callback_result]` distinguishes three outcomes: `Ok(Verified)` runs the post-DCAP checks (RTMR3 replay, app-compose validation, measurement allowlist matching, report-data binding) and, on success, stores the attestation and charges storage; `Ok(Rejected)` returns a `QuoteRejected` error carrying the reason; and `Err(PromiseError::Failed)` — the verifier unreachable, panicked, or out of gas — returns `VerifierUnavailable`. On any error branch `resolve_verification` refunds the whole attached deposit and fires a *separate* `fail_attestation_submission` receipt whose panic fails the submitter's transaction. There is no yield, no `data_id`, no `pending_attestations` entry, and no ~200-block timeout: a failure settles immediately within the same promise chain. The exact branch-by-branch behavior is in [§Handling failures](#handling-failures) below. -The periodic re-validation path ([`re_verify`](../../crates/contract/src/tee/tee_state.rs)) does not call `dcap_qvl::verify` — it re-checks post-DCAP allowlist invariants against already-stored attestations — and is therefore unaffected by this design. It stays synchronous. +The post-DCAP policy inputs are the same fields `mpc-contract` already holds today — the allowed-image-hash list, the per-account TLS / account public-key binding, and the stored-attestation map. No new policy state is introduced, and the chain carries no bookkeeping map: everything `resolve_verification` needs travels as a `VerificationContext` borsh argument on the callback. + +The periodic re-validation path ([`re_verify`](../../crates/mpc-attestation/src/attestation.rs)) does not call `dcap_qvl::verify` — it re-checks post-DCAP allowlist invariants against already-stored attestations — and is therefore unaffected by this design. It stays synchronous. ```mermaid sequenceDiagram participant Op as Operator participant MPC as mpc-contract - participant State as State participant Ver as tee-verifier participant DCAP as dcap-qvl Op->>MPC: submit_participant_info(Dstack, tls_pk) - MPC->>MPC: promise_yield_create → data_id - MPC->>State: insert PendingAttestation { data_id, ... } - MPC->>Ver: Promise: verify_quote (chained .then resolve_verification) + MPC->>Ver: Promise: verify_quote(quote, collateral) + Note over MPC: .then resolve_verification(VerificationContext),
attached deposit forwarded to the callback Ver->>DCAP: verify(quote, collateral, now) - alt Verified (post-DCAP runs, then resumes) + alt Verified + store ok Ver-->>MPC: VerificationResult::Verified(report) - MPC->>MPC: resolve_verification: finish_verify vs fresh allowlist - MPC->>State: store on pass / refund on fail, remove PendingAttestation - MPC->>MPC: promise_yield_resume(data_id, FinalOutcome) - MPC-->>Op: success or error, immediately - else Rejected (resumes immediately) - Ver-->>MPC: VerificationResult::Rejected(reason) - MPC->>MPC: resolve_verification: refund, remove PendingAttestation - MPC->>MPC: promise_yield_resume(data_id, FinalOutcome::Err(reason)) - MPC-->>Op: error (carrying reason), immediately - else No verdict — verifier unreachable / silent for ~200 blocks - Note over MPC: resolve_verification (if it runs) only logs and returns.
Cleanup is left to the runtime yield-timeout. - MPC->>MPC: on_attestation_verified fires with Err(PromiseError::Failed) - MPC->>State: remove PendingAttestation, refund - MPC-->>Op: error + MPC->>MPC: resolve_verification: verify_post_dcap_and_store vs fresh allowlist + MPC->>MPC: charge_attestation_storage (refund excess) + MPC-->>Op: PromiseOrValue::Value(()) — success + else Verified + post-DCAP fail, or Rejected, or verifier unreachable + Ver-->>MPC: Verified(report) / Rejected(reason) / (no answer) + MPC->>MPC: resolve_verification produces Err (QuoteRejected / VerifierUnavailable) + MPC->>Op: refund whole attached deposit (this receipt) + MPC->>MPC: fail_attestation_submission receipt panics + MPC-->>Op: transaction fails (carrying the reason) end ``` #### Caller-side impact -The only caller of `submit_participant_info` in production is `mpc-node`'s `periodic_attestation_submission` task, which resubmits on a 1-hour cadence and on attestation-removal events. It already polls contract state to confirm the attestation is actually stored, with exponential backoff (100 ms → 60 s, capped at 12 h). That polling-based success criterion is what makes the sync→async change transparent. Under yield-resume the returned `Promise` also resolves with the actual outcome — success, a verifier-rejection error, or a post-DCAP-failure error (each as soon as the verifier answers), or a timeout error after ~200 blocks if it never does — so any future caller that wants to await the result synchronously can, without changing the contract. +The only caller of `submit_participant_info` in production is `mpc-node`'s `periodic_attestation_submission` task, which resubmits on a 1-hour cadence and on attestation-removal events. It already polls contract state to confirm the attestation is actually stored, with exponential backoff (100 ms → 60 s, capped at 12 h). That polling-based success criterion is what makes the sync→async change transparent. The returned `Promise` now resolves through the chain with the actual outcome — success, a verifier-rejection error, a post-DCAP-failure error, or a `VerifierUnavailable` error if the verifier never answers — so any future caller that wants to await the result synchronously can, without changing the contract. There is no ~200-block timeout error to account for: every path settles as soon as the verifier's receipt finishes. #### Handling failures -The first thing `submit_participant_info` does is insert a `PendingAttestation` entry, and that entry has to come back out once verification finishes — successfully or not. If a failure leaves the entry behind, the submitter's account is wedged: every future `submit_participant_info` call panics on the "already pending" guard, and the deposit stays locked because the refund is part of the cleanup the contract never got around to. +The submission produces no in-flight state to clean up: nothing is inserted into contract storage at submit time, so there is no pending entry that a failure could leave wedged and no "already pending" guard to trip on a resubmit. What a failure must still get right is the money — the attached deposit — and the caller-facing outcome. Both are handled in the single `.then` callback, `resolve_verification`. + +`resolve_verification` is a `#[private]` `#[payable]` method. It is `#[payable]` because the deposit rides forward onto it via `.with_attached_deposit`, so `env::attached_deposit()` inside the callback returns the amount the submitter attached. It observes the verifier's answer through `#[callback_result]` and reduces it to a `Result<(), Error>`: + +- `Ok(VerificationResult::Verified(report))` → `verify_post_dcap_and_store(&context, &report)`, which returns `Ok(())` on a clean store or an `Err` if a post-DCAP check or the storage charge fails. +- `Ok(VerificationResult::Rejected(reason))` → `Err(QuoteRejected { reason })`. +- `Err(promise_err)` → `Err(VerifierUnavailable)` — the verifier was unreachable, panicked, or ran out of gas. -That makes *where* the cleanup runs the central question, because NEAR offers two natural homes for "do something when the verifier responds" and they have very different failure modes. +On `Ok(())` the callback returns `PromiseOrValue::Value(())`; the attestation is stored and storage has been charged, with any excess deposit refunded inside `charge_attestation_storage`. -A **`.then` callback** is a normal cross-contract callback chained onto the verifier's promise. The runtime runs it in a fresh receipt once the verifier's receipt finishes; if it panics or runs out of gas, that receipt rolls back atomically and the chain ends. Because the receipt is independent of whatever yield is parked in parallel, its failure has no special effect on the submitter's call — the submitter just keeps waiting on the yield. +On `Err(err)` the callback does two things, in order, and the order is the whole point: -A **yield-callback** is different. When `submit_participant_info` calls `promise_yield_create`, it asks the runtime to *park* the submitter's call so the contract can return its result later. The runtime fires the named callback exactly once per `data_id` — either when something calls `promise_yield_resume(data_id, payload)`, or after ~200 blocks of silence with `Err(PromiseError::Failed)`. That single firing's return value is what the submitter eventually receives. There is no second invocation: an OOG inside the yield-callback rolls back its whole receipt and drops whatever cleanup it was meant to do, with no automatic retry. +1. `refund_to(&account_id, env::attached_deposit())` — refund the *entire* attached deposit in this receipt. +2. Schedule a *separate* `fail_attestation_submission` receipt via `Promise::new(current_account).function_call(...).as_return()`, and return it as `PromiseOrValue::Promise`. -The asymmetry decides the design. The work the verifier's *answer* unlocks — post-DCAP checks, the `stored_attestations` insert, the refund on rejection or post-DCAP failure, the pending-entry removal, the `promise_yield_resume` call — lives in the `.then` bridge `resolve_verification`. If it aborts mid-flight, the entire receipt rolls back atomically (including the resume), so the yield stays parked and the runtime's 200-block timeout still fires the yield-callback for cleanup — same recovery as "verifier never responded." `resolve_verification` resolves immediately on either answer it can act on: `Verified` (run post-DCAP, then resume) and `Rejected` (refund and resume with the reason). Only `Err(PromiseError::Failed)` — no verdict, the verifier was unreachable or crashed — is deliberately *not* resolved here: `resolve_verification` logs and returns early, routing that case to the timeout cleanup. The yield-callback `on_attestation_verified` is intentionally tiny: on resume, return the value to the caller; on its `Err(PromiseError::Failed)` branch — verifier unreachable or silent timeout — remove the pending entry and schedule a refund. +The refund and the failure live in different receipts deliberately. `fail_attestation_submission` is a tiny `#[private]` method that logs the reason and then `env::panic_str(&reason)` — its panic is what fails the submitter's transaction and surfaces the error. If that panic instead happened inside `resolve_verification` (the `#[handle_result]`-return-an-`Err` shape), it would roll back the whole callback receipt, discarding the refund transfer and any created promises along with it. Splitting them lets the refund commit in the first receipt while the second receipt fails the caller's transaction afterward. + +`verify_post_dcap_and_store` has its own commit-order subtlety. Unlike the synchronous `Mock` path — where returning an `Err` rolls back the entire method receipt and un-does any partial store — this callback receipt *commits regardless* of the `Err` it hands back to `resolve_verification`. So the store cannot be left to implicit rollback. `verify_post_dcap_and_store` snapshots `env::storage_usage()`, calls `verify_and_store_dstack`, then `charge_attestation_storage`; if the charge fails (`InsufficientDeposit`), it explicitly calls `tee_state.revert_dstack_store(tls_pk, insertion)` before returning the error, so the caller never gets storage for free plus a full refund. Walking every path the system can take: -- `resolve_verification` resumes (verifier returned `Verified` (post-DCAP pass or fail) or `Rejected`) → it cleaned up before resuming, caller receives the outcome. -- `resolve_verification` returns early (verifier unreachable — `Err(PromiseError::Failed)`) → timeout fires, yield-callback cleans up. -- `resolve_verification` aborts (OOG / panic mid-receipt) → timeout fires, yield-callback cleans up. -- `resolve_verification` never runs (verifier silent) → timeout fires, yield-callback cleans up. -- Yield-callback itself OOGs → genuine orphan. Its body is one `LookupMap::remove` plus one Promise schedule, small enough that an out-of-gas failure is implausible with a sensible gas budget. +- Verifier returned `Verified`, post-DCAP checks pass, storage charge succeeds → attestation stored, excess refunded, `Value(())`. Caller polls and sees the entry. +- Verifier returned `Verified` but a post-DCAP check fails → `verify_and_store_dstack` errors (nothing was stored), `resolve_verification` refunds and fires the fail receipt. +- Verifier returned `Verified`, post-DCAP passes, but the storage charge fails → `verify_post_dcap_and_store` reverts the store explicitly, returns the error, `resolve_verification` refunds and fires the fail receipt. +- Verifier returned `Rejected` → `QuoteRejected`, refund + fail receipt. +- Verifier unreachable / panicked / out of gas (`Err(PromiseError::Failed)`) → `VerifierUnavailable`, refund + fail receipt. This is handled right here, immediately; it is not deferred to any timeout. +- `resolve_verification` itself runs out of gas or panics mid-receipt → the whole callback receipt rolls back atomically, no partial commits, and the submitter's transaction fails. Because nothing was inserted at submit time, there is no orphaned state to reclaim. -This isn't a novel pattern in this codebase: `sign`, `request_app_private_key`, and `request_verify_foreign_tx` already use the same split. The heavy work and the `promise_yield_resume` call live in [`pending_requests::resolve_yields_for`][pending-requests-mod] (invoked from `respond`, `respond_ckd`, and `respond_verify_foreign_tx`), and [`return_signature_and_clean_state_on_success`][sign-yield-callback] is a tiny yield-callback that just routes the outcome back to the caller and handles the no-response timeout. The attestation flow inherits the same orphan-proof argument: heavy work in a safely-recoverable receipt, cleanup in a callback small enough that gas budgeting makes its failure implausible. +The verifier still returns its verdict as a *value* rather than a failed receipt, so `#[callback_result]` can tell a definitive `Rejected` apart from `Err(PromiseError::Failed)` (no answer). Under the no-yield design both still lead to an immediate fail-and-refund in `resolve_verification`; the distinction only changes the error type and message the caller sees (`QuoteRejected` with the reason vs `VerifierUnavailable`), not whether cleanup is immediate. ### Contract state changes -The callback runs in a later block than `submit_participant_info`, as an independent contract invocation. Anything the callback still needs must be stashed in contract storage, in a new field: +`resolve_verification` runs in a later block than `submit_participant_info`, as an independent contract invocation, so anything it needs from the original call must travel with it. That is done not through contract storage but through a borsh callback argument: ```rust -pending_attestations: LookupMap +pub struct VerificationContext { + pub(crate) node_id: NodeId, + pub(crate) attestation: DstackAttestation, +} ``` -This map mirrors the other pending-request maps in `mpc-contract` ([`pending_signature_requests`][pending-requests-mod], `pending_ckd_requests`, `pending_verify_foreign_tx_requests`), but stores a single `PendingAttestation` per `AccountId` rather than a `Vec`: attestation submissions are 1-per-account. +`VerificationContext` carries the submitter's `NodeId` (account id, TLS public key, and account public key — the binding the post-DCAP report-data check reproves) and the full `DstackAttestation` payload (RTMR3 event log, app-compose, report-data) that the post-DCAP checks consume. It is passed to `resolve_verification` as a `#[serializer(borsh)]` argument and is never written to contract state. The attached deposit is *not* part of it — it rides forward on the promise via `.with_attached_deposit`, so `env::attached_deposit()` in the callback yields the submitter's deposit directly. -Each [`PendingAttestation`](#mpc-contractsubmit_participant_info) holds: +This design adds **no** new attestation-related state. There is no `pending_attestations` map, no `PendingAttestation` struct, no `AttestationResult` enum, and no stashed `data_id` or deposit. The only new fields on the contract are `tee_verifier_account_id` and `tee_verifier_votes`, both from [§Voting on the trusted verifier](#voting-on-the-trusted-verifier-in-mpc-contract). -- **The submitter's `Attestation::Dstack` payload** — the RTMR3 event log, app-compose, and report-data the post-DCAP checks consume. -- **The submitter's TLS public key** — the callback hashes it with the submitter's account public key and compares to the quote's `report_data` field, proving the enclave produced the quote for this specific submitter. -- **The attached deposit** — covers storage staking on success, refunded to the signer of the original `submit_participant_info` transaction on failure. `env::attached_deposit()` is not visible from the callback receipt, so the value is stashed at submit time and the recipient `AccountId` is the same one used to key the entry (set by `Self::assert_caller_is_signer()`). -- **`data_id: CryptoHash`** — the yield handle returned by [`env::promise_yield_create`][promise-yield-create]. The intermediate `.then` callback (`resolve_verification`) reads this back to call [`env::promise_yield_resume`][promise-yield-resume] with a `FinalOutcome` after the post-DCAP checks have run. - -Entries are removed by `resolve_verification` on every branch where the verifier returned an answer — `Verified` (post-DCAP success or failure) and `Rejected`; only when the verifier gave no verdict (`Err(PromiseError::Failed)` — unreachable or crashed) does it return early and leave the entry in place. `on_attestation_verified` then removes the entry on its `Err(PromiseError::Failed)` branch, which covers that unreachable case and the no-response timeout. - -Notably absent from `PendingAttestation`: the **post-DCAP policy state** — allowed MPC image hashes, allowed launcher compose hashes, and accepted measurements. `resolve_verification` re-reads all of them from contract state, so any governance vote that adds or removes an entry mid-flight applies to verifications it overlaps. Snapshotting at request time would freeze each submission against stale policy — wrong default for a security control, where removing a compromised hash should take effect immediately. +Notably, the **post-DCAP policy state** — allowed MPC image hashes, allowed launcher compose hashes, and accepted measurements — is not snapshotted either. `verify_post_dcap_and_store` reads all of it fresh from contract state when the callback runs, so any governance vote that adds or removes an entry mid-flight applies to verifications it overlaps. Snapshotting at request time would freeze each submission against stale policy — wrong default for a security control, where removing a compromised hash should take effect immediately. ```mermaid sequenceDiagram @@ -154,8 +155,6 @@ sequenceDiagram participant Ver as tee-verifier Op->>MPC: submit_participant_info(Dstack, tls_pk) - MPC->>MPC: promise_yield_create → data_id - MPC->>MPC: insert PendingAttestation { data_id, ... } MPC->>Ver: Promise: verify_quote(...) (.then resolve_verification) Gov->>MPC: vote_add_image_hash(H) @@ -163,10 +162,8 @@ sequenceDiagram Ver-->>MPC: VerificationResult::Verified(report) MPC->>MPC: resolve_verification - MPC->>MPC: read allowlist (sees H) - MPC->>MPC: finish_verify against fresh allowlist - MPC->>MPC: promise_yield_resume(data_id, FinalOutcome) - MPC->>MPC: on_attestation_verified (trivial: return value) + MPC->>MPC: verify_post_dcap_and_store reads allowlist fresh (sees H) + MPC->>MPC: store on pass / refund + fail receipt on error ``` ## Crate layout @@ -281,13 +278,13 @@ pub enum VerificationResult { #### Why a rejection is a value, not a failed receipt -Encoding the rejection the obvious way — `Result` via `#[handle_result]`, so a rejection fails the receipt — loses the reason. NEAR's promise result is either `Successful(bytes)` or `Failed`, and **`Failed` carries no payload**: the caller's `#[callback_result]` sees a bare `Err(PromiseError::Failed)`, *identical* to the verifier being unreachable or out of gas. But `mpc-contract` must treat "the verifier rejected this quote" (definitive — refund and finish now) differently from "the verifier did not answer" (transient — wait for the yield timeout, the node resubmits). Returning the outcome as a *value* keeps them distinct, so `#[callback_result]` observes: +Encoding the rejection the obvious way — `Result` via `#[handle_result]`, so a rejection fails the receipt — loses the reason. NEAR's promise result is either `Successful(bytes)` or `Failed`, and **`Failed` carries no payload**: the caller's `#[callback_result]` sees a bare `Err(PromiseError::Failed)`, *identical* to the verifier being unreachable or out of gas. `mpc-contract` still wants to tell "the verifier rejected this quote" apart from "the verifier did not answer", so it can report the right error to the caller. Returning the outcome as a *value* keeps them distinct, so `#[callback_result]` observes: - `Ok(VerificationResult::Verified(report))` — quote valid; run post-DCAP checks. -- `Ok(VerificationResult::Rejected(reason))` — rejected; refund and resume **immediately**, with the reason. -- `Err(PromiseError::Failed)` — unreachable / panicked / timed out; the yield timeout cleans up. +- `Ok(VerificationResult::Rejected(reason))` — rejected; `resolve_verification` returns `QuoteRejected { reason }`. +- `Err(PromiseError::Failed)` — unreachable / panicked / out of gas; `resolve_verification` returns `VerifierUnavailable`. -This is the NEP-141 / NEP-171 shape — the meaningful outcome rides in the success payload, a failed receipt is reserved for "something broke" — and it preserves `mpc-contract`'s existing invariant that a rejection and a non-answer are never the same event. (near-sdk also refuses to serialize a bare `Result` return, so `VerificationResult` is a dedicated sum type.) +This is the NEP-141 / NEP-171 shape — the meaningful outcome rides in the success payload, a failed receipt is reserved for "something broke". Under the no-yield design both error branches lead to the same immediate refund-and-fail; keeping them distinct only changes the error type and message the caller receives. (near-sdk also refuses to serialize a bare `Result` return, so `VerificationResult` is a dedicated sum type.) ### Voting on the trusted verifier in `mpc-contract` @@ -301,7 +298,7 @@ The proposal payload is the pair `(candidate_account_id, expected_code_hash)`. ` #[near(serializers = [borsh])] pub struct VerifierChangeProposal { pub candidate_account_id: AccountId, - pub expected_code_hash: CryptoHash, + pub expected_code_hash: TeeVerifierCodeHash, } impl ProposalHashEncoding for VerifierChangeProposal { @@ -322,7 +319,7 @@ impl MpcContract { pub fn vote_tee_verifier_change( &mut self, candidate_account_id: AccountId, - expected_code_hash: CryptoHash, + expected_code_hash: TeeVerifierCodeHash, ); /// Withdraw the caller's current vote on any pending verifier-change @@ -337,17 +334,20 @@ The contract gains two new state fields: pub struct MpcContract { // ... existing fields ... - /// The locked account `mpc-contract` currently trusts as the verifier. - /// `submit_participant_info` calls `verify_quote` on this account. - /// Mutated only by the threshold-crossing vote above; the mutation - /// re-routes future submissions and does not touch already-stored - /// attestations. - tee_verifier_account_id: AccountId, + /// The locked account `mpc-contract` currently trusts as the verifier, or + /// `None` until participants vote one in (a `Dstack` `submit_participant_info` + /// is then rejected with `VerifierNotConfigured`). `submit_participant_info` + /// calls `verify_quote` on this account. Mutated only by the threshold-crossing + /// vote above; the mutation re-routes future submissions and does not touch + /// already-stored attestations. (Making this non-`Option` once a verifier is + /// voted in is the follow-up #3639.) + tee_verifier_account_id: Option, /// Pending votes for changing `tee_verifier_account_id`. Each voter is an /// active MPC participant; each proposal is hashed from - /// `(candidate_account_id, expected_code_hash)`. - tee_verifier_votes: Votes, + /// `(candidate_account_id, expected_code_hash)`. `TeeVerifierVotes` is a thin + /// newtype wrapping the generic `Votes`. + tee_verifier_votes: TeeVerifierVotes, } ``` @@ -371,7 +371,7 @@ sequenceDiagram Note over MPC: tee_verifier_account_id = new (routing only,
no eviction) VerOld-->>MPC: VerificationResult::Verified(report) - MPC->>MPC: resolve_verification (post-DCAP + insert, as usual) + MPC->>MPC: resolve_verification (post-DCAP + store, as usual) Note over MPC: stored entry ages out within the
expiration window via re_verify Op->>MPC: submit_participant_info(Dstack, tls_pk) (next hourly resubmit) @@ -381,235 +381,186 @@ sequenceDiagram ### `mpc-contract::submit_participant_info` -The method splits across three receipts joined by yield-resume — see [§Submission flow](#submission-flow) above for the architecture. The return type is [`PromiseOrValue<()>`](https://docs.rs/near-sdk/5.26.1/near_sdk/enum.PromiseOrValue.html), `near-sdk`'s "sometimes synchronous, sometimes a Promise chain" type: `Mock` attestations return `Value(())` immediately, and `Dstack` attestations return the yielded `Promise` from [`env::promise_yield_create`][promise-yield-create], which the runtime resolves either when `resolve_verification` calls [`env::promise_yield_resume`][promise-yield-resume] or after ~200 blocks of silence. The post-DCAP checks and the `stored_attestations` insert live in `resolve_verification`, not in the yield-callback — that's what keeps `on_attestation_verified` trivial enough to match the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]. Draft implementation: +The method resolves a Dstack submission through a two-receipt promise chain: `verify_quote` on the verifier, then `resolve_verification` as its callback — see [§Submission flow](#submission-flow) above for the architecture. It returns `Result, Error>`. A `Mock` attestation is verified and stored synchronously and returns `Ok(PromiseOrValue::Value(()))`. A `Dstack` attestation is delegated to `submit_dstack_attestation`, which builds the chain and returns `Ok(PromiseOrValue::Promise(...))`. The attached deposit is forwarded onto `resolve_verification` via `.with_attached_deposit`, so the callback can charge storage or refund without any state being stashed at submit time. There is no `pending_attestations` insert and no "one in-flight per account" guard. Draft implementation: ```rust impl MpcContract { + #[payable] + #[handle_result] pub fn submit_participant_info( &mut self, attestation: Attestation, - tls_pk: Ed25519PublicKey, - ) -> PromiseOrValue<()> { + tls_public_key: Ed25519PublicKey, + ) -> Result, Error> { // Existing convention: caller must be the signer of this transaction, // not a relayer or proxy. let account_id = Self::assert_caller_is_signer(); + let node_id = NodeId { account_id, tls_public_key, /* account_public_key */ }; + match attestation { - // Unchanged from today. + // Synchronous: no DCAP, verified and stored in this call. A + // returned Err here rolls back the whole receipt. Attestation::Mock(mock) => { - self.verify_mock_synchronously(mock, tls_pk); - PromiseOrValue::Value(()) - } - // Dstack: yield-resume. - Attestation::Dstack(dstack) => { - // One in-flight verification per AccountId. A duplicate submit - // before the previous one finishes (verifier response or - // runtime timeout) is rejected outright — same shape as - // duplicate sign requests. - if self.pending_attestations.contains_key(&account_id) { - env::panic_str("verification already pending"); - } - - let (quote, collateral) = extract_dcap_inputs(&dstack); - let attached_deposit = env::attached_deposit(); - - // Reuses the existing `enqueue_yield_request` helper that - // wraps `env::promise_yield_create`. The helper allocates - // `data_id`, registers `on_attestation_verified` as the - // yield-callback, and surfaces `data_id` via the `insert` - // closure so we can stash it together with the rest of the - // `PendingAttestation` fields. - self.enqueue_yield_request( - "on_attestation_verified", - borsh::to_vec(&account_id).unwrap(), - Gas::from_tgas(YIELD_CALLBACK_GAS_TGAS), - |this, data_id| { - this.pending_attestations.insert( - account_id.clone(), - PendingAttestation { - dstack, - tls_pk, - attached_deposit, - data_id, - }, - ); - }, - ); - - // Cross-contract call to the verifier. Its `.then` callback - // (`resolve_verification`) is the bridge that turns the - // verifier's response into a `promise_yield_resume` on the - // yield this method registered above. - Promise::new(self.tee_verifier_account_id.clone()) - .function_call( - "verify_quote".into(), - borsh::to_vec(&(quote, collateral)).unwrap(), - NearToken::from_yoctonear(0), - Gas::from_tgas(VERIFIER_GAS_TGAS), - ) - .then( - Self::ext(env::current_account_id()) - .with_static_gas(Gas::from_tgas(RESOLVE_GAS_TGAS)) - .resolve_verification(account_id), - ); - - // The yield handle was returned by `enqueue_yield_request` - // via `env::promise_return`, so the caller's `Promise` - // resolves with whatever the yield-callback returns. - PromiseOrValue::Value(()) + let initial_storage = env::storage_usage(); + self.tee_state.verify_and_store_mock(node_id, mock, ...)?; + self.charge_attestation_storage(&node_id.account_id, initial_storage)?; + Ok(PromiseOrValue::Value(())) } + // Dstack: async via the verifier promise chain. + Attestation::Dstack(attestation) => Ok(PromiseOrValue::Promise( + self.submit_dstack_attestation(node_id, attestation)?, + )), } } - /// `.then` bridge between the verifier's cross-contract call and the - /// yield this submission registered. Owns every outcome where the verifier - /// *answered* (`Ok(VerificationResult::{Verified,Rejected})`): on - /// `Verified` it runs the post-DCAP checks against fresh policy state and - /// inserts into `stored_attestations` on success; on `Rejected` it skips - /// straight to the refund. Either way it removes the pending entry, - /// schedules a refund where the outcome is an error, and calls - /// `promise_yield_resume(data_id, FinalOutcome)` as the LAST step of the - /// receipt — so a rejected quote is resolved *immediately*, not at the - /// timeout. Only `Err(PromiseError::Failed)` (no verdict — verifier - /// unreachable or crashed) is logged and returned early WITHOUT resuming or - /// removing the pending entry; `on_attestation_verified` owns that cleanup - /// on its `Err(PromiseError::Failed)` branch, so we must not race the - /// timeout for it. State mutations in this receipt are visible to the - /// yield-callback that fires next; if any line below `promise_yield_resume` - /// panicked or OOG'd, the entire receipt would roll back atomically (no - /// partial state commits) and the runtime's ~200-block yield-timeout would - /// still fire `on_attestation_verified` with `Err(PromiseError::Failed)` - /// for cleanup. + /// Builds the verifier promise chain. Fails the submit transaction + /// synchronously with `VerifierNotConfigured` if no verifier has been + /// voted in — there is no account to call `verify_quote` on. Otherwise it + /// calls `verify_quote` on the trusted verifier and chains + /// `resolve_verification` as its `.then` callback, forwarding the attached + /// deposit onto that callback. Quote/collateral are serialized by + /// reference so `attestation` can move into the `VerificationContext`. + fn submit_dstack_attestation( + &mut self, + node_id: NodeId, + attestation: DstackAttestation, + ) -> Result { + let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { + return Err(TeeError::VerifierNotConfigured.into()); + }; + + Ok(Promise::new(verifier_account_id) + .function_call( + "verify_quote".into(), + borsh::to_vec(&(&attestation.quote, &attestation.collateral)).unwrap(), + NearToken::from_yoctonear(0), + Gas::from_tgas(self.config.verifier_tera_gas), + ) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(Gas::from_tgas(self.config.resolve_verification_tera_gas)) + .with_attached_deposit(env::attached_deposit()) + .resolve_verification(VerificationContext { node_id, attestation }), + )) + } + + /// Verify-quote callback. `#[payable]` because the submitter's deposit + /// rides forward via `.with_attached_deposit`, so `env::attached_deposit()` + /// here is the amount they attached. `#[callback_result]` distinguishes the + /// three verifier outcomes: + /// + /// - `Ok(Verified)` → run post-DCAP checks and store. + /// - `Ok(Rejected)` → `QuoteRejected { reason }`. + /// - `Err(_)` → `VerifierUnavailable` (unreachable / panicked / OOG). /// - /// Same architectural shape as [`pending_requests::resolve_yields_for`][pending-requests-mod] - /// in the sign-request flow: the response-side function owns the state - /// mutation and the `promise_yield_resume` call; the yield-callback is - /// kept trivial. + /// On success returns `Value(())` (attestation stored, storage charged, + /// excess refunded). On any error it refunds the WHOLE attached deposit in + /// this receipt, then fires a SEPARATE `fail_attestation_submission` + /// receipt whose panic fails the caller's transaction — the split is what + /// lets the refund commit, since a panic in this receipt would roll it back + /// (and drop the created promises) along with the refund. #[private] + #[payable] pub fn resolve_verification( &mut self, - account_id: AccountId, - #[callback_result] result: Result, - ) { - let final_outcome = match result { - // No verdict: the verifier was unreachable, panicked, or ran out of - // gas. Do nothing — the runtime's yield-timeout will fire - // `on_attestation_verified` with `Err(PromiseError::Failed)` and - // clean up the pending entry there. We must not call - // `promise_yield_resume` here, or we'd race the timeout for - // ownership of the cleanup path. - Err(promise_err) => { - log!("verifier did not answer for {account_id}: {promise_err:?}"); - return; + #[serializer(borsh)] context: VerificationContext, + #[serializer(borsh)] + #[callback_result] + result: Result, + ) -> PromiseOrValue<()> { + let account_id = context.node_id.account_id.clone(); + + let attestation_result = match result { + Ok(VerificationResult::Verified(report)) => { + self.verify_post_dcap_and_store(&context, &report) } - // The verifier ran and rejected the quote. A definitive verdict: - // refund and resume now, with the reason, rather than waiting for - // the timeout. Ok(VerificationResult::Rejected(reason)) => { log!("verifier rejected quote for {account_id}: {reason}"); - FinalOutcome::Err(format!("verifier: {reason}")) + Err(TeeError::QuoteRejected { reason: reason.to_string() }.into()) } - Ok(VerificationResult::Verified(report)) => { - let pending = self.pending_attestations.get(&account_id).expect( - "PendingAttestation must exist while resolve_verification holds the yield", - ); - // Post-DCAP checks operate on the verified report plus state held - // here. The allowlist is read fresh — governance votes mid-flight - // take effect. - match finish_verify(pending, &report, self.allowlist_fresh()) { - Ok(()) => { - self.tee_state.stored_attestations.insert( - pending.tls_pk.clone(), - VerifiedAttestation::from((pending.clone(), report)), - ); - FinalOutcome::Ok - } - Err(reason) => { - log!("post-DCAP check failed for {account_id}: {reason}"); - FinalOutcome::Err(format!("post-DCAP: {reason}")) - } - } + Err(promise_err) => { + log!("verifier did not answer for {account_id}: {promise_err:?}"); + Err(TeeError::VerifierUnavailable.into()) } }; - let pending = self - .pending_attestations - .remove(&account_id) - .expect("PendingAttestation must exist while resolve_verification holds the yield"); - if matches!(final_outcome, FinalOutcome::Err(_)) { - refund_deposit(&account_id, pending.attached_deposit); + match attestation_result { + Ok(()) => PromiseOrValue::Value(()), + Err(err) => { + refund_to(&account_id, env::attached_deposit()); + let promise = Promise::new(env::current_account_id()).function_call( + "fail_attestation_submission".into(), + borsh::to_vec(&err.to_string()).unwrap(), + NearToken::from_yoctonear(0), + Gas::from_tgas(self.config.fail_attestation_submission_tera_gas), + ); + PromiseOrValue::Promise(promise.as_return()) + } } - // `promise_yield_resume` must be the LAST host call in this receipt: - // anything after it could panic and roll back the state mutations above. - env::promise_yield_resume(&pending.data_id, borsh::to_vec(&final_outcome).unwrap()); } - /// Yield-callback. Same shape as the sign-request callback - /// [`return_signature_and_clean_state_on_success`][sign-yield-callback]: the - /// `Verified` and `Rejected` outcomes (every case where the verifier - /// answered) were already finalized by `resolve_verification` (which removed - /// the pending entry and scheduled any refund before calling - /// `promise_yield_resume`), so this body just returns the outcome to the - /// caller. - /// - /// The only branch that does real work is `Err(PromiseError::Failed)`, fired - /// by the runtime ~200 blocks after submit if no `promise_yield_resume` has - /// landed: the verifier was unreachable / never responded so - /// `resolve_verification` deliberately returned early, or it ran but rolled - /// back (OOM / panic). On that branch the pending entry is still present, so - /// it removes the entry and schedules a deposit refund. - #[private] - pub fn on_attestation_verified( + /// Runs the post-DCAP checks and stores the attestation for a `Verified` + /// response. The callback receipt commits regardless of the `Err` returned, + /// so a failed storage charge cannot rely on implicit rollback: it reverts + /// the store explicitly, or the caller would get storage for free plus a + /// full refund. + fn verify_post_dcap_and_store( &mut self, - account_id: AccountId, - #[callback_result] result: Result, - ) -> Result<(), String> { - match result { - Ok(FinalOutcome::Ok) => Ok(()), - Ok(FinalOutcome::Err(reason)) => Err(reason), - Err(_promise_err) => { - if let Some(pending) = self.pending_attestations.remove(&account_id) { - refund_deposit(&account_id, pending.attached_deposit); - log!("yield timeout for {account_id}: refunded and cleaned up"); - } - Err("verifier did not respond within yield-resume window".to_string()) + context: &VerificationContext, + report: &VerifiedReport, + ) -> Result<(), Error> { + let account_id = &context.node_id.account_id; + let initial_storage = env::storage_usage(); + let insertion = self.tee_state.verify_and_store_dstack( + context.node_id.clone(), + &context.attestation, + report, + /* tee_upgrade_deadline_duration */ + )?; + + match self.charge_attestation_storage(account_id, initial_storage) { + Ok(()) => Ok(()), + Err(err) => { + self.tee_state + .revert_dstack_store(&context.node_id.tls_public_key, insertion); + Err(err) } } } -} -#[derive(BorshSerialize, BorshDeserialize)] -pub enum FinalOutcome { - Ok, - Err(String), + /// Separate receipt whose panic fails the caller's transaction after the + /// refund in `resolve_verification` has committed. + #[private] + pub fn fail_attestation_submission(#[serializer(borsh)] reason: String) { + log!("fail_attestation_submission: {reason}"); + env::panic_str(&reason); + } } ``` -`VERIFIER_GAS_TGAS`, `RESOLVE_GAS_TGAS`, and `YIELD_CALLBACK_GAS_TGAS` are placeholders until benchmarked. The verifier-side cost is dominated by ECDSA verifications and X.509-chain walking inside `dcap_qvl::verify::verify`. The bulk of the contract-side post-DCAP work — RTMR3 replay, app-compose validation, allowlist matching, report-data binding, plus the `stored_attestations.insert` — runs inside `resolve_verification`, so `RESOLVE_GAS_TGAS` gets the largest budget. `YIELD_CALLBACK_GAS_TGAS` can be conservatively small (on the order of 10 TGas with comfortable headroom): the yield-callback only does a `LookupMap::remove` and schedules a `Promise` on the timeout branch, and just returns a value on the resume branch. +`charge_attestation_storage` reads `env::attached_deposit()` itself: if the attached amount is less than the measured storage cost it returns `InsufficientDeposit`; otherwise it refunds the excess to the account via `refund_to`. `refund_to` is the generic refund helper (a detached `transfer` promise, no-op on zero). -The contract gains the following state fields: +`verifier_tera_gas`, `resolve_verification_tera_gas`, and `fail_attestation_submission_tera_gas` are unbenchmarked estimates until measured. The verifier-side cost (`verifier_tera_gas`) is dominated by ECDSA verifications and X.509-chain walking inside `dcap_qvl::verify::verify`, so it gets the largest budget. `resolve_verification_tera_gas` covers the contract-side post-DCAP work — RTMR3 replay, app-compose validation, allowlist matching, report-data binding — plus the `verify_and_store_dstack` insert and the storage charge. `fail_attestation_submission_tera_gas` can be tiny (a couple of TGas): the method only logs and panics. -```rust -pub struct MpcContract { - // ... existing fields, including tee_verifier_account_id and - // tee_verifier_votes from §Voting on the trusted verifier ... - pending_attestations: LookupMap, -} +### Contract state changes summary -pub struct PendingAttestation { - pub dstack: DstackAttestation, - pub tls_pk: Ed25519PublicKey, - pub attached_deposit: NearToken, - pub data_id: CryptoHash, -} -``` +No new attestation state fields. The chain carries a `VerificationContext { node_id, attestation }` as a borsh callback argument; nothing new is written to storage. The only new fields on the contract are `tee_verifier_account_id` and `tee_verifier_votes` from [§Voting on the trusted verifier](#voting-on-the-trusted-verifier-in-mpc-contract). ## Testing -The yield-resume split adds four resolution branches the synchronous version never had. Three do their work in `resolve_verification`, each from a distinct verifier answer: `Verified` + post-DCAP pass (store + resume `Ok`), `Verified` + post-DCAP fail (refund + resume `Err`), and `Rejected` (refund + resume `Err`, immediately — the path that recovers the synchronous-rejection behavior the split would otherwise lose). The fourth lives in `on_attestation_verified`, on its `Err(PromiseError::Failed)` branch, reached when the verifier gave no verdict — unreachable, panicked, or no resume landed within ~200 blocks (verifier silent, or a `resolve_verification` receipt that rolled back). That no-verdict case re-enters `resolve_verification`, which logs and returns early without resuming, so its cleanup happens in `on_attestation_verified`. Each branch needs test coverage, and exercising them requires the verifier to return specific answers on demand — a `Verified` or `Rejected` value for the three `resolve_verification` branches, and for the no-verdict path either an unreachable account or the test driver advancing the chain past the yield-resume window without resuming. +The no-yield chain adds a handful of resolution branches the synchronous version never had, all inside `resolve_verification` and the helper it delegates to: + +- **Verifier not configured** — `Dstack` submit while `tee_verifier_account_id` is `None` fails *synchronously* with `VerifierNotConfigured`; the submit transaction itself errors, no promise is scheduled. +- **Verified + store happy path** — `verify_quote` returns `Verified`, post-DCAP passes, storage charged, excess refunded, `Value(())`. The attestation is present in state afterward. +- **Verified + post-DCAP fail** — `verify_and_store_dstack` errors; `resolve_verification` refunds the whole deposit and fires the `fail_attestation_submission` receipt; nothing is stored. +- **Verified + insufficient deposit** — post-DCAP passes but `charge_attestation_storage` returns `InsufficientDeposit`; `verify_post_dcap_and_store` reverts the store explicitly, so state is unchanged; refund + fail receipt. +- **Rejected → fail + refund** — `verify_quote` returns `Rejected`; `resolve_verification` returns `QuoteRejected` carrying the reason; refund + fail receipt. +- **Verifier unreachable → `VerifierUnavailable`** — the callback observes `Err(PromiseError::Failed)`; refund + fail receipt. +- **OOG in `resolve_verification` rolls back atomically** — an out-of-gas or panic mid-callback rolls back the whole receipt (no partial store, no partial refund) and fails the caller's transaction; because nothing was inserted at submit time, there is no orphaned state to reclaim. The verifier-rotation design changes the test surface in three ways. First, the expiration window itself: an entry whose `expiry_timestamp_seconds` is in the past must be rejected by `re_verify` even when every post-DCAP allowlist invariant still holds, and an entry within the (shortened) window must still pass — this is the existing expiry check, now exercised against the lowered `DEFAULT_EXPIRATION_DURATION_SECONDS`. Second, rotation routing: after `vote_tee_verifier_change` crosses threshold, the next `submit_participant_info` must call `verify_quote` on the new `tee_verifier_account_id`, and existing stored entries must remain present (no purge) until they expire. Third, the in-flight case: a verification scheduled against the old verifier that resolves after the vote crosses threshold must still be stored as a normal entry — it is not treated specially and ages out via the same expiration window as any other entry. -To make that practical, we introduce a stub `tee-verifier` crate: same `tee-verifier-interface` DTOs as the real verifier, but `verify_quote` returns whatever `VerificationResult` (`Verified` or `Rejected`) the test asks for — and a stub that panics, or an undeployed account, covers the no-verdict path. Sandbox tests deploy the stub like any other verifier candidate — lock its account, then call `vote_tee_verifier_change` from the test setup to point `mpc-contract` at the stub. This runs the same code path as production; nothing in `mpc-contract` knows or cares whether it's talking to the real verifier or the stub. +To make that practical, we introduce a stub `tee-verifier` crate: same `tee-verifier-interface` DTOs as the real verifier, but `verify_quote` returns whatever `VerificationResult` (`Verified` or `Rejected`) the test asks for — and a stub that panics, or an undeployed account, covers the `VerifierUnavailable` path. Sandbox tests deploy the stub like any other verifier candidate — lock its account, then call `vote_tee_verifier_change` from the test setup to point `mpc-contract` at the stub. This runs the same code path as production; nothing in `mpc-contract` knows or cares whether it's talking to the real verifier or the stub. E2E tests in `crates/e2e-tests` deploy either the real `tee-verifier` (when the test wants real `dcap-qvl` against a fixture quote) or the stub (for everything else). The change is one extra `deploy` call in the setup helper. @@ -618,17 +569,9 @@ E2E tests in `crates/e2e-tests` deploy either the real `tee-verifier` (when the [nep-509]: https://github.com/near/NEPs/blob/master/neps/nep-0509.md [re-verify]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/mpc-attestation/src/attestation.rs#L93 [periodic-attestation-submission]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/tee/remote_attestation.rs#L140 -[attestation-resubmission-interval]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/run.rs#L43 -[attestation-attempts-metric]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/metrics.rs#L364 [verify-tee]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1543 [clean-invalid-attestations]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1646 -[clean-foreign-chain-data]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1669 [monitor-attestation-removal]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/node/src/tee/remote_attestation.rs#L209 [submit-participant-info]: https://github.com/near/mpc/blob/efe49230bb66854c55bba080e7610e42f9221506/crates/contract/src/lib.rs#L754-L782 [launcher-pattern]: https://github.com/near/mpc/blob/efe49230bb66854c55bba080e7610e42f9221506/docs/tee-lifecycle.md#upgrade [slack-launcher-discussion]: https://nearone.slack.com/archives/C0B12RKBSAV/p1777897902903889 -[promise-yield-create]: https://docs.rs/near-sdk/5.26.1/near_sdk/env/fn.promise_yield_create.html -[promise-yield-resume]: https://docs.rs/near-sdk/5.26.1/near_sdk/env/fn.promise_yield_resume.html -[enqueue-yield-request]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L301-L323 -[pending-requests-mod]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/pending_requests.rs -[sign-yield-callback]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/contract/src/lib.rs#L1999-L2023 diff --git a/docs/localnet/tee-localnet.md b/docs/localnet/tee-localnet.md index 054a39042f..6d6b64a4a5 100644 --- a/docs/localnet/tee-localnet.md +++ b/docs/localnet/tee-localnet.md @@ -276,7 +276,7 @@ near transaction view-status network-config mpc-localnet ``` ``` -(ExecutionError("Smart contract panicked: Invalid TEE Remote Attestation.: TeeQuoteStatus is invalid: the allowed mpc image hashes list is empty" +(ExecutionError("Smart contract panicked: the submitted attestation failed verification, reason: Custom(\"the allowed mpc image hashes list is empty\")")) ``` ### Vote Commands diff --git a/docs/running-an-mpc-node-in-tdx-external-guide.md b/docs/running-an-mpc-node-in-tdx-external-guide.md index 3c455fc1f5..60c969e0ec 100644 --- a/docs/running-an-mpc-node-in-tdx-external-guide.md +++ b/docs/running-an-mpc-node-in-tdx-external-guide.md @@ -2062,8 +2062,7 @@ The error after `err=` is the NEAR runtime error. Common ones: If the transaction reaches execution and the contract panics, the node logs only the generic retry line above; the actual message lives in the transaction receipt. Find the tx on `https://testnet.nearblocks.io/address/` and open the failed `submit_participant_info` call — the error appears under the action's status / logs. The contract wraps the attestation-side error like this: ``` -Invalid TEE Remote Attestation: TeeQuoteStatus is invalid: - the submitted attestation failed verification, reason: Custom("...") +the submitted attestation failed verification, reason: Custom("...") ``` The `reason` is the same `VerificationError` the client-side WARN reports (see section 1) — for example `Custom("the allowed mpc image hashes list is empty")`. Errors that **only** surface on-chain (because they're checked against the contract's allowed-measurements list, the contract's deposit logic, or the contract's caller assertion):