From 3790000d5eed478b302c95350c8bd2a58f6be730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 23 Jul 2026 14:18:08 +0200 Subject: [PATCH 01/23] fix(contract): charge attestation storage by actual delta submit_participant_info kept the whole 0.1 NEAR deposit on success. Re-submissions that change no stored bytes overpaid, and the flat fee mis-charged when the stored attestation entry changed size. Charge only the measured storage delta (storage_byte_cost * bytes_grown) and refund the excess, keeping the up-front minimum-deposit floor. The floor covers the worst-case entry, so the charge can never exceed the deposit and the refund cannot underflow, which is why no insert-then-revert machinery is needed. store_verified_attestation now flushes so env::storage_usage() reflects the insert before the delta is measured. Closes #3857 --- crates/contract/src/lib.rs | 109 ++++++++++++------ crates/contract/src/tee/tee_state.rs | 30 +++++ crates/contract/tests/sandbox/tee.rs | 74 ++++++++---- .../tests/sandbox/utils/mpc_contract.rs | 16 +++ .../src/deposits.rs | 5 +- docs/design/attestation-verifier-contract.md | 2 +- 6 files changed, 176 insertions(+), 60 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 9a209527e7..6828936233 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -115,10 +115,9 @@ const MINIMUM_CKD_REQUEST_DEPOSIT: NearToken = NearToken::from_yoctonear(1); /// node key cannot invoke these methods. pub const MINIMUM_NODE_MANAGEMENT_DEPOSIT: NearToken = NearToken::from_yoctonear(1); -/// Flat fee a node attaches to [`MpcContract::submit_participant_info`] for its -/// stored attestation entry. The entry is bounded, so the fee is fixed and -/// nothing is refunded; its margin over the true cost absorbs storage-price and -/// layout changes. A unit test asserts it covers the worst-case entry. +/// Minimum a node must attach to [`MpcContract::submit_participant_info`], +/// sized to cover the worst-case stored entry. Only the actual storage delta is +/// kept; the excess is refunded. const MINIMUM_ATTESTATION_STORAGE_DEPOSIT: NearToken = NearToken::from_millinear(100); /// Entries to scan in the post-reshare `clean_invalid_attestations` sweep. External @@ -160,6 +159,19 @@ fn refund_to(account_id: &AccountId, amount: NearToken) { } } +/// Charges this submission's storage delta and refunds the excess. `initial_storage` +/// must be captured before the store has flushed (see +/// [`TeeState::store_verified_attestation`]); the [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`] +/// floor guarantees the refund never underflows. +fn keep_storage_delta_and_refund_rest(account_id: &AccountId, initial_storage: u64) { + // saturating_sub: a shrink charges nothing rather than underflowing. + let bytes_grown = env::storage_usage().saturating_sub(initial_storage); + let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); + if let Some(refund) = env::attached_deposit().checked_sub(cost) { + refund_to(account_id, refund); + } +} + impl Default for MpcContract { fn default() -> Self { env::panic_str("Calling default not allowed."); @@ -782,8 +794,11 @@ impl MpcContract { /// `verify_quote` call, with [`Self::resolve_verification`] chained as its /// callback to run the post-DCAP checks and store the attestation. /// - /// The caller must attach a flat 0.1 NEAR fee for the stored entry; the whole - /// fee is kept on success and refunded if the attestation is not accepted. + /// The caller must attach at least [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`], + /// enough to cover the worst-case stored entry. On success only the actual + /// storage delta is kept and the excess is refunded, so a re-submission that + /// changes no stored bytes is charged nothing. The full deposit is refunded if + /// the attestation is not accepted. #[payable] #[handle_result] pub fn submit_participant_info( @@ -833,11 +848,13 @@ impl MpcContract { 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, )?; + keep_storage_delta_and_refund_rest(&account_id, initial_storage); Ok(PromiseOrValue::Value(())) } Attestation::Dstack(attestation) => Ok(PromiseOrValue::Promise( @@ -2301,8 +2318,9 @@ impl MpcContract { } /// Verify-quote callback: on a verifier verdict it runs the post-DCAP - /// checks and stores the attestation, refunding the flat fee if the - /// attestation is not accepted. + /// checks and stores the attestation, keeping the storage delta and + /// refunding the excess. Refunds the full deposit if the attestation is not + /// accepted. #[private] #[payable] pub fn resolve_verification( @@ -2352,9 +2370,8 @@ impl MpcContract { } /// Runs the post-DCAP checks and stores the attestation for a - /// [`VerificationResult::Verified`] response. The deposit was already - /// checked against the flat fee in [`Self::submit_participant_info`], so this - /// only verifies and stores. + /// [`VerificationResult::Verified`] response, then keeps the storage delta + /// and refunds the excess deposit. fn verify_post_dcap_and_store( &mut self, context: &VerificationContext, @@ -2364,6 +2381,7 @@ impl MpcContract { let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); + let initial_storage = env::storage_usage(); if let Err(err) = self.tee_state.verify_and_store_dstack( context.node_id.clone(), &context.attestation, @@ -2374,6 +2392,7 @@ impl MpcContract { return Err(err.into()); } + keep_storage_delta_and_refund_rest(account_id, initial_storage); Ok(()) } @@ -8049,43 +8068,59 @@ mod tests { assert!(configs.contains_key(&tls_key_b), "node B config must exist"); } - // Catches only entry-size growth: fails if a schema change makes the stored entry - // cost more than the fee at today's storage_byte_cost. It cannot see a future - // storage_byte_cost increase on a live contract; the fee's margin covers that. + // Catches entry-size growth: fails if a schema change makes the largest storable entry + // cost more than the deposit at today's storage_byte_cost. It cannot see a future + // storage_byte_cost increase on a live contract; the deposit's margin covers that. #[test] fn minimum_attestation_storage_deposit__should_cover_worst_case_entry() { - // Given: the largest entry a submission can store. NEAR caps an account id - // at 64 bytes; every other field is fixed-size, so this is the worst case. testing_env!(VMContextBuilder::new().build()); + // NEAR caps an account id at 64 bytes; every other NodeId field is fixed-size. let node_id = create_node_id( &"a".repeat(64).parse().unwrap(), &bogus_ed25519_public_key(), ); - let worst_case = NodeAttestation { - node_id: node_id.clone(), - verified_attestation: VerifiedAttestation::Dstack(ValidatedDstackAttestation { - mpc_image_hash: [0xff; 32].into(), - launcher_compose_hash: [0xff; 32].into(), - expiry_timestamp_seconds: u64::MAX, - measurements: default_measurements()[0], - }), + let cost_of = |verified_attestation| { + let mut tee_state = TeeState::default(); + let before = env::storage_usage(); + tee_state.stored_attestations.insert( + node_id.tls_public_key.clone(), + NodeAttestation { + node_id: node_id.clone(), + verified_attestation, + }, + ); + tee_state.stored_attestations.flush(); + let bytes_grown = env::storage_usage() - before; + let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); + (bytes_grown, cost) }; - // When: the entry is inserted and flushed, so storage_usage reflects it. - let mut tee_state = TeeState::default(); - let storage_before = env::storage_usage(); - tee_state - .stored_attestations - .insert(node_id.tls_public_key.clone(), worst_case); - tee_state.stored_attestations.flush(); - let bytes_grown = env::storage_usage() - storage_before; - let worst_case_cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); + // Given: the largest entry each variant can store. The Mock arm is not + // feature-gated, so a caller can force either variant. + let dstack = VerifiedAttestation::Dstack(ValidatedDstackAttestation { + mpc_image_hash: [0xff; 32].into(), + launcher_compose_hash: [0xff; 32].into(), + expiry_timestamp_seconds: u64::MAX, + measurements: default_measurements()[0], + }); + let mock = VerifiedAttestation::Mock(MpcMockAttestation::WithConstraints { + mpc_docker_image_hash: Some([0xff; 32].into()), + launcher_docker_compose_hash: Some([0xff; 32].into()), + expiry_timestamp_seconds: Some(u64::MAX), + expected_measurements: Some(default_measurements()[0]), + }); + + // When + let (dstack_bytes, dstack_cost) = cost_of(dstack); + let (mock_bytes, mock_cost) = cost_of(mock); + let (worst_bytes, worst_cost) = + std::cmp::max((dstack_bytes, dstack_cost), (mock_bytes, mock_cost)); - // Then: the flat fee covers the worst-case cost with headroom to spare. + // Then: the minimum deposit covers the worst case with headroom to spare. assert!( - MINIMUM_ATTESTATION_STORAGE_DEPOSIT >= worst_case_cost, - "flat fee {MINIMUM_ATTESTATION_STORAGE_DEPOSIT} must cover the worst-case entry \ - ({bytes_grown} bytes, {worst_case_cost}) at today's storage price" + MINIMUM_ATTESTATION_STORAGE_DEPOSIT >= worst_cost, + "minimum deposit {MINIMUM_ATTESTATION_STORAGE_DEPOSIT} must cover the worst-case entry \ + ({worst_bytes} bytes, {worst_cost}) at today's storage price" ); } } diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 9d10d68f72..04fe34f257 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -211,6 +211,10 @@ impl TeeState { /// entry was newly inserted or updated an existing one. Rejects a submission whose /// TLS key is already registered to a different account with /// [`AttestationSubmissionError::TlsKeyOwnedByOtherAccount`]. + /// + /// Flushes the insert before returning, so a caller's subsequent + /// [`env::storage_usage`] reflects the stored bytes and can be used to charge + /// the storage delta. fn store_verified_attestation( &mut self, node_id: NodeId, @@ -236,6 +240,11 @@ impl TeeState { }, ); + // `IterableMap` defers writes to flush-on-Drop, so without an explicit + // flush the just-inserted entry reads as a zero storage delta and the + // caller would charge nothing for it. + self.stored_attestations.flush(); + Ok(match previous { Some(_) => ParticipantInsertion::UpdatedExistingParticipant, None => ParticipantInsertion::NewlyInsertedParticipant, @@ -846,6 +855,27 @@ mod tests { ); } + #[test] + fn verify_and_store_mock__should_flush_so_storage_usage_grows() { + // given + testing_env!(VMContextBuilder::new().build()); + let mut tee_state = TeeState::default(); + let node_id = node_id_for(&"alice.near".parse().unwrap()); + let storage_before = env::storage_usage(); + + // when + tee_state + .verify_and_store_mock(node_id, MockAttestation::Valid, Duration::from_secs(0)) + .unwrap(); + + // then: without the internal flush the deferred write reads as a zero delta. + let storage_after = env::storage_usage(); + assert!( + storage_after > storage_before, + "env::storage_usage() should grow after the store ({storage_before} -> {storage_after})" + ); + } + #[test] fn verify_and_store_mock__should_index_by_tls_key() { // given diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index 3255d4cbe8..9b4a3882e6 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -8,7 +8,8 @@ use crate::sandbox::{ mpc_contract::{ assert_running_return_participants, assert_running_return_threshold, get_participant_attestation, get_state, get_tee_accounts, submit_participant_info, - total_gas_fee, vote_add_launcher_hash, vote_for_hash, + submit_participant_info_and_measure_kept_deposit, vote_add_launcher_hash, + vote_for_hash, }, resharing_utils::conclude_resharing, sign_utils::DomainResponseTest, @@ -1031,13 +1032,12 @@ async fn submit_participant_info__should_reject_new_attestation_below_flat_fee() Ok(()) } -/// A submission attaching exactly the flat fee is stored, and the caller is -/// charged the whole fee with no excess refunded (the fee far exceeds the true -/// storage cost by design). +/// A first submission is stored, and only the actual storage delta is kept; the +/// excess over the true cost is refunded. #[tokio::test] -async fn submit_participant_info__should_store_new_attestation_and_charge_the_flat_fee() +async fn submit_participant_info__should_store_new_attestation_and_keep_only_the_storage_delta() -> Result<()> { - // Given + // given let SandboxTestSetup { worker, contract, .. } = SandboxTestSetup::builder() @@ -1046,10 +1046,9 @@ async fn submit_participant_info__should_store_new_attestation_and_charge_the_fl .await; let outsider = worker.dev_create_account().await?; let fresh_tls_key = bogus_ed25519_public_key(); - let balance_before = outsider.view_account().await?.balance; - // When - let result = submit_participant_info( + // when + let kept = submit_participant_info_and_measure_kept_deposit( &outsider, &contract, &Attestation::Mock(MockAttestation::Valid), @@ -1057,19 +1056,54 @@ async fn submit_participant_info__should_store_new_attestation_and_charge_the_fl ) .await?; - // Then - assert!( - result.is_success(), - "submission attaching the flat fee should succeed: {result:?}" - ); + // then let stored = get_participant_attestation(&contract, &fresh_tls_key).await?; + assert!(stored.is_some(), "the entry should be stored on-chain"); assert!( - stored.is_some(), - "the attestation entry should be stored on-chain" + kept > NearToken::from_yoctonear(0) && kept < SUBMIT_PARTICIPANT_INFO_DEPOSIT, + "a new entry keeps a nonzero storage delta below the full deposit, refunding the rest: \ + kept {kept}" + ); + Ok(()) +} + +/// Re-submitting an identical entry changes no stored bytes, so the second call +/// keeps nothing. +#[tokio::test] +async fn submit_participant_info__should_not_overcharge_identical_resubmission() -> 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 attestation = Attestation::Mock(MockAttestation::Valid); + let first_kept = submit_participant_info_and_measure_kept_deposit( + &outsider, + &contract, + &attestation, + &fresh_tls_key, + ) + .await?; + + // when + let resubmission_kept = submit_participant_info_and_measure_kept_deposit( + &outsider, + &contract, + &attestation, + &fresh_tls_key, + ) + .await?; + + // then + assert_eq!( + resubmission_kept, + NearToken::from_yoctonear(0), + "an identical re-submission stores no new bytes and must keep nothing: \ + first kept {first_kept}, resubmission kept {resubmission_kept}" ); - let balance_after = outsider.view_account().await?.balance; - let net_spent = balance_before.saturating_sub(balance_after); - let non_gas_spent = net_spent.saturating_sub(total_gas_fee(&result)); - assert_eq!(non_gas_spent, SUBMIT_PARTICIPANT_INFO_DEPOSIT); Ok(()) } diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index 2d01dda080..db3f0ca12e 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -65,6 +65,22 @@ pub async fn submit_participant_info( .map_err(Into::into) } +/// Submits an attestation and returns the non-gas amount the caller was left +/// out of pocket, i.e. the kept storage deposit after subtracting gas fees. +pub async fn submit_participant_info_and_measure_kept_deposit( + account: &Account, + contract: &Contract, + attestation: &Attestation, + tls_key: &Ed25519PublicKey, +) -> anyhow::Result { + let balance_before = account.view_account().await?.balance; + let result = submit_participant_info(account, contract, attestation, tls_key).await?; + assert!(result.is_success(), "submission should succeed: {result:?}"); + let balance_after = account.view_account().await?.balance; + let net_spent = balance_before.saturating_sub(balance_after); + Ok(net_spent.saturating_sub(total_gas_fee(&result))) +} + pub async fn vote_tee_verifier_change( account: &Account, contract: &Contract, diff --git a/crates/near-mpc-contract-interface/src/deposits.rs b/crates/near-mpc-contract-interface/src/deposits.rs index 51a1e91587..c4259df42b 100644 --- a/crates/near-mpc-contract-interface/src/deposits.rs +++ b/crates/near-mpc-contract-interface/src/deposits.rs @@ -1,6 +1,7 @@ //! Deposit amounts to attach to contract methods, in milli-NEAR. One shared //! value for node, tests, and e2e. -/// Deposit for `submit_participant_info`. The contract requires exactly this -/// flat fee to store the bounded attestation entry; nothing is refunded. +/// Deposit for `submit_participant_info`. Sized to cover the worst-case +/// attestation entry; the contract keeps only the actual storage delta and +/// refunds the rest. pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR: u128 = 100; diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 0346ce6c69..830366e630 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -139,7 +139,7 @@ Each [`PendingAttestation`](#mpc-contractsubmit_participant_info) holds: - **The submitter's `Attestation::Dstack` payload** — the RTMR3 event log, app-compose, and report-data the post-DCAP checks consume. - **The submitter's TLS public key** — the callback hashes it with the submitter's account public key and compares to the quote's `report_data` field, proving the enclave produced the quote for this specific submitter. -- **The attached deposit** — covers storage staking on success, refunded to the signer of the original `submit_participant_info` transaction on failure. `env::attached_deposit()` is not visible from the callback receipt, so the value is stashed at submit time and the recipient `AccountId` is the same one used to key the entry (set by `Self::assert_caller_is_signer()`). +- **The attached deposit** — on success the contract keeps only the actual storage delta of the stored entry and refunds the excess; on failure the full deposit is refunded to the signer of the original `submit_participant_info` transaction. `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. From c0886f07101a14bb01515244d4e22bbbf5c1b139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 23 Jul 2026 14:33:47 +0200 Subject: [PATCH 02/23] fix(contract): make attestation deposit const pub, address review Make MINIMUM_ATTESTATION_STORAGE_DEPOSIT pub so the public submit_participant_info doc can link to it (broken_intra_doc_links is denied), matching the sibling MINIMUM_NODE_MANAGEMENT_DEPOSIT. Sandbox measure-kept-deposit helper now asserts the balance decreased and covers gas, using plain subtraction so it fails loudly instead of masking an inverted balance. Log the unreachable cost-exceeds-deposit branch for observability. Correct the design-doc bullet: the deposit is forwarded onto the callback receipt, not stashed. --- crates/contract/src/lib.rs | 9 ++++++--- crates/contract/tests/sandbox/utils/mpc_contract.rs | 10 ++++++++-- docs/design/attestation-verifier-contract.md | 2 +- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 6828936233..bf54294b03 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -118,7 +118,7 @@ pub const MINIMUM_NODE_MANAGEMENT_DEPOSIT: NearToken = NearToken::from_yoctonear /// Minimum a node must attach to [`MpcContract::submit_participant_info`], /// sized to cover the worst-case stored entry. Only the actual storage delta is /// kept; the excess is refunded. -const MINIMUM_ATTESTATION_STORAGE_DEPOSIT: NearToken = NearToken::from_millinear(100); +pub const MINIMUM_ATTESTATION_STORAGE_DEPOSIT: NearToken = NearToken::from_millinear(100); /// Entries to scan in the post-reshare `clean_invalid_attestations` sweep. External /// callers may pick a different value; this only governs the automatic invocation. @@ -167,8 +167,11 @@ fn keep_storage_delta_and_refund_rest(account_id: &AccountId, initial_storage: u // saturating_sub: a shrink charges nothing rather than underflowing. let bytes_grown = env::storage_usage().saturating_sub(initial_storage); let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); - if let Some(refund) = env::attached_deposit().checked_sub(cost) { - refund_to(account_id, refund); + match env::attached_deposit().checked_sub(cost) { + Some(refund) => refund_to(account_id, refund), + // Unreachable given the MINIMUM_ATTESTATION_STORAGE_DEPOSIT floor covers the + // worst-case entry; log rather than fail so a drifted invariant is observable. + None => log!("attestation storage cost {cost} exceeded deposit for {account_id}"), } } diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index db3f0ca12e..babf9fe78e 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -77,8 +77,14 @@ pub async fn submit_participant_info_and_measure_kept_deposit( let result = submit_participant_info(account, contract, attestation, tls_key).await?; assert!(result.is_success(), "submission should succeed: {result:?}"); let balance_after = account.view_account().await?.balance; - let net_spent = balance_before.saturating_sub(balance_after); - Ok(net_spent.saturating_sub(total_gas_fee(&result))) + assert!( + balance_after < balance_before, + "caller balance must not increase: {balance_before} -> {balance_after}" + ); + let net_spent = balance_before - balance_after; + let gas = total_gas_fee(&result); + assert!(net_spent >= gas, "net spend {net_spent} must cover gas {gas}"); + Ok(net_spent - gas) } pub async fn vote_tee_verifier_change( diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 830366e630..363cc014ab 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -139,7 +139,7 @@ Each [`PendingAttestation`](#mpc-contractsubmit_participant_info) holds: - **The submitter's `Attestation::Dstack` payload** — the RTMR3 event log, app-compose, and report-data the post-DCAP checks consume. - **The submitter's TLS public key** — the callback hashes it with the submitter's account public key and compares to the quote's `report_data` field, proving the enclave produced the quote for this specific submitter. -- **The attached deposit** — on success the contract keeps only the actual storage delta of the stored entry and refunds the excess; on failure the full deposit is refunded to the signer of the original `submit_participant_info` transaction. `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()`). +- **The attached deposit** — on success the contract keeps only the actual storage delta of the stored entry and refunds the excess; on failure the full deposit is refunded to the signer of the original `submit_participant_info` transaction. The deposit is forwarded onto the callback receipt via `.with_attached_deposit(env::attached_deposit())` in `submit_dstack_attestation`, so `resolve_verification` sees it as its own `env::attached_deposit()`; the refund recipient is the submitter `AccountId` carried in the callback's `VerificationContext`. - **`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. From ba694a9130b73a5a4158b8580f842b7cf670294b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 23 Jul 2026 14:45:11 +0200 Subject: [PATCH 03/23] fix(contract): regenerate ABI snapshot, use fail-loud balance math in test helper The submit_participant_info doc edits changed the ABI doc strings; regenerate the snapshot. The sandbox measure-kept-deposit helper uses checked_sub with a panicking fallback so an inverted balance or gas exceeding spend fails loudly instead of saturating to zero. --- crates/contract/src/lib.rs | 4 ++-- .../contract/tests/sandbox/utils/mpc_contract.rs | 15 ++++++++------- .../tests/snapshots/abi__abi_has_not_changed.snap | 4 ++-- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index d40df3a1b0..5b6d04f2a7 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -169,8 +169,8 @@ fn keep_storage_delta_and_refund_rest(account_id: &AccountId, initial_storage: u let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); match env::attached_deposit().checked_sub(cost) { Some(refund) => refund_to(account_id, refund), - // Unreachable given the MINIMUM_ATTESTATION_STORAGE_DEPOSIT floor covers the - // worst-case entry; log rather than fail so a drifted invariant is observable. + // Unreachable given the MINIMUM_ATTESTATION_STORAGE_DEPOSIT floor; logged so a + // drifted invariant is observable rather than a silent storage subsidy. None => log!("attestation storage cost {cost} exceeded deposit for {account_id}"), } } diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index babf9fe78e..68cf67e6f1 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -77,14 +77,15 @@ pub async fn submit_participant_info_and_measure_kept_deposit( let result = submit_participant_info(account, contract, attestation, tls_key).await?; assert!(result.is_success(), "submission should succeed: {result:?}"); let balance_after = account.view_account().await?.balance; - assert!( - balance_after < balance_before, - "caller balance must not increase: {balance_before} -> {balance_after}" - ); - let net_spent = balance_before - balance_after; + let net_spent = balance_before + .checked_sub(balance_after) + .unwrap_or_else(|| { + panic!("caller balance must not increase: {balance_before} -> {balance_after}") + }); let gas = total_gas_fee(&result); - assert!(net_spent >= gas, "net spend {net_spent} must cover gas {gas}"); - Ok(net_spent - gas) + Ok(net_spent + .checked_sub(gas) + .unwrap_or_else(|| panic!("net spend {net_spent} must cover gas {gas}"))) } pub async fn vote_tee_verifier_change( diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 902e3c438f..31286e736e 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -1317,7 +1317,7 @@ expression: abi }, { "name": "resolve_verification", - "doc": " Verify-quote callback: on a verifier verdict it runs the post-DCAP\n checks and stores the attestation, refunding the flat fee if the\n attestation is not accepted.", + "doc": " Verify-quote callback: on a verifier verdict it runs the post-DCAP\n checks and stores the attestation, keeping the storage delta and\n refunding the excess. Refunds the full deposit if the attestation is not\n accepted.", "kind": "call", "modifiers": [ "payable", @@ -2342,7 +2342,7 @@ expression: abi }, { "name": "submit_participant_info", - "doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously via a cross-contract\n `verify_quote` call, with [`Self::resolve_verification`] chained as its\n callback to run the post-DCAP checks and store the attestation.\n\n The caller must attach a flat 0.1 NEAR fee for the stored entry; the whole\n fee is kept on success and refunded if the attestation is not accepted.", + "doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously via a cross-contract\n `verify_quote` call, with [`Self::resolve_verification`] chained as its\n callback to run the post-DCAP checks and store the attestation.\n\n The caller must attach at least [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`],\n enough to cover the worst-case stored entry. On success only the actual\n storage delta is kept and the excess is refunded, so a re-submission that\n changes no stored bytes is charged nothing. The full deposit is refunded if\n the attestation is not accepted.", "kind": "call", "modifiers": [ "payable" From 6b4cbeb3f96a9a08234d0c67e07208b9b48ac781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 23 Jul 2026 17:51:53 +0200 Subject: [PATCH 04/23] test(contract): parametrize worst-case storage test, tighten attestation-charge comments Convert minimum_attestation_storage_deposit__should_cover_worst_case_entry to an rstest parametrized over the Dstack and Mock attestation variants, so each reports as its own case. The per-variant assertion (deposit covers each variant's cost) is equivalent to the previous max-over-variants assertion. Also tighten the surrounding comments: drop the stale 'submission' framing on keep_storage_delta_and_refund_rest, name the test that pins the deposit floor on the unreachable None branch, and shorten the flush comment in store_verified_attestation. --- crates/contract/src/lib.rs | 81 ++++++++++++---------------- crates/contract/src/tee/tee_state.rs | 4 +- 2 files changed, 36 insertions(+), 49 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 5b6d04f2a7..add29fdeb9 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -159,18 +159,17 @@ fn refund_to(account_id: &AccountId, amount: NearToken) { } } -/// Charges this submission's storage delta and refunds the excess. `initial_storage` -/// must be captured before the store has flushed (see -/// [`TeeState::store_verified_attestation`]); the [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`] -/// floor guarantees the refund never underflows. +/// Charges the storage growth since `initial_storage` and refunds the rest. The +/// [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`] floor guarantees the refund never underflows. fn keep_storage_delta_and_refund_rest(account_id: &AccountId, initial_storage: u64) { // saturating_sub: a shrink charges nothing rather than underflowing. let bytes_grown = env::storage_usage().saturating_sub(initial_storage); let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); match env::attached_deposit().checked_sub(cost) { Some(refund) => refund_to(account_id, refund), - // Unreachable given the MINIMUM_ATTESTATION_STORAGE_DEPOSIT floor; logged so a - // drifted invariant is observable rather than a silent storage subsidy. + // Unreachable given the MINIMUM_ATTESTATION_STORAGE_DEPOSIT floor, which + // minimum_attestation_storage_deposit__should_cover_worst_case_entry pins to the + // worst-case entry cost. None => log!("attestation storage cost {cost} exceeded deposit for {account_id}"), } } @@ -8079,56 +8078,46 @@ mod tests { // Catches entry-size growth: fails if a schema change makes the largest storable entry // cost more than the deposit at today's storage_byte_cost. It cannot see a future // storage_byte_cost increase on a live contract; the deposit's margin covers that. - #[test] - fn minimum_attestation_storage_deposit__should_cover_worst_case_entry() { + #[rstest] + #[case::dstack(VerifiedAttestation::Dstack(ValidatedDstackAttestation { + mpc_image_hash: [0xff; 32].into(), + launcher_compose_hash: [0xff; 32].into(), + expiry_timestamp_seconds: u64::MAX, + measurements: default_measurements()[0], + }))] + #[case::mock(VerifiedAttestation::Mock(MpcMockAttestation::WithConstraints { + mpc_docker_image_hash: Some([0xff; 32].into()), + launcher_docker_compose_hash: Some([0xff; 32].into()), + expiry_timestamp_seconds: Some(u64::MAX), + expected_measurements: Some(default_measurements()[0]), + }))] + fn minimum_attestation_storage_deposit__should_cover_worst_case_entry( + #[case] verified_attestation: VerifiedAttestation, + ) { testing_env!(VMContextBuilder::new().build()); // NEAR caps an account id at 64 bytes; every other NodeId field is fixed-size. let node_id = create_node_id( &"a".repeat(64).parse().unwrap(), &bogus_ed25519_public_key(), ); - let cost_of = |verified_attestation| { - let mut tee_state = TeeState::default(); - let before = env::storage_usage(); - tee_state.stored_attestations.insert( - node_id.tls_public_key.clone(), - NodeAttestation { - node_id: node_id.clone(), - verified_attestation, - }, - ); - tee_state.stored_attestations.flush(); - let bytes_grown = env::storage_usage() - before; - let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); - (bytes_grown, cost) - }; - - // Given: the largest entry each variant can store. The Mock arm is not - // feature-gated, so a caller can force either variant. - let dstack = VerifiedAttestation::Dstack(ValidatedDstackAttestation { - mpc_image_hash: [0xff; 32].into(), - launcher_compose_hash: [0xff; 32].into(), - expiry_timestamp_seconds: u64::MAX, - measurements: default_measurements()[0], - }); - let mock = VerifiedAttestation::Mock(MpcMockAttestation::WithConstraints { - mpc_docker_image_hash: Some([0xff; 32].into()), - launcher_docker_compose_hash: Some([0xff; 32].into()), - expiry_timestamp_seconds: Some(u64::MAX), - expected_measurements: Some(default_measurements()[0]), - }); - // When - let (dstack_bytes, dstack_cost) = cost_of(dstack); - let (mock_bytes, mock_cost) = cost_of(mock); - let (worst_bytes, worst_cost) = - std::cmp::max((dstack_bytes, dstack_cost), (mock_bytes, mock_cost)); + let mut tee_state = TeeState::default(); + let before = env::storage_usage(); + tee_state.stored_attestations.insert( + node_id.tls_public_key.clone(), + NodeAttestation { + node_id, + verified_attestation, + }, + ); + tee_state.stored_attestations.flush(); + let bytes_grown = env::storage_usage() - before; + let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); - // Then: the minimum deposit covers the worst case with headroom to spare. assert!( - MINIMUM_ATTESTATION_STORAGE_DEPOSIT >= worst_cost, + MINIMUM_ATTESTATION_STORAGE_DEPOSIT >= cost, "minimum deposit {MINIMUM_ATTESTATION_STORAGE_DEPOSIT} must cover the worst-case entry \ - ({worst_bytes} bytes, {worst_cost}) at today's storage price" + ({bytes_grown} bytes, {cost}) at today's storage price" ); } } diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 04fe34f257..b7915a1499 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -240,9 +240,7 @@ impl TeeState { }, ); - // `IterableMap` defers writes to flush-on-Drop, so without an explicit - // flush the just-inserted entry reads as a zero storage delta and the - // caller would charge nothing for it. + // Flush the deferred write now so the caller's storage_usage read charges for it. self.stored_attestations.flush(); Ok(match previous { From c67cea5cab7594d8aa61248d08ff3797b29db4bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 23 Jul 2026 19:42:00 +0200 Subject: [PATCH 05/23] fix(contract): charge attestation storage only for new entries or non-participants submit_participant_info required a deposit unconditionally, which broke nodes signing with a function-call access key: such keys cannot attach any deposit, so every re-attestation failed at tx validation with DepositWithFunctionCall. Restore the pre-#3714 conditional: a participant re-attesting an existing entry attaches no deposit and is charged nothing (so the node's function-call key can re-attest), while a new entry or a non-participant caller still pays the storage delta. The client and node stop attaching the deposit; an operator's full-access key funds a first-time (join) submission. Adds a sandbox regression test that drives submit_participant_info through a real function-call access key (zero deposit succeeds; a deposit is rejected). Closes #3925 --- crates/contract/src/lib.rs | 85 ++++++++++++------- crates/contract/src/tee/tee_state.rs | 6 ++ .../contract/src/tee/verification_context.rs | 1 + .../tests/inprocess/attestation_submission.rs | 46 ++++++++-- crates/contract/tests/sandbox/tee.rs | 77 ++++++++++++++++- crates/contract/tests/sandbox/utils/consts.rs | 4 +- .../tests/sandbox/utils/mpc_contract.rs | 33 +++++-- .../snapshots/abi__abi_has_not_changed.snap | 9 +- .../near-mpc-contract-interface/src/client.rs | 5 +- ..._should_match_the_wire_format_catalog.snap | 2 +- crates/node/src/indexer/types.rs | 9 +- docs/securing-mpc-with-tee-design-doc.md | 2 +- 12 files changed, 217 insertions(+), 62 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 4f1b4827a9..4b38a64d0d 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -161,19 +161,29 @@ fn refund_to(account_id: &AccountId, amount: NearToken) { } } -/// Charges the storage growth since `initial_storage` and refunds the rest. The -/// [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`] floor guarantees the refund never underflows. -fn keep_storage_delta_and_refund_rest(account_id: &AccountId, initial_storage: u64) { +/// Requires at least [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`], then keeps the storage delta since +/// `initial_storage` and refunds the excess. +fn charge_storage(account_id: &AccountId, initial_storage: u64) -> Result<(), Error> { + let attached = env::attached_deposit(); + if attached < MINIMUM_ATTESTATION_STORAGE_DEPOSIT { + return Err(InvalidParameters::InsufficientDeposit { + attached: attached.as_yoctonear(), + required: MINIMUM_ATTESTATION_STORAGE_DEPOSIT.as_yoctonear(), + } + .into()); + } + // saturating_sub: a shrink charges nothing rather than underflowing. let bytes_grown = env::storage_usage().saturating_sub(initial_storage); let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); - match env::attached_deposit().checked_sub(cost) { + match attached.checked_sub(cost) { Some(refund) => refund_to(account_id, refund), // Unreachable given the MINIMUM_ATTESTATION_STORAGE_DEPOSIT floor, which // minimum_attestation_storage_deposit__should_cover_worst_case_entry pins to the // worst-case entry cost. None => log!("attestation storage cost {cost} exceeded deposit for {account_id}"), } + Ok(()) } impl Default for MpcContract { @@ -798,11 +808,12 @@ impl MpcContract { /// `verify_quote` call, with [`Self::resolve_verification`] chained as its /// callback to run the post-DCAP checks and store the attestation. /// - /// The caller must attach at least [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`], - /// enough to cover the worst-case stored entry. On success only the actual - /// storage delta is kept and the excess is refunded, so a re-submission that - /// changes no stored bytes is charged nothing. The full deposit is refunded if - /// the attestation is not accepted. + /// Storage is charged to the caller only when a new entry is stored or the caller is not a + /// current participant. A participant re-attesting an existing entry is charged nothing and + /// need not attach any deposit, so the node's function-call access key can re-attest. When a + /// charge applies, the caller must attach at least [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`] + /// (enough to cover the worst-case stored entry); only the actual storage delta is kept and + /// the excess is refunded. The full deposit is refunded if the attestation is not accepted. #[payable] #[handle_result] pub fn submit_participant_info( @@ -839,30 +850,30 @@ impl MpcContract { account_public_key, }; - let attached = env::attached_deposit(); - if attached < MINIMUM_ATTESTATION_STORAGE_DEPOSIT { - return Err(InvalidParameters::InsufficientDeposit { - attached: attached.as_yoctonear(), - required: MINIMUM_ATTESTATION_STORAGE_DEPOSIT.as_yoctonear(), - } - .into()); - } + // Non-participants pay per entry so an outsider cannot drain the contract; participants + // re-attest for free. + let caller_is_not_participant = !self + .protocol_state + .is_existing_or_prospective_participant(&account_id) + .unwrap_or(false); 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( + let insertion = self.tee_state.verify_and_store_mock( node_id, mock, tee_upgrade_deadline_duration, )?; - keep_storage_delta_and_refund_rest(&account_id, initial_storage); + if insertion.is_new() || caller_is_not_participant { + charge_storage(&account_id, initial_storage)?; + } Ok(PromiseOrValue::Value(())) } Attestation::Dstack(attestation) => Ok(PromiseOrValue::Promise( - self.submit_dstack_attestation(node_id, attestation)?, + self.submit_dstack_attestation(node_id, attestation, caller_is_not_participant)?, )), } } @@ -874,6 +885,7 @@ impl MpcContract { &mut self, node_id: NodeId, attestation: DstackAttestation, + caller_is_not_participant: bool, ) -> Result { let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { return Err(TeeError::VerifierNotConfigured.into()); @@ -894,6 +906,7 @@ impl MpcContract { .resolve_verification(VerificationContext { node_id, attestation, + caller_is_not_participant, }), )) } @@ -2383,8 +2396,8 @@ impl MpcContract { } /// Runs the post-DCAP checks and stores the attestation for a - /// [`VerificationResult::Verified`] response, then keeps the storage delta - /// and refunds the excess deposit. + /// [`VerificationResult::Verified`] response, then charges the storage delta unless a + /// participant re-attested an existing entry. fn verify_post_dcap_and_store( &mut self, context: &VerificationContext, @@ -2395,17 +2408,22 @@ impl MpcContract { Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); let initial_storage = env::storage_usage(); - if let Err(err) = self.tee_state.verify_and_store_dstack( + let insertion = match self.tee_state.verify_and_store_dstack( context.node_id.clone(), &context.attestation, report, tee_upgrade_deadline_duration, ) { - log!("post-DCAP check failed for {account_id}: {err}"); - return Err(err.into()); - } + Ok(insertion) => insertion, + Err(err) => { + log!("post-DCAP check failed for {account_id}: {err}"); + return Err(err.into()); + } + }; - keep_storage_delta_and_refund_rest(account_id, initial_storage); + if insertion.is_new() || context.caller_is_not_participant { + charge_storage(account_id, initial_storage)?; + } Ok(()) } @@ -4679,6 +4697,9 @@ mod tests { VerificationContext { node_id, attestation, + // Fixed, not parametrized: this flag only affects charging, which the mock VM + // can't observe, so true and false would store identical state here. + caller_is_not_participant: true, }, ) } @@ -8116,19 +8137,21 @@ mod tests { assert!(configs.contains_key(&tls_key_b), "node B config must exist"); } + const MAX_HASH: [u8; 32] = [0xff; 32]; + // Catches entry-size growth: fails if a schema change makes the largest storable entry // cost more than the deposit at today's storage_byte_cost. It cannot see a future // storage_byte_cost increase on a live contract; the deposit's margin covers that. #[rstest] #[case::dstack(VerifiedAttestation::Dstack(ValidatedDstackAttestation { - mpc_image_hash: [0xff; 32].into(), - launcher_compose_hash: [0xff; 32].into(), + mpc_image_hash: MAX_HASH.into(), + launcher_compose_hash: MAX_HASH.into(), expiry_timestamp_seconds: u64::MAX, measurements: default_measurements()[0], }))] #[case::mock(VerifiedAttestation::Mock(MpcMockAttestation::WithConstraints { - mpc_docker_image_hash: Some([0xff; 32].into()), - launcher_docker_compose_hash: Some([0xff; 32].into()), + mpc_docker_image_hash: Some(MAX_HASH.into()), + launcher_docker_compose_hash: Some(MAX_HASH.into()), expiry_timestamp_seconds: Some(u64::MAX), expected_measurements: Some(default_measurements()[0]), }))] diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index b7915a1499..a76c1bd2c5 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -53,6 +53,12 @@ pub(crate) enum ParticipantInsertion { UpdatedExistingParticipant, } +impl ParticipantInsertion { + pub(crate) fn is_new(&self) -> bool { + matches!(self, Self::NewlyInsertedParticipant) + } +} + #[derive(Debug)] pub enum TeeValidationResult { /// All participants are valid diff --git a/crates/contract/src/tee/verification_context.rs b/crates/contract/src/tee/verification_context.rs index 6450f60c04..411a5cf81d 100644 --- a/crates/contract/src/tee/verification_context.rs +++ b/crates/contract/src/tee/verification_context.rs @@ -11,4 +11,5 @@ use super::tee_state::NodeId; pub struct VerificationContext { pub(crate) node_id: NodeId, pub(crate) attestation: DstackAttestation, + pub(crate) caller_is_not_participant: bool, } diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index 3d6f37f3e4..eedef18ef5 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -278,18 +278,18 @@ 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. +/// A non-participant storing a new entry must cover its storage cost, so an outsider cannot +/// store an attestation without paying for it. #[test] -fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() { +fn submit_participant_info__should_reject_when_non_participant_deposit_is_below_storage_cost() { // Given let mut setup = TestSetupBuilder::new().build(); - let node = setup.get_participant_node_ids()[0].clone(); + let newcomer = node_id_for(&"newcomer.near".parse().unwrap()); 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()) + .signer_account_id(newcomer.account_id.clone()) + .predecessor_account_id(newcomer.account_id.clone()) .attached_deposit(attached_deposit) .build() ); @@ -299,7 +299,7 @@ fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() { .contract .submit_participant_info( Attestation::Mock(MockAttestation::Valid), - node.tls_public_key.clone(), + newcomer.tls_public_key.clone(), ) .map(|_| ()); @@ -311,6 +311,38 @@ fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() { ); } +/// A current participant re-attesting an existing entry is charged nothing and need not attach +/// any deposit, so the node's function-call access key can re-attest. +#[test] +fn submit_participant_info__should_charge_nothing_when_participant_reattests_with_zero_deposit() { + // Given + let mut setup = TestSetupBuilder::new().build(); + let node = setup.get_participant_node_ids()[0].clone(); + let attestation = Attestation::Mock(MockAttestation::Valid); + setup.submit_attestation_for_node(&node, attestation.clone()); + let stored_before = setup + .contract + .get_attestation(node.tls_public_key.clone()) + .unwrap() + .expect("participant attestation should be stored"); + + // When: the same participant re-attests with no attached deposit. + testing_env!(common::participant_context(&node.account_id)); + let result = setup + .contract + .submit_participant_info(attestation, node.tls_public_key.clone()) + .map(|_| ()); + + // Then: the submission succeeds and the stored entry is unchanged. + assert_matches!(&result, Ok(())); + let stored_after = setup + .contract + .get_attestation(node.tls_public_key) + .unwrap() + .expect("participant attestation should still be stored"); + assert_eq!(stored_before, stored_after); +} + /// Test that a `Dstack` submission is rejected when no verifier is configured. #[test] fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() { diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index 9b4a3882e6..a4da2d5f71 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -8,8 +8,8 @@ use crate::sandbox::{ mpc_contract::{ assert_running_return_participants, assert_running_return_threshold, get_participant_attestation, get_state, get_tee_accounts, submit_participant_info, - submit_participant_info_and_measure_kept_deposit, vote_add_launcher_hash, - vote_for_hash, + submit_participant_info_and_measure_kept_deposit, submit_participant_info_with_deposit, + vote_add_launcher_hash, vote_for_hash, }, resharing_utils::conclude_resharing, sign_utils::DomainResponseTest, @@ -21,8 +21,8 @@ use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash, NodeIma use near_mpc_contract_interface::method_names; use near_mpc_contract_interface::types::Protocol; use near_mpc_contract_interface::types::{self as dtos, Attestation, MockAttestation}; -use near_workspaces::Contract; -use near_workspaces::types::NearToken; +use near_workspaces::types::{KeyType, NearToken, SecretKey}; +use near_workspaces::{AccessKey, Account, Contract}; use rand::SeedableRng; use test_utils::attestation::{image_digest, p2p_tls_key}; @@ -280,6 +280,75 @@ async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result Ok(()) } +/// Regression test for #3925: a participant re-attesting through a function-call access key (which +/// cannot attach a deposit) must succeed with zero deposit, and the same call with a deposit must +/// be rejected by the protocol. +#[tokio::test] +async fn submit_participant_info__should_accept_zero_deposit_via_function_call_key() -> Result<()> { + let SandboxTestSetup { + worker, + contract, + mpc_signer_accounts, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .build() + .await; + + // Setup already stored an attestation for this participant, so re-attesting is the free path. + let account = &mpc_signer_accounts[0]; + let participants = assert_running_return_participants(&contract).await?; + let tls_key = participants + .participants + .iter() + .find(|(id, _, _)| id == account.id()) + .map(|(_, _, info)| info.tls_public_key.clone()) + .expect("participant must exist"); + let attestation = Attestation::Mock(MockAttestation::Valid); + + let fc_sk = SecretKey::from_random(KeyType::ED25519); + account + .batch(account.id()) + .add_key( + fc_sk.public_key(), + AccessKey::function_call_access(contract.id(), &[], None), + ) + .transact() + .await? + .into_result()?; + let fc_account = Account::from_secret_key(account.id().clone(), fc_sk, &worker); + + // Zero deposit through the fc key succeeds. + let zero = submit_participant_info_with_deposit( + &fc_account, + &contract, + &attestation, + &tls_key, + NearToken::from_yoctonear(0), + ) + .await?; + assert!( + zero.is_success(), + "zero-deposit fc submission must succeed: {zero:?}" + ); + + // A deposit through the fc key is rejected at tx validation (the original bug). + let with_deposit = submit_participant_info_with_deposit( + &fc_account, + &contract, + &attestation, + &tls_key, + SUBMIT_PARTICIPANT_INFO_DEPOSIT, + ) + .await; + let err = format!("{with_deposit:?}"); + assert!( + err.contains("DepositWithFunctionCall"), + "fc submission with a deposit must be rejected: {err}" + ); + Ok(()) +} + /// **Access control validation** - Tests that external accounts cannot call the private clean_tee_status contract method. /// This verifies the security boundary: only the contract itself should be able to perform internal cleanup operations. #[tokio::test] diff --git a/crates/contract/tests/sandbox/utils/consts.rs b/crates/contract/tests/sandbox/utils/consts.rs index bd9d862484..d5c5ddff8f 100644 --- a/crates/contract/tests/sandbox/utils/consts.rs +++ b/crates/contract/tests/sandbox/utils/consts.rs @@ -47,8 +47,8 @@ pub const MAX_GAS_FOR_THRESHOLD_VOTE: Gas = Gas::from_tgas(190); /// TODO(#2756): Reduce this to the minimal value possible pub const CURRENT_CONTRACT_DEPLOY_DEPOSIT: NearToken = NearToken::from_millinear(17000); -/// Attached to `submit_participant_info`; the contract requires exactly this flat -/// fee to store the bounded attestation entry, with no refund. +/// Attached by sandbox onboarding submissions to `submit_participant_info`, which store a new +/// entry the contract charges for; the excess over the actual storage delta is refunded. pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT: NearToken = NearToken::from_millinear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR); diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index 366703b22c..d51377de26 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -1,10 +1,10 @@ use std::collections::BTreeSet; -use super::transactions::{SandboxCaller, all_receipts_successful}; +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, TeeVerifierCodeHash}; use near_mpc_contract_interface::{ - client::MpcContractHandle, method_names, types::{ Attestation, Ed25519PublicKey, GovernanceThreshold, Participants, ProtocolContractState, @@ -59,10 +59,31 @@ pub async fn submit_participant_info( attestation: &Attestation, tls_key: &Ed25519PublicKey, ) -> anyhow::Result { - // TODO(#3906): check if inlining is nicer once we ported the entire contract interface. - let contract_handle = MpcContractHandle::new(SandboxCaller(account), contract.id().clone()); - contract_handle - .submit_participant_info(attestation.clone(), tls_key.clone()) + // Sandbox onboards not-yet-participants, which the contract charges; attach the deposit an + // operator's full-access key would (excess refunded). The production client attaches none. + 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 { + account + .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) + .args_json((attestation.clone(), tls_key.clone())) + .deposit(deposit) + .max_gas() + .transact() .await .map_err(Into::into) } 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 8ab12f913f..1191d2b26d 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -1576,6 +1576,10 @@ expression: abi [ "attestation", "DstackAttestation" + ], + [ + "caller_is_not_participant", + "bool" ] ] }, @@ -1599,6 +1603,9 @@ expression: abi "elements": "u8" } }, + "bool": { + "Primitive": 1 + }, "u32": { "Primitive": 4 }, @@ -2342,7 +2349,7 @@ expression: abi }, { "name": "submit_participant_info", - "doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously via a cross-contract\n `verify_quote` call, with [`Self::resolve_verification`] chained as its\n callback to run the post-DCAP checks and store the attestation.\n\n The caller must attach at least [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`],\n enough to cover the worst-case stored entry. On success only the actual\n storage delta is kept and the excess is refunded, so a re-submission that\n changes no stored bytes is charged nothing. The full deposit is refunded if\n the attestation is not accepted.", + "doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously via a cross-contract\n `verify_quote` call, with [`Self::resolve_verification`] chained as its\n callback to run the post-DCAP checks and store the attestation.\n\n Storage is charged to the caller only when a new entry is stored or the caller is not a\n current participant. A participant re-attesting an existing entry is charged nothing and\n need not attach any deposit, so the node's function-call access key can re-attest. When a\n charge applies, the caller must attach at least [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`]\n (enough to cover the worst-case stored entry); only the actual storage delta is kept and\n the excess is refunded. The full deposit is refunded if the attestation is not accepted.", "kind": "call", "modifiers": [ "payable" diff --git a/crates/near-mpc-contract-interface/src/client.rs b/crates/near-mpc-contract-interface/src/client.rs index 4d73c5550e..9342eda081 100644 --- a/crates/near-mpc-contract-interface/src/client.rs +++ b/crates/near-mpc-contract-interface/src/client.rs @@ -7,7 +7,7 @@ use near_contract_transport::{CallContract, FunctionCallArgs, NearGas, NearToken}; use crate::call_args::{SignArgs, SubmitParticipantInfoArgs}; -use crate::deposits::{SIGN_DEPOSIT_YOCTONEAR, SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR}; +use crate::deposits::SIGN_DEPOSIT_YOCTONEAR; use crate::method_names::{SIGN, SUBMIT_PARTICIPANT_INFO, VERIFY_TEE}; use crate::types::{AccountId, Attestation, Ed25519PublicKey, SignRequestArgs}; @@ -71,7 +71,8 @@ impl MpcContractHandle { method_name: SUBMIT_PARTICIPANT_INFO.to_string(), args, gas: MAX_GAS, - deposit: NearToken::from_millinear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR), + // Zero: the node's function-call key cannot attach a deposit; the contract only charges new entries. + deposit: NearToken::from_yoctonear(0), }, ) .await diff --git a/crates/near-mpc-contract-interface/src/snapshots/near_mpc_contract_interface__client__tests__mpc_contract_handle__should_match_the_wire_format_catalog.snap b/crates/near-mpc-contract-interface/src/snapshots/near_mpc_contract_interface__client__tests__mpc_contract_handle__should_match_the_wire_format_catalog.snap index 02d3525663..66c88a16b1 100644 --- a/crates/near-mpc-contract-interface/src/snapshots/near_mpc_contract_interface__client__tests__mpc_contract_handle__should_match_the_wire_format_catalog.snap +++ b/crates/near-mpc-contract-interface/src/snapshots/near_mpc_contract_interface__client__tests__mpc_contract_handle__should_match_the_wire_format_catalog.snap @@ -11,7 +11,7 @@ args: {"request":{"path":"test","payload_v2":{"Ecdsa":"070707070707070707070 contract: mpc.near method: submit_participant_info gas: 300.0 Tgas -deposit: 0.1 NEAR +deposit: 0 NEAR args: {"proposed_participant_attestation":{"Mock":"Valid"},"tls_public_key":"ed25519:US517G5965aydkZ46HS38QLi7UQiSojurfbQfKCELFx"} contract: mpc.near diff --git a/crates/node/src/indexer/types.rs b/crates/node/src/indexer/types.rs index b47402041e..01599a189c 100644 --- a/crates/node/src/indexer/types.rs +++ b/crates/node/src/indexer/types.rs @@ -8,7 +8,6 @@ use k256::{ }; use near_indexer_primitives::types::{Balance, Gas}; use near_mpc_contract_interface::call_args as contract_args; -use near_mpc_contract_interface::deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR; use near_mpc_contract_interface::method_names::{ CONCLUDE_NODE_MIGRATION, REGISTER_FOREIGN_CHAINS_CONFIG, RESPOND, RESPOND_CKD, RESPOND_VERIFY_FOREIGN_TX, START_KEYGEN_INSTANCE, START_RESHARE_INSTANCE, @@ -129,12 +128,8 @@ impl ChainSendTransactionRequest { } pub fn deposit_required(&self) -> Balance { - match self { - Self::SubmitParticipantInfo { .. } => { - Balance::from_millinear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR) - } - _ => Balance::from_near(0), - } + // The node signs with a function-call access key, which cannot attach a deposit. + Balance::from_near(0) } } diff --git a/docs/securing-mpc-with-tee-design-doc.md b/docs/securing-mpc-with-tee-design-doc.md index 128092e9d1..c03eeb60c0 100644 --- a/docs/securing-mpc-with-tee-design-doc.md +++ b/docs/securing-mpc-with-tee-design-doc.md @@ -386,7 +386,7 @@ pub struct Contract { } ``` -_Note_: submit_participant_info - can be called either by the node or by the operator. +_Note_: submit_participant_info - can be called either by the node or by the operator. Storage is charged only when a new entry is stored or the caller is not a current participant, so a participant re-attesting an existing entry attaches no deposit and the node's function-call access key can re-attest. A first-time (join) submission stores a new entry, so it must attach a deposit to cover it; that submission is made by the operator's full-access key (step 6), since a function-call key cannot attach a deposit. ## MPC Node changes: From b1ab8eb6c670fb146965cd420dfe43c4f7a0a814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 24 Jul 2026 12:36:35 +0200 Subject: [PATCH 06/23] fix(contract): charge attestation storage by measured delta; fund e2e onboarding The fixed MINIMUM_ATTESTATION_STORAGE_DEPOSIT floor rejected zero-deposit submissions outright. Restore the pre-#3714 behavior: charge only the measured storage delta and reject only when the attached deposit is below it. A participant re-attesting an unchanged entry grows nothing, so it pays nothing and its function-call key works; a new entry costs the measured delta, funded by a deposit-capable caller. In e2e, fund each new participant's first attestation before resharing, signed with the node's near_signer_key so the stored account_public_key matches the key the node later uses for key-event votes (an operator key would store the wrong key and stall resharing). Add MpcNodeState::near_signer_key for this. Repurpose the worst-case test to assert the entry cost stays under a bounded ceiling, and regenerate the ABI snapshot. --- crates/contract/src/lib.rs | 56 ++++++++----------- crates/contract/tests/sandbox/tee.rs | 11 ++-- .../snapshots/abi__abi_has_not_changed.snap | 2 +- crates/e2e-tests/src/cluster.rs | 49 ++++++++++++++++ 4 files changed, 79 insertions(+), 39 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 4b38a64d0d..d429341d39 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -117,11 +117,6 @@ const MINIMUM_CKD_REQUEST_DEPOSIT: NearToken = NearToken::from_yoctonear(1); /// node key cannot invoke these methods. pub const MINIMUM_NODE_MANAGEMENT_DEPOSIT: NearToken = NearToken::from_yoctonear(1); -/// Minimum a node must attach to [`MpcContract::submit_participant_info`], -/// sized to cover the worst-case stored entry. Only the actual storage delta is -/// kept; the excess is refunded. -pub const MINIMUM_ATTESTATION_STORAGE_DEPOSIT: NearToken = NearToken::from_millinear(100); - /// Entries to scan in the post-reshare `clean_invalid_attestations` sweep. External /// callers may pick a different value; this only governs the automatic invocation. const RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN: u32 = 100; @@ -161,28 +156,23 @@ fn refund_to(account_id: &AccountId, amount: NearToken) { } } -/// Requires at least [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`], then keeps the storage delta since -/// `initial_storage` and refunds the excess. +/// Charges `account_id` the storage growth since `initial_storage` and refunds the excess. A +/// participant re-attesting an unchanged entry grows nothing and pays nothing, so the node's +/// function-call access key can re-attest; a new entry costs the measured delta, funded by the +/// caller (an operator's full-access key for a first submission). fn charge_storage(account_id: &AccountId, initial_storage: u64) -> Result<(), Error> { + // saturating_sub: a shrink charges nothing rather than underflowing. + let bytes_grown = env::storage_usage().saturating_sub(initial_storage); + let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); let attached = env::attached_deposit(); - if attached < MINIMUM_ATTESTATION_STORAGE_DEPOSIT { + if attached < cost { return Err(InvalidParameters::InsufficientDeposit { attached: attached.as_yoctonear(), - required: MINIMUM_ATTESTATION_STORAGE_DEPOSIT.as_yoctonear(), + required: cost.as_yoctonear(), } .into()); } - - // saturating_sub: a shrink charges nothing rather than underflowing. - let bytes_grown = env::storage_usage().saturating_sub(initial_storage); - let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); - match attached.checked_sub(cost) { - Some(refund) => refund_to(account_id, refund), - // Unreachable given the MINIMUM_ATTESTATION_STORAGE_DEPOSIT floor, which - // minimum_attestation_storage_deposit__should_cover_worst_case_entry pins to the - // worst-case entry cost. - None => log!("attestation storage cost {cost} exceeded deposit for {account_id}"), - } + refund_to(account_id, attached.saturating_sub(cost)); Ok(()) } @@ -809,11 +799,11 @@ impl MpcContract { /// callback to run the post-DCAP checks and store the attestation. /// /// Storage is charged to the caller only when a new entry is stored or the caller is not a - /// current participant. A participant re-attesting an existing entry is charged nothing and - /// need not attach any deposit, so the node's function-call access key can re-attest. When a - /// charge applies, the caller must attach at least [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`] - /// (enough to cover the worst-case stored entry); only the actual storage delta is kept and - /// the excess is refunded. The full deposit is refunded if the attestation is not accepted. + /// current participant. A participant re-attesting an existing entry grows nothing and is charged + /// nothing, so the node's function-call access key can re-attest with no deposit. A new entry + /// costs the measured storage delta, which the caller must cover (an operator's full-access key + /// funds a first submission); the excess is refunded, as is the full deposit on a rejected + /// attestation. #[payable] #[handle_result] pub fn submit_participant_info( @@ -4674,7 +4664,7 @@ mod tests { let context = VMContextBuilder::new() .current_account_id(contract_account_id.clone()) .predecessor_account_id(contract_account_id) - .attached_deposit(MINIMUM_ATTESTATION_STORAGE_DEPOSIT) + .attached_deposit(NearToken::from_near(1)) .block_timestamp(VALID_ATTESTATION_TIMESTAMP * 1_000_000_000) .build(); testing_env!(context); @@ -8139,9 +8129,9 @@ mod tests { const MAX_HASH: [u8; 32] = [0xff; 32]; - // Catches entry-size growth: fails if a schema change makes the largest storable entry - // cost more than the deposit at today's storage_byte_cost. It cannot see a future - // storage_byte_cost increase on a live contract; the deposit's margin covers that. + // A stored entry a caller must fund stays bounded; a schema change that bloats it fails here. + const WORST_CASE_ENTRY_COST_CEILING: NearToken = NearToken::from_millinear(100); + #[rstest] #[case::dstack(VerifiedAttestation::Dstack(ValidatedDstackAttestation { mpc_image_hash: MAX_HASH.into(), @@ -8155,7 +8145,7 @@ mod tests { expiry_timestamp_seconds: Some(u64::MAX), expected_measurements: Some(default_measurements()[0]), }))] - fn minimum_attestation_storage_deposit__should_cover_worst_case_entry( + fn submit_participant_info__should_bound_worst_case_entry_cost( #[case] verified_attestation: VerifiedAttestation, ) { testing_env!(VMContextBuilder::new().build()); @@ -8179,9 +8169,9 @@ mod tests { let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); assert!( - MINIMUM_ATTESTATION_STORAGE_DEPOSIT >= cost, - "minimum deposit {MINIMUM_ATTESTATION_STORAGE_DEPOSIT} must cover the worst-case entry \ - ({bytes_grown} bytes, {cost}) at today's storage price" + cost <= WORST_CASE_ENTRY_COST_CEILING, + "worst-case entry cost ({bytes_grown} bytes, {cost}) must stay under \ + {WORST_CASE_ENTRY_COST_CEILING} at today's storage price" ); } } diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index a4da2d5f71..e5c4d13957 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -1050,10 +1050,10 @@ async fn verify_tee__should_keep_participants_and_stop_signing_when_kickout_drop Ok(()) } -/// A submission attaching less than the flat storage fee is rejected before the +/// A new-entry submission attaching less than the measured storage cost is rejected before the /// entry is stored. #[tokio::test] -async fn submit_participant_info__should_reject_new_attestation_below_flat_fee() -> Result<()> { +async fn submit_participant_info__should_reject_new_attestation_below_storage_cost() -> Result<()> { // Given let SandboxTestSetup { worker, contract, .. @@ -1064,7 +1064,8 @@ async fn submit_participant_info__should_reject_new_attestation_below_flat_fee() let outsider = worker.dev_create_account().await?; let fresh_tls_key = bogus_ed25519_public_key(); let storage_before = worker.view_account(contract.id()).await?.storage_usage; - let below_fee = SUBMIT_PARTICIPANT_INFO_DEPOSIT.saturating_sub(NearToken::from_yoctonear(1)); + // 1 yoctoNEAR is below any nonzero entry cost. + let below_cost = NearToken::from_yoctonear(1); // When let result = outsider @@ -1073,7 +1074,7 @@ async fn submit_participant_info__should_reject_new_attestation_below_flat_fee() Attestation::Mock(MockAttestation::Valid), fresh_tls_key.clone(), )) - .deposit(below_fee) + .deposit(below_cost) .max_gas() .transact() .await?; @@ -1081,7 +1082,7 @@ async fn submit_participant_info__should_reject_new_attestation_below_flat_fee() // Then assert!( !result.is_success(), - "submission below the flat fee must fail: {result:?}" + "submission below the storage cost must fail: {result:?}" ); let error_msg = format!("{:?}", result.into_result()); assert!( 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 1191d2b26d..3145109296 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -2349,7 +2349,7 @@ expression: abi }, { "name": "submit_participant_info", - "doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously via a cross-contract\n `verify_quote` call, with [`Self::resolve_verification`] chained as its\n callback to run the post-DCAP checks and store the attestation.\n\n Storage is charged to the caller only when a new entry is stored or the caller is not a\n current participant. A participant re-attesting an existing entry is charged nothing and\n need not attach any deposit, so the node's function-call access key can re-attest. When a\n charge applies, the caller must attach at least [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`]\n (enough to cover the worst-case stored entry); only the actual storage delta is kept and\n the excess is refunded. The full deposit is refunded if the attestation is not accepted.", + "doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously via a cross-contract\n `verify_quote` call, with [`Self::resolve_verification`] chained as its\n callback to run the post-DCAP checks and store the attestation.\n\n Storage is charged to the caller only when a new entry is stored or the caller is not a\n current participant. A participant re-attesting an existing entry grows nothing and is charged\n nothing, so the node's function-call access key can re-attest with no deposit. A new entry\n costs the measured storage delta, which the caller must cover (an operator's full-access key\n funds a first submission); the excess is refunded, as is the full deposit on a rejected\n attestation.", "kind": "call", "modifiers": [ "payable" diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index 1c7c40a429..a08a9a1a8e 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -49,6 +49,10 @@ pub fn cluster_poll_retry() -> ConstantBuilder { } const NODE_MANAGEMENT_DEPOSIT: near_kit::NearToken = near_kit::NearToken::from_yoctonear(1); +// Covers a new attestation entry's storage cost; the contract refunds the excess. The contract test +// submit_participant_info__should_bound_worst_case_entry_cost asserts the worst-case entry stays +// under this amount. +const ATTESTATION_STORAGE_DEPOSIT: near_kit::NearToken = near_kit::NearToken::from_millinear(100); // The contract's default `key_event_timeout_blocks = 30` is ~18 s on // mainnet (~600 ms blocks). The e2e sandbox runs ~8 blocks/s, so the // same 30 collapses to ~3.7 s — too tight for the resharing @@ -559,6 +563,8 @@ impl MpcCluster { new_participants: &[usize], new_threshold: usize, ) -> anyhow::Result<()> { + self.fund_new_participant_attestations(new_participants) + .await?; self.wait_for_participant_attestations(new_participants) .await?; @@ -611,6 +617,42 @@ impl MpcCluster { .map(|_| ()) } + /// Submit each new participant's first attestation with a deposit covering the new entry's + /// storage cost, which the node's own function-call key cannot attach on-chain. Signed with the + /// node's near_signer_key so the stored account_public_key matches the key it later signs + /// key-event votes with. Idempotent: the node's later self-submit of the same entry is free. + async fn fund_new_participant_attestations( + &self, + node_indices: &[usize], + ) -> anyhow::Result<()> { + for &idx in node_indices { + let node = &self.nodes[idx]; + let client = self + .blockchain + .client_for(node.account_id().as_ref(), node.near_signer_key())?; + let pubkey = node.p2p_public_key(); + let outcome = self + .contract + .call_from_deposit( + &client, + method_names::SUBMIT_PARTICIPANT_INFO, + json!({ + "proposed_participant_attestation": Attestation::Mock(MockAttestation::Valid), + "tls_public_key": pubkey, + }), + ATTESTATION_STORAGE_DEPOSIT, + ) + .await + .with_context(|| format!("failed to fund attestation for node {idx}"))?; + anyhow::ensure!( + outcome.is_success(), + "funding attestation for node {idx} reverted: {:?}", + outcome.failure_message() + ); + } + Ok(()) + } + /// Poll until all proposed participants have TEE attestations on-chain. async fn wait_for_participant_attestations( &self, @@ -1182,6 +1224,13 @@ impl MpcNodeState { } } + pub fn near_signer_key(&self) -> &SigningKey { + match self { + MpcNodeState::Running(n) => n.setup().near_signer_key(), + MpcNodeState::Stopped(s) => s.near_signer_key(), + } + } + pub fn p2p_public_key_str(&self) -> String { String::from(&self.p2p_public_key()) } From 3efa6e45e5c1e2fcaa874bddaa9d0c3c357d3184 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 24 Jul 2026 13:22:59 +0200 Subject: [PATCH 07/23] fix(contract): fund attestation storage from the contract balance submit_participant_info takes no deposit; the contract's own balance stakes the bounded attestation entry. A node self-submits with its function-call access key for its first attestation and re-attestations alike, fixing onboarding: #3714 required a deposit, which a function-call key cannot attach. This makes intentional what pre-#3714 did by accident. Pre-#3714 measured the storage delta right after inserting into the IterableMap, before the deferred write was flushed, so the delta read as 0 and the caller was never charged. #3714 added the flush (kept here), which exposed the real cost and started rejecting the node's zero-deposit submit. Storage is bounded and reclaimed by clean_invalid_attestations, so the contract-funded cost is bounded and self-healing. Removes the charge path, the payable modifiers, deposit forwarding, and the e2e funding workaround; updates tests and the ABI snapshot. --- crates/contract/src/lib.rs | 80 ++-------- crates/contract/src/tee/tee_state.rs | 6 - .../contract/src/tee/verification_context.rs | 1 - .../tests/inprocess/attestation_submission.rs | 55 +++---- crates/contract/tests/sandbox/tee.rs | 149 ++---------------- crates/contract/tests/sandbox/utils/consts.rs | 9 +- .../tests/sandbox/utils/mpc_contract.rs | 45 +----- .../snapshots/abi__abi_has_not_changed.snap | 15 +- crates/e2e-tests/src/cluster.rs | 49 ------ .../src/deposits.rs | 5 - docs/securing-mpc-with-tee-design-doc.md | 2 +- 11 files changed, 51 insertions(+), 365 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index d429341d39..f55dcf5f6c 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -156,26 +156,6 @@ fn refund_to(account_id: &AccountId, amount: NearToken) { } } -/// Charges `account_id` the storage growth since `initial_storage` and refunds the excess. A -/// participant re-attesting an unchanged entry grows nothing and pays nothing, so the node's -/// function-call access key can re-attest; a new entry costs the measured delta, funded by the -/// caller (an operator's full-access key for a first submission). -fn charge_storage(account_id: &AccountId, initial_storage: u64) -> Result<(), Error> { - // saturating_sub: a shrink charges nothing rather than underflowing. - let bytes_grown = env::storage_usage().saturating_sub(initial_storage); - let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); - let attached = env::attached_deposit(); - if attached < cost { - return Err(InvalidParameters::InsufficientDeposit { - attached: attached.as_yoctonear(), - required: cost.as_yoctonear(), - } - .into()); - } - refund_to(account_id, attached.saturating_sub(cost)); - Ok(()) -} - impl Default for MpcContract { fn default() -> Self { env::panic_str("Calling default not allowed."); @@ -798,13 +778,8 @@ impl MpcContract { /// `verify_quote` call, with [`Self::resolve_verification`] chained as its /// callback to run the post-DCAP checks and store the attestation. /// - /// Storage is charged to the caller only when a new entry is stored or the caller is not a - /// current participant. A participant re-attesting an existing entry grows nothing and is charged - /// nothing, so the node's function-call access key can re-attest with no deposit. A new entry - /// costs the measured storage delta, which the caller must cover (an operator's full-access key - /// funds a first submission); the excess is refunded, as is the full deposit on a rejected - /// attestation. - #[payable] + /// Storage is funded by the contract's own balance, so a node submits with no deposit via its + /// function-call access key, for its first attestation and re-attestations alike. #[handle_result] pub fn submit_participant_info( &mut self, @@ -840,30 +815,19 @@ impl MpcContract { account_public_key, }; - // Non-participants pay per entry so an outsider cannot drain the contract; participants - // re-attest for free. - let caller_is_not_participant = !self - .protocol_state - .is_existing_or_prospective_participant(&account_id) - .unwrap_or(false); - match proposed_participant_attestation { Attestation::Mock(mock) => { let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); - let initial_storage = env::storage_usage(); - let insertion = self.tee_state.verify_and_store_mock( + self.tee_state.verify_and_store_mock( node_id, mock, tee_upgrade_deadline_duration, )?; - if insertion.is_new() || caller_is_not_participant { - charge_storage(&account_id, initial_storage)?; - } Ok(PromiseOrValue::Value(())) } Attestation::Dstack(attestation) => Ok(PromiseOrValue::Promise( - self.submit_dstack_attestation(node_id, attestation, caller_is_not_participant)?, + self.submit_dstack_attestation(node_id, attestation)?, )), } } @@ -875,7 +839,6 @@ impl MpcContract { &mut self, node_id: NodeId, attestation: DstackAttestation, - caller_is_not_participant: bool, ) -> Result { let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { return Err(TeeError::VerifierNotConfigured.into()); @@ -892,11 +855,9 @@ impl MpcContract { .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, - caller_is_not_participant, }), )) } @@ -2333,12 +2294,10 @@ impl MpcContract { } } - /// Verify-quote callback: on a verifier verdict it runs the post-DCAP - /// checks and stores the attestation, keeping the storage delta and - /// refunding the excess. Refunds the full deposit if the attestation is not - /// accepted. + /// Verify-quote callback: on a verifier verdict it runs the post-DCAP checks and stores the + /// attestation (storage funded by the contract's balance). On any failure it fails the + /// submitter's transaction. #[private] - #[payable] pub fn resolve_verification( &mut self, #[serializer(borsh)] context: VerificationContext, @@ -2370,9 +2329,8 @@ impl MpcContract { 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) + // Fail the submitter's transaction from a separate receipt so any prior state + // 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()) @@ -2386,8 +2344,7 @@ impl MpcContract { } /// Runs the post-DCAP checks and stores the attestation for a - /// [`VerificationResult::Verified`] response, then charges the storage delta unless a - /// participant re-attested an existing entry. + /// [`VerificationResult::Verified`] response. Storage is funded by the contract's balance. fn verify_post_dcap_and_store( &mut self, context: &VerificationContext, @@ -2397,22 +2354,14 @@ impl MpcContract { let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); - let initial_storage = env::storage_usage(); - let insertion = match self.tee_state.verify_and_store_dstack( + if let Err(err) = self.tee_state.verify_and_store_dstack( context.node_id.clone(), &context.attestation, report, tee_upgrade_deadline_duration, ) { - Ok(insertion) => insertion, - Err(err) => { - log!("post-DCAP check failed for {account_id}: {err}"); - return Err(err.into()); - } - }; - - if insertion.is_new() || context.caller_is_not_participant { - charge_storage(account_id, initial_storage)?; + log!("post-DCAP check failed for {account_id}: {err}"); + return Err(err.into()); } Ok(()) } @@ -4687,9 +4636,6 @@ mod tests { VerificationContext { node_id, attestation, - // Fixed, not parametrized: this flag only affects charging, which the mock VM - // can't observe, so true and false would store identical state here. - caller_is_not_participant: true, }, ) } diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index a76c1bd2c5..b7915a1499 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -53,12 +53,6 @@ pub(crate) enum ParticipantInsertion { UpdatedExistingParticipant, } -impl ParticipantInsertion { - pub(crate) fn is_new(&self) -> bool { - matches!(self, Self::NewlyInsertedParticipant) - } -} - #[derive(Debug)] pub enum TeeValidationResult { /// All participants are valid diff --git a/crates/contract/src/tee/verification_context.rs b/crates/contract/src/tee/verification_context.rs index 411a5cf81d..6450f60c04 100644 --- a/crates/contract/src/tee/verification_context.rs +++ b/crates/contract/src/tee/verification_context.rs @@ -11,5 +11,4 @@ use super::tee_state::NodeId; pub struct VerificationContext { pub(crate) node_id: NodeId, pub(crate) attestation: DstackAttestation, - pub(crate) caller_is_not_participant: bool, } diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index eedef18ef5..9c4c774e92 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -3,7 +3,7 @@ use super::common; use mpc_contract::{ MpcContract, - errors::{Error, InvalidParameters, InvalidState, TeeError}, + errors::{Error, InvalidState, TeeError}, primitives::{ key_state::EpochId, participants::{ParticipantId, ParticipantInfo}, @@ -15,15 +15,14 @@ use mpc_contract::{ }, tee::tee_state::{AttestationSubmissionError, NodeId}, }; -use near_mpc_contract_interface::{ - deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR, - types::{Attestation, InitConfig, MockAttestation, ProtocolContractState}, +use near_mpc_contract_interface::types::{ + Attestation, InitConfig, MockAttestation, ProtocolContractState, }; use std::collections::BTreeMap; use assert_matches::assert_matches; use near_account_id::AccountId; -use near_sdk::{NearToken, test_utils::VMContextBuilder, testing_env}; +use near_sdk::{test_utils::VMContextBuilder, testing_env}; use rstest::rstest; use std::time::Duration; use test_utils::attestation::mock_dto_dstack_attestation; @@ -31,9 +30,6 @@ 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_millinear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR); - const DEFAULT_PARTICIPANT_COUNT: usize = 3; const DEFAULT_THRESHOLD_SIZE: u64 = 2; const DEFAULT_CONTRACT_PROTOCOL_STATE: ContractProtocolState = ContractProtocolState::Running; @@ -163,16 +159,7 @@ impl TestSetup { node_id: &NodeId, attestation: Attestation, ) -> Result<(), mpc_contract::errors::Error> { - // `submit_participant_info` requires the flat storage fee, unlike the - // deposit-free calls `common::participant_context` is built for. - testing_env!( - VMContextBuilder::new() - .signer_account_id(node_id.account_id.clone()) - .predecessor_account_id(node_id.account_id.clone()) - .block_timestamp(near_sdk::env::block_timestamp()) - .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) - .build() - ); + testing_env!(common::participant_context(&node_id.account_id)); self.contract .submit_participant_info(attestation, node_id.tls_public_key.clone()) .map(|_| ()) @@ -278,21 +265,14 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { assert_eq!(stored_before, stored_after); } -/// A non-participant storing a new entry must cover its storage cost, so an outsider cannot -/// store an attestation without paying for it. +/// A newcomer stores its first attestation with no deposit (contract-funded storage), so the +/// node's function-call access key can self-onboard. #[test] -fn submit_participant_info__should_reject_when_non_participant_deposit_is_below_storage_cost() { +fn submit_participant_info__should_store_new_entry_with_zero_deposit() { // Given let mut setup = TestSetupBuilder::new().build(); let newcomer = node_id_for(&"newcomer.near".parse().unwrap()); - let attached_deposit = NearToken::from_yoctonear(1); - testing_env!( - VMContextBuilder::new() - .signer_account_id(newcomer.account_id.clone()) - .predecessor_account_id(newcomer.account_id.clone()) - .attached_deposit(attached_deposit) - .build() - ); + testing_env!(common::participant_context(&newcomer.account_id)); // When let result = setup @@ -304,17 +284,20 @@ fn submit_participant_info__should_reject_when_non_participant_deposit_is_below_ .map(|_| ()); // Then - assert_matches!( - &result, - Err(Error::InvalidParameters(InvalidParameters::InsufficientDeposit { attached, required })) - if *attached == attached_deposit.as_yoctonear() && required > attached + assert_matches!(&result, Ok(())); + assert!( + setup + .contract + .get_attestation(newcomer.tls_public_key) + .unwrap() + .is_some() ); } -/// A current participant re-attesting an existing entry is charged nothing and need not attach -/// any deposit, so the node's function-call access key can re-attest. +/// A current participant re-attesting an existing entry succeeds with no attached deposit, so the +/// node's function-call access key can re-attest. #[test] -fn submit_participant_info__should_charge_nothing_when_participant_reattests_with_zero_deposit() { +fn submit_participant_info__should_reattest_with_zero_deposit() { // Given let mut setup = TestSetupBuilder::new().build(); let node = setup.get_participant_node_ids()[0].clone(); diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index e5c4d13957..b65728b5b4 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -3,12 +3,11 @@ use crate::sandbox::{ common::{SandboxTestSetup, build_sandbox_node_ids, gen_accounts, submit_tee_attestations}, utils::{ - consts::{ALL_PROTOCOLS, SUBMIT_PARTICIPANT_INFO_DEPOSIT}, + consts::ALL_PROTOCOLS, interface::IntoContractType, mpc_contract::{ assert_running_return_participants, assert_running_return_threshold, get_participant_attestation, get_state, get_tee_accounts, submit_participant_info, - submit_participant_info_and_measure_kept_deposit, submit_participant_info_with_deposit, vote_add_launcher_hash, vote_for_hash, }, resharing_utils::conclude_resharing, @@ -281,8 +280,7 @@ async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result } /// Regression test for #3925: a participant re-attesting through a function-call access key (which -/// cannot attach a deposit) must succeed with zero deposit, and the same call with a deposit must -/// be rejected by the protocol. +/// cannot attach a deposit) succeeds with zero deposit (storage is contract-funded). #[tokio::test] async fn submit_participant_info__should_accept_zero_deposit_via_function_call_key() -> Result<()> { let SandboxTestSetup { @@ -295,7 +293,6 @@ async fn submit_participant_info__should_accept_zero_deposit_via_function_call_k .build() .await; - // Setup already stored an attestation for this participant, so re-attesting is the free path. let account = &mpc_signer_accounts[0]; let participants = assert_running_return_participants(&contract).await?; let tls_key = participants @@ -318,33 +315,10 @@ async fn submit_participant_info__should_accept_zero_deposit_via_function_call_k .into_result()?; let fc_account = Account::from_secret_key(account.id().clone(), fc_sk, &worker); - // Zero deposit through the fc key succeeds. - let zero = submit_participant_info_with_deposit( - &fc_account, - &contract, - &attestation, - &tls_key, - NearToken::from_yoctonear(0), - ) - .await?; - assert!( - zero.is_success(), - "zero-deposit fc submission must succeed: {zero:?}" - ); - - // A deposit through the fc key is rejected at tx validation (the original bug). - let with_deposit = submit_participant_info_with_deposit( - &fc_account, - &contract, - &attestation, - &tls_key, - SUBMIT_PARTICIPANT_INFO_DEPOSIT, - ) - .await; - let err = format!("{with_deposit:?}"); + let outcome = submit_participant_info(&fc_account, &contract, &attestation, &tls_key).await?; assert!( - err.contains("DepositWithFunctionCall"), - "fc submission with a deposit must be rejected: {err}" + outcome.is_success(), + "zero-deposit fc submission must succeed: {outcome:?}" ); Ok(()) } @@ -1050,10 +1024,10 @@ async fn verify_tee__should_keep_participants_and_stop_signing_when_kickout_drop Ok(()) } -/// A new-entry submission attaching less than the measured storage cost is rejected before the -/// entry is stored. +/// A new attestation entry is stored with no attached deposit; the contract's own balance funds +/// the storage. #[tokio::test] -async fn submit_participant_info__should_reject_new_attestation_below_storage_cost() -> Result<()> { +async fn submit_participant_info__should_store_new_entry_with_zero_deposit() -> Result<()> { // Given let SandboxTestSetup { worker, contract, .. @@ -1063,62 +1037,9 @@ async fn submit_participant_info__should_reject_new_attestation_below_storage_co .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; - // 1 yoctoNEAR is below any nonzero entry cost. - let below_cost = NearToken::from_yoctonear(1); // When - let result = outsider - .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) - .args_json(( - Attestation::Mock(MockAttestation::Valid), - fresh_tls_key.clone(), - )) - .deposit(below_cost) - .max_gas() - .transact() - .await?; - - // Then - assert!( - !result.is_success(), - "submission below the storage cost must fail: {result:?}" - ); - let error_msg = format!("{:?}", result.into_result()); - assert!( - error_msg.contains("Attached deposit is lower than required"), - "expected an insufficient-deposit error, got: {error_msg}" - ); - let stored = get_participant_attestation(&contract, &fresh_tls_key).await?; - assert!( - stored.is_none(), - "no attestation should be stored when the deposit is rejected" - ); - let storage_after = worker.view_account(contract.id()).await?.storage_usage; - assert_eq!( - storage_after, storage_before, - "contract storage must not grow when the submission is rejected" - ); - Ok(()) -} - -/// A first submission is stored, and only the actual storage delta is kept; the -/// excess over the true cost is refunded. -#[tokio::test] -async fn submit_participant_info__should_store_new_attestation_and_keep_only_the_storage_delta() --> 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(); - - // when - let kept = submit_participant_info_and_measure_kept_deposit( + let result = submit_participant_info( &outsider, &contract, &Attestation::Mock(MockAttestation::Valid), @@ -1126,54 +1047,12 @@ async fn submit_participant_info__should_store_new_attestation_and_keep_only_the ) .await?; - // then - let stored = get_participant_attestation(&contract, &fresh_tls_key).await?; - assert!(stored.is_some(), "the entry should be stored on-chain"); + // Then assert!( - kept > NearToken::from_yoctonear(0) && kept < SUBMIT_PARTICIPANT_INFO_DEPOSIT, - "a new entry keeps a nonzero storage delta below the full deposit, refunding the rest: \ - kept {kept}" - ); - Ok(()) -} - -/// Re-submitting an identical entry changes no stored bytes, so the second call -/// keeps nothing. -#[tokio::test] -async fn submit_participant_info__should_not_overcharge_identical_resubmission() -> 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 attestation = Attestation::Mock(MockAttestation::Valid); - let first_kept = submit_participant_info_and_measure_kept_deposit( - &outsider, - &contract, - &attestation, - &fresh_tls_key, - ) - .await?; - - // when - let resubmission_kept = submit_participant_info_and_measure_kept_deposit( - &outsider, - &contract, - &attestation, - &fresh_tls_key, - ) - .await?; - - // then - assert_eq!( - resubmission_kept, - NearToken::from_yoctonear(0), - "an identical re-submission stores no new bytes and must keep nothing: \ - first kept {first_kept}, resubmission kept {resubmission_kept}" + result.is_success(), + "zero-deposit submission must succeed: {result:?}" ); + let stored = get_participant_attestation(&contract, &fresh_tls_key).await?; + assert!(stored.is_some(), "the entry should be stored on-chain"); Ok(()) } diff --git a/crates/contract/tests/sandbox/utils/consts.rs b/crates/contract/tests/sandbox/utils/consts.rs index d5c5ddff8f..b122dad2f6 100644 --- a/crates/contract/tests/sandbox/utils/consts.rs +++ b/crates/contract/tests/sandbox/utils/consts.rs @@ -1,8 +1,6 @@ use std::time::Duration; -use near_mpc_contract_interface::{ - deposits::SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR, types::Protocol, -}; +use near_mpc_contract_interface::types::Protocol; use near_sdk::{Gas, NearToken}; /* --- Protocol defaults --- */ @@ -47,9 +45,4 @@ 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 by sandbox onboarding submissions to `submit_participant_info`, which store a new -/// entry the contract charges for; the excess over the actual storage delta is refunded. -pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT: NearToken = - NearToken::from_millinear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR); - pub const DEFAULT_MAX_TIMEOUT_TX_INCLUDED: Duration = Duration::from_secs(3); diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index d51377de26..bc7a132f9a 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -1,6 +1,5 @@ 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, TeeVerifierCodeHash}; @@ -59,58 +58,16 @@ pub async fn submit_participant_info( attestation: &Attestation, tls_key: &Ed25519PublicKey, ) -> anyhow::Result { - // Sandbox onboards not-yet-participants, which the contract charges; attach the deposit an - // operator's full-access key would (excess refunded). The production client attaches none. - 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 { + // No deposit: attestation storage is contract-funded, mirroring the production node. account .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) .args_json((attestation.clone(), tls_key.clone())) - .deposit(deposit) .max_gas() .transact() .await .map_err(Into::into) } -/// Submits an attestation and returns the non-gas amount the caller was left -/// out of pocket, i.e. the kept storage deposit after subtracting gas fees. -pub async fn submit_participant_info_and_measure_kept_deposit( - account: &Account, - contract: &Contract, - attestation: &Attestation, - tls_key: &Ed25519PublicKey, -) -> anyhow::Result { - let balance_before = account.view_account().await?.balance; - let result = submit_participant_info(account, contract, attestation, tls_key).await?; - assert!(result.is_success(), "submission should succeed: {result:?}"); - let balance_after = account.view_account().await?.balance; - let net_spent = balance_before - .checked_sub(balance_after) - .unwrap_or_else(|| { - panic!("caller balance must not increase: {balance_before} -> {balance_after}") - }); - let gas = total_gas_fee(&result); - Ok(net_spent - .checked_sub(gas) - .unwrap_or_else(|| panic!("net spend {net_spent} must cover gas {gas}"))) -} - pub async fn vote_tee_verifier_change( account: &Account, contract: &Contract, diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 3145109296..0ec9c8ab76 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -1317,10 +1317,9 @@ expression: abi }, { "name": "resolve_verification", - "doc": " Verify-quote callback: on a verifier verdict it runs the post-DCAP\n checks and stores the attestation, keeping the storage delta and\n refunding the excess. Refunds the full deposit if the attestation is not\n accepted.", + "doc": " Verify-quote callback: on a verifier verdict it runs the post-DCAP checks and stores the\n attestation (storage funded by the contract's balance). On any failure it fails the\n submitter's transaction.", "kind": "call", "modifiers": [ - "payable", "private" ], "params": { @@ -1576,10 +1575,6 @@ expression: abi [ "attestation", "DstackAttestation" - ], - [ - "caller_is_not_participant", - "bool" ] ] }, @@ -1603,9 +1598,6 @@ expression: abi "elements": "u8" } }, - "bool": { - "Primitive": 1 - }, "u32": { "Primitive": 4 }, @@ -2349,11 +2341,8 @@ expression: abi }, { "name": "submit_participant_info", - "doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously via a cross-contract\n `verify_quote` call, with [`Self::resolve_verification`] chained as its\n callback to run the post-DCAP checks and store the attestation.\n\n Storage is charged to the caller only when a new entry is stored or the caller is not a\n current participant. A participant re-attesting an existing entry grows nothing and is charged\n nothing, so the node's function-call access key can re-attest with no deposit. A new entry\n costs the measured storage delta, which the caller must cover (an operator's full-access key\n funds a first submission); the excess is refunded, as is the full deposit on a rejected\n attestation.", + "doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously via a cross-contract\n `verify_quote` call, with [`Self::resolve_verification`] chained as its\n callback to run the post-DCAP checks and store the attestation.\n\n Storage is funded by the contract's own balance, so a node submits with no deposit via its\n function-call access key, for its first attestation and re-attestations alike.", "kind": "call", - "modifiers": [ - "payable" - ], "params": { "serialization_type": "json", "args": [ diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index a08a9a1a8e..1c7c40a429 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -49,10 +49,6 @@ pub fn cluster_poll_retry() -> ConstantBuilder { } const NODE_MANAGEMENT_DEPOSIT: near_kit::NearToken = near_kit::NearToken::from_yoctonear(1); -// Covers a new attestation entry's storage cost; the contract refunds the excess. The contract test -// submit_participant_info__should_bound_worst_case_entry_cost asserts the worst-case entry stays -// under this amount. -const ATTESTATION_STORAGE_DEPOSIT: near_kit::NearToken = near_kit::NearToken::from_millinear(100); // The contract's default `key_event_timeout_blocks = 30` is ~18 s on // mainnet (~600 ms blocks). The e2e sandbox runs ~8 blocks/s, so the // same 30 collapses to ~3.7 s — too tight for the resharing @@ -563,8 +559,6 @@ impl MpcCluster { new_participants: &[usize], new_threshold: usize, ) -> anyhow::Result<()> { - self.fund_new_participant_attestations(new_participants) - .await?; self.wait_for_participant_attestations(new_participants) .await?; @@ -617,42 +611,6 @@ impl MpcCluster { .map(|_| ()) } - /// Submit each new participant's first attestation with a deposit covering the new entry's - /// storage cost, which the node's own function-call key cannot attach on-chain. Signed with the - /// node's near_signer_key so the stored account_public_key matches the key it later signs - /// key-event votes with. Idempotent: the node's later self-submit of the same entry is free. - async fn fund_new_participant_attestations( - &self, - node_indices: &[usize], - ) -> anyhow::Result<()> { - for &idx in node_indices { - let node = &self.nodes[idx]; - let client = self - .blockchain - .client_for(node.account_id().as_ref(), node.near_signer_key())?; - let pubkey = node.p2p_public_key(); - let outcome = self - .contract - .call_from_deposit( - &client, - method_names::SUBMIT_PARTICIPANT_INFO, - json!({ - "proposed_participant_attestation": Attestation::Mock(MockAttestation::Valid), - "tls_public_key": pubkey, - }), - ATTESTATION_STORAGE_DEPOSIT, - ) - .await - .with_context(|| format!("failed to fund attestation for node {idx}"))?; - anyhow::ensure!( - outcome.is_success(), - "funding attestation for node {idx} reverted: {:?}", - outcome.failure_message() - ); - } - Ok(()) - } - /// Poll until all proposed participants have TEE attestations on-chain. async fn wait_for_participant_attestations( &self, @@ -1224,13 +1182,6 @@ impl MpcNodeState { } } - pub fn near_signer_key(&self) -> &SigningKey { - match self { - MpcNodeState::Running(n) => n.setup().near_signer_key(), - MpcNodeState::Stopped(s) => s.near_signer_key(), - } - } - pub fn p2p_public_key_str(&self) -> String { String::from(&self.p2p_public_key()) } diff --git a/crates/near-mpc-contract-interface/src/deposits.rs b/crates/near-mpc-contract-interface/src/deposits.rs index 39c38c4ccf..baf35b9ef4 100644 --- a/crates/near-mpc-contract-interface/src/deposits.rs +++ b/crates/near-mpc-contract-interface/src/deposits.rs @@ -1,9 +1,4 @@ //! Deposit amounts to attach to contract methods. One shared value for node, //! tests, and e2e. -/// Deposit for `submit_participant_info`. Sized to cover the worst-case -/// attestation entry; the contract keeps only the actual storage delta and -/// refunds the rest. -pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR: u128 = 100; - pub const SIGN_DEPOSIT_YOCTONEAR: u128 = 1; diff --git a/docs/securing-mpc-with-tee-design-doc.md b/docs/securing-mpc-with-tee-design-doc.md index c03eeb60c0..dd646e6d5a 100644 --- a/docs/securing-mpc-with-tee-design-doc.md +++ b/docs/securing-mpc-with-tee-design-doc.md @@ -386,7 +386,7 @@ pub struct Contract { } ``` -_Note_: submit_participant_info - can be called either by the node or by the operator. Storage is charged only when a new entry is stored or the caller is not a current participant, so a participant re-attesting an existing entry attaches no deposit and the node's function-call access key can re-attest. A first-time (join) submission stores a new entry, so it must attach a deposit to cover it; that submission is made by the operator's full-access key (step 6), since a function-call key cannot attach a deposit. +_Note_: submit_participant_info - can be called either by the node or by the operator. Attestation storage is funded by the contract's own balance, so submissions attach no deposit and the node's function-call access key works for both a first-time (join) submission and re-attestations. ## MPC Node changes: From 7b7444ccb52dbd12070ea6ae2782c218d5cae228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 24 Jul 2026 13:35:10 +0200 Subject: [PATCH 08/23] refactor(contract): drop now-vestigial attestation-store flush Once storage is contract-funded, nothing reads env::storage_usage() after the store, so the explicit flush in store_verified_attestation is dead: IterableMap flushes on Drop at end-of-call, so persistence is unchanged. Remove the flush, its comments, and the test that only guarded immediate delta visibility. Also reword one stale comment referring to the removed caller-funded model. --- crates/contract/src/lib.rs | 2 +- crates/contract/src/tee/tee_state.rs | 28 ---------------------------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index f55dcf5f6c..1ead8801bc 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -8075,7 +8075,7 @@ mod tests { const MAX_HASH: [u8; 32] = [0xff; 32]; - // A stored entry a caller must fund stays bounded; a schema change that bloats it fails here. + // The contract-funded entry stays bounded; a schema change that bloats it fails here. const WORST_CASE_ENTRY_COST_CEILING: NearToken = NearToken::from_millinear(100); #[rstest] diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index b7915a1499..9d10d68f72 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -211,10 +211,6 @@ impl TeeState { /// entry was newly inserted or updated an existing one. Rejects a submission whose /// TLS key is already registered to a different account with /// [`AttestationSubmissionError::TlsKeyOwnedByOtherAccount`]. - /// - /// Flushes the insert before returning, so a caller's subsequent - /// [`env::storage_usage`] reflects the stored bytes and can be used to charge - /// the storage delta. fn store_verified_attestation( &mut self, node_id: NodeId, @@ -240,9 +236,6 @@ impl TeeState { }, ); - // Flush the deferred write now so the caller's storage_usage read charges for it. - self.stored_attestations.flush(); - Ok(match previous { Some(_) => ParticipantInsertion::UpdatedExistingParticipant, None => ParticipantInsertion::NewlyInsertedParticipant, @@ -853,27 +846,6 @@ mod tests { ); } - #[test] - fn verify_and_store_mock__should_flush_so_storage_usage_grows() { - // given - testing_env!(VMContextBuilder::new().build()); - let mut tee_state = TeeState::default(); - let node_id = node_id_for(&"alice.near".parse().unwrap()); - let storage_before = env::storage_usage(); - - // when - tee_state - .verify_and_store_mock(node_id, MockAttestation::Valid, Duration::from_secs(0)) - .unwrap(); - - // then: without the internal flush the deferred write reads as a zero delta. - let storage_after = env::storage_usage(); - assert!( - storage_after > storage_before, - "env::storage_usage() should grow after the store ({storage_before} -> {storage_after})" - ); - } - #[test] fn verify_and_store_mock__should_index_by_tls_key() { // given From e693f1c843a5c4e642e9118206ea3d4d83414388 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 24 Jul 2026 13:53:18 +0200 Subject: [PATCH 09/23] test(contract): drop dead deposit context, fix stale refund naming submit_participant_info and resolve_verification no longer take a deposit, so the attached_deposit(1 NEAR) set in their unit-test contexts is dead context; remove it. Rename the tee_verifier sandbox refund helper/test to reflect that a failed submission now costs only gas (no deposit to refund). --- crates/contract/src/lib.rs | 5 ----- crates/contract/tests/sandbox/tee_verifier.rs | 11 ++++++----- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 1ead8801bc..7af86f4b88 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -4169,7 +4169,6 @@ mod tests { let participant_context = VMContextBuilder::new() .signer_account_id(account_id.clone()) .predecessor_account_id(account_id.clone()) - .attached_deposit(NearToken::from_near(1)) .build(); testing_env!(participant_context); @@ -4598,7 +4597,6 @@ mod tests { let ctx = VMContextBuilder::new() .signer_account_id(participant_id.clone()) .predecessor_account_id("outsider.near".parse().unwrap()) - .attached_deposit(NearToken::from_near(1)) .build(); testing_env!(ctx); @@ -4613,7 +4611,6 @@ mod tests { let context = VMContextBuilder::new() .current_account_id(contract_account_id.clone()) .predecessor_account_id(contract_account_id) - .attached_deposit(NearToken::from_near(1)) .block_timestamp(VALID_ATTESTATION_TIMESTAMP * 1_000_000_000) .build(); testing_env!(context); @@ -4695,7 +4692,6 @@ mod tests { let ctx = VMContextBuilder::new() .signer_account_id(outsider_id.clone()) .predecessor_account_id(outsider_id.clone()) - .attached_deposit(NearToken::from_near(1)) .build(); testing_env!(ctx); @@ -4757,7 +4753,6 @@ mod tests { VMContextBuilder::new() .signer_account_id(outsider_id.clone()) .predecessor_account_id(outsider_id.clone()) - .attached_deposit(NearToken::from_near(1)) .build() ); diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index b0d221079d..b4403ba478 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -67,7 +67,7 @@ async fn submit_dstack(submitter: &Account, contract: &Contract) -> ExecutionFin /// 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. +/// attestation was stored, and the caller spent only gas. async fn assert_submission_failed_cleanly( result: &ExecutionFinalResult, contract: &Contract, @@ -93,11 +93,12 @@ async fn assert_submission_failed_cleanly( .await .unwrap(); assert!(stored.is_none(), "nothing should be stored on failure"); - assert_deposit_refunded(submitter, balance_before, result).await; + assert_only_gas_spent(submitter, balance_before, result).await; } -/// Asserts the deposit was fully refunded: with nothing stored, the caller spends only gas. -async fn assert_deposit_refunded( +/// Asserts the caller spent only gas: no deposit is attached, so a failed submission costs nothing +/// beyond gas. +async fn assert_only_gas_spent( account: &Account, balance_before: NearToken, result: &ExecutionFinalResult, @@ -165,7 +166,7 @@ async fn tee_verifier_account_id__should_return_none_until_a_verifier_is_voted_i } #[tokio::test] -async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_rejection() { +async fn submit_participant_info__should_store_nothing_on_verifier_rejection() { // Given let SandboxTestSetup { worker, From 0ca1cc9939f2baa089a53b86fe95d8c102cbbc29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 24 Jul 2026 14:07:33 +0200 Subject: [PATCH 10/23] test(contract): restore MpcContractHandle sandbox helper, add given/when/then markers Revert the sandbox submit_participant_info helper to the MpcContractHandle-based form (it attaches zero deposit, which the contract-funded method accepts), and add the Given/When/Then markers to the function-call-key test for consistency with the rest of the file. --- crates/contract/tests/sandbox/tee.rs | 4 ++++ crates/contract/tests/sandbox/utils/mpc_contract.rs | 13 ++++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index b65728b5b4..aa17a6a54c 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -283,6 +283,7 @@ async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result /// cannot attach a deposit) succeeds with zero deposit (storage is contract-funded). #[tokio::test] async fn submit_participant_info__should_accept_zero_deposit_via_function_call_key() -> Result<()> { + // Given let SandboxTestSetup { worker, contract, @@ -315,7 +316,10 @@ async fn submit_participant_info__should_accept_zero_deposit_via_function_call_k .into_result()?; let fc_account = Account::from_secret_key(account.id().clone(), fc_sk, &worker); + // When let outcome = submit_participant_info(&fc_account, &contract, &attestation, &tls_key).await?; + + // Then assert!( outcome.is_success(), "zero-deposit fc submission must succeed: {outcome:?}" diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index bc7a132f9a..9ff978c0b8 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -1,9 +1,10 @@ use std::collections::BTreeSet; -use super::transactions::all_receipts_successful; +use super::transactions::{SandboxCaller, all_receipts_successful}; use mpc_contract::tee::tee_state::NodeId; use mpc_primitives::hash::{LauncherImageHash, NodeImageHash, TeeVerifierCodeHash}; use near_mpc_contract_interface::{ + client::MpcContractHandle, method_names, types::{ Attestation, Ed25519PublicKey, GovernanceThreshold, Participants, ProtocolContractState, @@ -58,12 +59,10 @@ pub async fn submit_participant_info( attestation: &Attestation, tls_key: &Ed25519PublicKey, ) -> anyhow::Result { - // No deposit: attestation storage is contract-funded, mirroring the production node. - account - .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) - .args_json((attestation.clone(), tls_key.clone())) - .max_gas() - .transact() + // TODO(#3906): check if inlining is nicer once we ported the entire contract interface. + let contract_handle = MpcContractHandle::new(SandboxCaller(account), contract.id().clone()); + contract_handle + .submit_participant_info(attestation.clone(), tls_key.clone()) .await .map_err(Into::into) } From 29e75203d0871338b6f000ae730caef083f33969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 24 Jul 2026 14:15:11 +0200 Subject: [PATCH 11/23] docs: correct attestation-verifier deposit note to contract-funded storage The 'attached deposit' bullet described the removed delta-charge/refund/ deposit-forwarding design. Attestation storage is now contract-funded, so submit_participant_info attaches no deposit and none is forwarded or refunded. --- docs/design/attestation-verifier-contract.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 363cc014ab..4d8ad87165 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -139,7 +139,7 @@ Each [`PendingAttestation`](#mpc-contractsubmit_participant_info) holds: - **The submitter's `Attestation::Dstack` payload** — the RTMR3 event log, app-compose, and report-data the post-DCAP checks consume. - **The submitter's TLS public key** — the callback hashes it with the submitter's account public key and compares to the quote's `report_data` field, proving the enclave produced the quote for this specific submitter. -- **The attached deposit** — on success the contract keeps only the actual storage delta of the stored entry and refunds the excess; on failure the full deposit is refunded to the signer of the original `submit_participant_info` transaction. The deposit is forwarded onto the callback receipt via `.with_attached_deposit(env::attached_deposit())` in `submit_dstack_attestation`, so `resolve_verification` sees it as its own `env::attached_deposit()`; the refund recipient is the submitter `AccountId` carried in the callback's `VerificationContext`. +- No deposit is stashed: attestation storage is funded by the contract's own balance, so `submit_participant_info` attaches nothing and there is no deposit to forward to the callback or refund. - **`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. From afacfef96574556a35aea8dd272a7cae101954fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 24 Jul 2026 16:05:35 +0200 Subject: [PATCH 12/23] refactor(node): drop deposit threading from the tx path The node signs with a function-call access key, which cannot attach a deposit, so remove deposit_required() and the deposit parameter from submit_tx and create_and_sign_function_call_tx; the FunctionCallAction hardcodes a zero deposit. Also drop the now-redundant doc comment on the function-call-key regression test. --- crates/contract/tests/sandbox/tee.rs | 2 -- crates/node/src/indexer/tx_sender.rs | 5 +---- crates/node/src/indexer/tx_signer.rs | 5 ++--- crates/node/src/indexer/types.rs | 7 +------ 4 files changed, 4 insertions(+), 15 deletions(-) diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index aa17a6a54c..ba0e76440c 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -279,8 +279,6 @@ async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result Ok(()) } -/// Regression test for #3925: a participant re-attesting through a function-call access key (which -/// cannot attach a deposit) succeeds with zero deposit (storage is contract-funded). #[tokio::test] async fn submit_participant_info__should_accept_zero_deposit_via_function_call_key() -> Result<()> { // Given diff --git a/crates/node/src/indexer/tx_sender.rs b/crates/node/src/indexer/tx_sender.rs index da378427cc..7390b76ba0 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::{Balance, Gas}; +use near_indexer_primitives::types::Gas; use near_mpc_contract_interface::types::{Attestation, Ed25519PublicKey, VerifiedAttestation}; use near_time::Clock; use std::future::Future; @@ -148,7 +148,6 @@ 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?; @@ -157,7 +156,6 @@ async fn submit_tx( method, params_ser.into(), gas, - deposit, block.header.hash, block.header.height, ); @@ -343,7 +341,6 @@ 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 1152fc6d72..0fe2b99888 100644 --- a/crates/node/src/indexer/tx_signer.rs +++ b/crates/node/src/indexer/tx_signer.rs @@ -36,22 +36,21 @@ 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 { + // The node signs with a function-call access key, which cannot attach a deposit. let action = FunctionCallAction { method_name, args, gas, - deposit, + deposit: Balance::from_near(0), }; 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 01599a189c..663cf0e586 100644 --- a/crates/node/src/indexer/types.rs +++ b/crates/node/src/indexer/types.rs @@ -6,7 +6,7 @@ use k256::{ ecdsa::RecoveryId, elliptic_curve::{Curve, CurveArithmetic, ops::Reduce, point::AffineCoordinates}, }; -use near_indexer_primitives::types::{Balance, Gas}; +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, REGISTER_FOREIGN_CHAINS_CONFIG, RESPOND, RESPOND_CKD, @@ -126,11 +126,6 @@ impl ChainSendTransactionRequest { | Self::VerifyForeignTransactionRespond(_) => MAX_GAS, } } - - pub fn deposit_required(&self) -> Balance { - // The node signs with a function-call access key, which cannot attach a deposit. - Balance::from_near(0) - } } /// Extension trait for constructing SignatureRespond arguments from node-internal types. From 296539df4421de59a023533155e64830a3fa7a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 24 Jul 2026 17:16:08 +0200 Subject: [PATCH 13/23] refactor(contract): reuse storage-cost idiom for attestation entry cap Drop the duplicated per-entry storage-cost helper (entry_storage_cost was identical to update.rs's required_deposit) and inline the standard env::storage_byte_cost().saturating_mul(..) idiom at each call site, avoiding a cross-module dependency from tee_state into update. --- crates/contract/src/lib.rs | 11 ++-- crates/contract/src/tee/tee_state.rs | 81 +++++++++++++++++++++++++--- crates/contract/src/update.rs | 6 +-- 3 files changed, 80 insertions(+), 18 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 7af86f4b88..c7c4222b38 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -117,6 +117,10 @@ const MINIMUM_CKD_REQUEST_DEPOSIT: NearToken = NearToken::from_yoctonear(1); /// node key cannot invoke these methods. pub const MINIMUM_NODE_MANAGEMENT_DEPOSIT: NearToken = NearToken::from_yoctonear(1); +/// Caps the contract-funded storage cost of a single stored attestation entry, guarding against a +/// future schema/attestation-size change bloating an entry. Does not bound total attestation storage. +pub(crate) const MAX_ATTESTATION_ENTRY_STORAGE_COST: NearToken = NearToken::from_millinear(100); + /// Entries to scan in the post-reshare `clean_invalid_attestations` sweep. External /// callers may pick a different value; this only governs the automatic invocation. const RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN: u32 = 100; @@ -8070,9 +8074,6 @@ mod tests { const MAX_HASH: [u8; 32] = [0xff; 32]; - // The contract-funded entry stays bounded; a schema change that bloats it fails here. - const WORST_CASE_ENTRY_COST_CEILING: NearToken = NearToken::from_millinear(100); - #[rstest] #[case::dstack(VerifiedAttestation::Dstack(ValidatedDstackAttestation { mpc_image_hash: MAX_HASH.into(), @@ -8110,9 +8111,9 @@ mod tests { let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); assert!( - cost <= WORST_CASE_ENTRY_COST_CEILING, + cost <= MAX_ATTESTATION_ENTRY_STORAGE_COST, "worst-case entry cost ({bytes_grown} bytes, {cost}) must stay under \ - {WORST_CASE_ENTRY_COST_CEILING} at today's storage price" + {MAX_ATTESTATION_ENTRY_STORAGE_COST} at today's storage price" ); } } diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 9d10d68f72..fc1a2b7edb 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -1,4 +1,5 @@ use crate::{ + MAX_ATTESTATION_ENTRY_STORAGE_COST, primitives::{key_state::AuthenticatedParticipantId, participants::Participants}, storage_keys::StorageKey, tee::measurements::{ @@ -19,7 +20,7 @@ use mpc_attestation::{ }; use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash}; use near_mpc_contract_interface::types::{self as dtos, Ed25519PublicKey}; -use near_sdk::{env, near, store::IterableMap}; +use near_sdk::{NearToken, env, near, store::IterableMap}; use std::time::Duration; use tee_verifier_interface::VerifiedReport; @@ -45,6 +46,33 @@ pub enum AttestationSubmissionError { "TLS public key is already registered to a different account; only the owning account may update it" )] TlsKeyOwnedByOtherAccount, + #[error("stored attestation entry costs {cost} which exceeds the maximum {max}")] + EntryStorageCostExceeded { cost: NearToken, max: NearToken }, +} + +/// Rejects an attestation entry whose storage cost exceeds [`MAX_ATTESTATION_ENTRY_STORAGE_COST`], +/// estimated from the borsh-serialized key and value before inserting. Pre-insert because the dstack +/// callback commits its receipt on error, so an insert-then-measure check would keep an oversized +/// entry. +fn check_attestation_entry_storage_cost( + tls_pk: &Ed25519PublicKey, + entry: &NodeAttestation, +) -> Result<(), AttestationSubmissionError> { + let bytes = borsh::to_vec(tls_pk) + .expect("borsh serialization of tls key must succeed") + .len() + + borsh::to_vec(entry) + .expect("borsh serialization of attestation entry must succeed") + .len(); + let cost = env::storage_byte_cost() + .saturating_mul(u128::try_from(bytes).expect("usize always fits in u128")); + if cost > MAX_ATTESTATION_ENTRY_STORAGE_COST { + return Err(AttestationSubmissionError::EntryStorageCostExceeded { + cost, + max: MAX_ATTESTATION_ENTRY_STORAGE_COST, + }); + } + Ok(()) } #[derive(Debug)] @@ -228,13 +256,13 @@ impl TeeState { return Err(AttestationSubmissionError::TlsKeyOwnedByOtherAccount); } - let previous = self.stored_attestations.insert( - tls_pk, - NodeAttestation { - node_id, - verified_attestation, - }, - ); + let entry = NodeAttestation { + node_id, + verified_attestation, + }; + check_attestation_entry_storage_cost(&tls_pk, &entry)?; + + let previous = self.stored_attestations.insert(tls_pk, entry); Ok(match previous { Some(_) => ParticipantInsertion::UpdatedExistingParticipant, @@ -846,6 +874,43 @@ mod tests { ); } + fn mock_attestation_entry() -> (Ed25519PublicKey, NodeAttestation) { + let tls_pk = bogus_ed25519_public_key(); + let node_id = create_node_id(&"alice.near".parse().unwrap(), &tls_pk); + let entry = NodeAttestation { + node_id, + verified_attestation: VerifiedAttestation::Mock(MockAttestation::Valid), + }; + (tls_pk, entry) + } + + #[test] + fn check_attestation_entry_storage_cost__should_accept_entry_within_cap() { + // Given + testing_env!(VMContextBuilder::new().build()); + let (tls_pk, entry) = mock_attestation_entry(); + + // When + let result = check_attestation_entry_storage_cost(&tls_pk, &entry); + + // Then + assert_matches!(result, Ok(())); + } + + #[test] + fn entry_cost__should_exceed_cap_once_byte_count_is_large_enough() { + // Given + testing_env!(VMContextBuilder::new().build()); + let byte_cost = env::storage_byte_cost().as_yoctonear(); + let bytes_over_cap = (MAX_ATTESTATION_ENTRY_STORAGE_COST.as_yoctonear() / byte_cost) + 1; + + // When + let cost = env::storage_byte_cost().saturating_mul(bytes_over_cap); + + // Then + assert!(cost > MAX_ATTESTATION_ENTRY_STORAGE_COST); + } + #[test] fn verify_and_store_mock__should_index_by_tls_key() { // given diff --git a/crates/contract/src/update.rs b/crates/contract/src/update.rs index 022f2879e4..7df7dff961 100644 --- a/crates/contract/src/update.rs +++ b/crates/contract/src/update.rs @@ -142,7 +142,7 @@ impl Default for ProposedUpdates { impl ProposedUpdates { pub fn required_deposit(update: &Update) -> NearToken { - required_deposit(bytes_used(update)) + env::storage_byte_cost().saturating_mul(bytes_used(update)) } /// Propose an update given the new contract code and/or config. @@ -276,10 +276,6 @@ fn bytes_used(update: &Update) -> u128 { bytes_used } -fn required_deposit(bytes_used: u128) -> NearToken { - env::storage_byte_cost().saturating_mul(bytes_used) -} - #[cfg(test)] mod tests { use crate::{ From 5a164a84e34c5275c0f2112485d3b6a82bdb1c06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 24 Jul 2026 17:22:00 +0200 Subject: [PATCH 14/23] test(contract): drop tautological entry-cost test, inline single-use helper The entry-cost test exercised no production code after the storage-cost helper was inlined (it asserted x+1 > x); real coverage stays in the accept-path test and the worst-case storage bound. Inline mock_attestation_entry into its one remaining caller. --- crates/contract/src/tee/tee_state.rs | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index fc1a2b7edb..133dea0f53 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -874,21 +874,16 @@ mod tests { ); } - fn mock_attestation_entry() -> (Ed25519PublicKey, NodeAttestation) { + #[test] + fn check_attestation_entry_storage_cost__should_accept_entry_within_cap() { + // Given + testing_env!(VMContextBuilder::new().build()); let tls_pk = bogus_ed25519_public_key(); let node_id = create_node_id(&"alice.near".parse().unwrap(), &tls_pk); let entry = NodeAttestation { node_id, verified_attestation: VerifiedAttestation::Mock(MockAttestation::Valid), }; - (tls_pk, entry) - } - - #[test] - fn check_attestation_entry_storage_cost__should_accept_entry_within_cap() { - // Given - testing_env!(VMContextBuilder::new().build()); - let (tls_pk, entry) = mock_attestation_entry(); // When let result = check_attestation_entry_storage_cost(&tls_pk, &entry); @@ -897,20 +892,6 @@ mod tests { assert_matches!(result, Ok(())); } - #[test] - fn entry_cost__should_exceed_cap_once_byte_count_is_large_enough() { - // Given - testing_env!(VMContextBuilder::new().build()); - let byte_cost = env::storage_byte_cost().as_yoctonear(); - let bytes_over_cap = (MAX_ATTESTATION_ENTRY_STORAGE_COST.as_yoctonear() / byte_cost) + 1; - - // When - let cost = env::storage_byte_cost().saturating_mul(bytes_over_cap); - - // Then - assert!(cost > MAX_ATTESTATION_ENTRY_STORAGE_COST); - } - #[test] fn verify_and_store_mock__should_index_by_tls_key() { // given From e845e90434c8552c423733788d57bfd1d0981944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 24 Jul 2026 18:17:21 +0200 Subject: [PATCH 15/23] refactor(contract): measure real storage delta for the attestation entry cap Replace the pre-insert borsh-size estimate with an insert-then-measure check: insert, flush, and compare the actual storage_usage delta against the cap, restoring prior state before rejecting so the dstack callback's committing error path cannot leave an oversized entry. --- crates/contract/src/tee/tee_state.rs | 72 ++++++++++++++-------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 133dea0f53..aebcdac553 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -50,31 +50,6 @@ pub enum AttestationSubmissionError { EntryStorageCostExceeded { cost: NearToken, max: NearToken }, } -/// Rejects an attestation entry whose storage cost exceeds [`MAX_ATTESTATION_ENTRY_STORAGE_COST`], -/// estimated from the borsh-serialized key and value before inserting. Pre-insert because the dstack -/// callback commits its receipt on error, so an insert-then-measure check would keep an oversized -/// entry. -fn check_attestation_entry_storage_cost( - tls_pk: &Ed25519PublicKey, - entry: &NodeAttestation, -) -> Result<(), AttestationSubmissionError> { - let bytes = borsh::to_vec(tls_pk) - .expect("borsh serialization of tls key must succeed") - .len() - + borsh::to_vec(entry) - .expect("borsh serialization of attestation entry must succeed") - .len(); - let cost = env::storage_byte_cost() - .saturating_mul(u128::try_from(bytes).expect("usize always fits in u128")); - if cost > MAX_ATTESTATION_ENTRY_STORAGE_COST { - return Err(AttestationSubmissionError::EntryStorageCostExceeded { - cost, - max: MAX_ATTESTATION_ENTRY_STORAGE_COST, - }); - } - Ok(()) -} - #[derive(Debug)] pub(crate) enum ParticipantInsertion { NewlyInsertedParticipant, @@ -260,9 +235,28 @@ impl TeeState { node_id, verified_attestation, }; - check_attestation_entry_storage_cost(&tls_pk, &entry)?; - let previous = self.stored_attestations.insert(tls_pk, entry); + let before = env::storage_usage(); + let previous = self.stored_attestations.insert(tls_pk.clone(), entry); + self.stored_attestations.flush(); + let cost = + env::storage_byte_cost().saturating_mul(u128::from(env::storage_usage() - before)); + + if cost > MAX_ATTESTATION_ENTRY_STORAGE_COST { + match previous { + Some(prev) => { + self.stored_attestations.insert(tls_pk, prev); + } + None => { + self.stored_attestations.remove(&tls_pk); + } + } + self.stored_attestations.flush(); + return Err(AttestationSubmissionError::EntryStorageCostExceeded { + cost, + max: MAX_ATTESTATION_ENTRY_STORAGE_COST, + }); + } Ok(match previous { Some(_) => ParticipantInsertion::UpdatedExistingParticipant, @@ -875,21 +869,27 @@ mod tests { } #[test] - fn check_attestation_entry_storage_cost__should_accept_entry_within_cap() { + fn store_verified_attestation__should_accept_entry_within_cap() { // Given testing_env!(VMContextBuilder::new().build()); - let tls_pk = bogus_ed25519_public_key(); - let node_id = create_node_id(&"alice.near".parse().unwrap(), &tls_pk); - let entry = NodeAttestation { - node_id, - verified_attestation: VerifiedAttestation::Mock(MockAttestation::Valid), - }; + let mut tee_state = TeeState::default(); + let node_id = node_id_for(&"alice.near".parse().unwrap()); + let verified_attestation = VerifiedAttestation::Mock(MockAttestation::Valid); // When - let result = check_attestation_entry_storage_cost(&tls_pk, &entry); + let result = tee_state.store_verified_attestation(node_id.clone(), verified_attestation); // Then - assert_matches!(result, Ok(())); + assert_matches!(result, Ok(ParticipantInsertion::NewlyInsertedParticipant)); + let stored = tee_state + .stored_attestations + .get(&node_id.tls_public_key) + .expect("attestation must be stored"); + assert_eq!(stored.node_id, node_id); + assert_matches!( + stored.verified_attestation, + VerifiedAttestation::Mock(MockAttestation::Valid) + ); } #[test] From 158e8e4a69c9258bac48a5d8d539425911f0c8a8 Mon Sep 17 00:00:00 2001 From: Barakeinav1 Date: Tue, 28 Jul 2026 08:10:21 +0000 Subject: [PATCH 16/23] revert(contract): drop the runtime attestation entry-size cap Reverts the three commits that introduced it, returning store_verified_attestation to the state approved in afacfef9: e845e904 refactor(contract): measure real storage delta for the attestation entry cap 5a164a84 test(contract): drop tautological entry-cost test, inline single-use helper 296539df refactor(contract): reuse storage-cost idiom for attestation entry cap NodeAttestation is bounded by construction - every field is fixed-width except account_id, which NEAR caps at 64 bytes - so no runtime-variable input exists for the check to catch. It only guarded against a future schema change, which submit_participant_info__should_bound_worst_case_entry_cost already covers, and it would have caught that in production by rejecting live submissions rather than in CI. It also carried an underflow: env::storage_usage() - before reads a signed delta as unsigned, so any submission that shrank the stored entry wrapped and permanently pinned that account to its larger entry. --- crates/contract/src/lib.rs | 11 +++-- crates/contract/src/tee/tee_state.rs | 62 ++++------------------------ crates/contract/src/update.rs | 6 ++- 3 files changed, 18 insertions(+), 61 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index c7c4222b38..7af86f4b88 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -117,10 +117,6 @@ const MINIMUM_CKD_REQUEST_DEPOSIT: NearToken = NearToken::from_yoctonear(1); /// node key cannot invoke these methods. pub const MINIMUM_NODE_MANAGEMENT_DEPOSIT: NearToken = NearToken::from_yoctonear(1); -/// Caps the contract-funded storage cost of a single stored attestation entry, guarding against a -/// future schema/attestation-size change bloating an entry. Does not bound total attestation storage. -pub(crate) const MAX_ATTESTATION_ENTRY_STORAGE_COST: NearToken = NearToken::from_millinear(100); - /// Entries to scan in the post-reshare `clean_invalid_attestations` sweep. External /// callers may pick a different value; this only governs the automatic invocation. const RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN: u32 = 100; @@ -8074,6 +8070,9 @@ mod tests { const MAX_HASH: [u8; 32] = [0xff; 32]; + // The contract-funded entry stays bounded; a schema change that bloats it fails here. + const WORST_CASE_ENTRY_COST_CEILING: NearToken = NearToken::from_millinear(100); + #[rstest] #[case::dstack(VerifiedAttestation::Dstack(ValidatedDstackAttestation { mpc_image_hash: MAX_HASH.into(), @@ -8111,9 +8110,9 @@ mod tests { let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); assert!( - cost <= MAX_ATTESTATION_ENTRY_STORAGE_COST, + cost <= WORST_CASE_ENTRY_COST_CEILING, "worst-case entry cost ({bytes_grown} bytes, {cost}) must stay under \ - {MAX_ATTESTATION_ENTRY_STORAGE_COST} at today's storage price" + {WORST_CASE_ENTRY_COST_CEILING} at today's storage price" ); } } diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index aebcdac553..9d10d68f72 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -1,5 +1,4 @@ use crate::{ - MAX_ATTESTATION_ENTRY_STORAGE_COST, primitives::{key_state::AuthenticatedParticipantId, participants::Participants}, storage_keys::StorageKey, tee::measurements::{ @@ -20,7 +19,7 @@ use mpc_attestation::{ }; use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash}; use near_mpc_contract_interface::types::{self as dtos, Ed25519PublicKey}; -use near_sdk::{NearToken, env, near, store::IterableMap}; +use near_sdk::{env, near, store::IterableMap}; use std::time::Duration; use tee_verifier_interface::VerifiedReport; @@ -46,8 +45,6 @@ pub enum AttestationSubmissionError { "TLS public key is already registered to a different account; only the owning account may update it" )] TlsKeyOwnedByOtherAccount, - #[error("stored attestation entry costs {cost} which exceeds the maximum {max}")] - EntryStorageCostExceeded { cost: NearToken, max: NearToken }, } #[derive(Debug)] @@ -231,32 +228,13 @@ impl TeeState { return Err(AttestationSubmissionError::TlsKeyOwnedByOtherAccount); } - let entry = NodeAttestation { - node_id, - verified_attestation, - }; - - let before = env::storage_usage(); - let previous = self.stored_attestations.insert(tls_pk.clone(), entry); - self.stored_attestations.flush(); - let cost = - env::storage_byte_cost().saturating_mul(u128::from(env::storage_usage() - before)); - - if cost > MAX_ATTESTATION_ENTRY_STORAGE_COST { - match previous { - Some(prev) => { - self.stored_attestations.insert(tls_pk, prev); - } - None => { - self.stored_attestations.remove(&tls_pk); - } - } - self.stored_attestations.flush(); - return Err(AttestationSubmissionError::EntryStorageCostExceeded { - cost, - max: MAX_ATTESTATION_ENTRY_STORAGE_COST, - }); - } + let previous = self.stored_attestations.insert( + tls_pk, + NodeAttestation { + node_id, + verified_attestation, + }, + ); Ok(match previous { Some(_) => ParticipantInsertion::UpdatedExistingParticipant, @@ -868,30 +846,6 @@ mod tests { ); } - #[test] - fn store_verified_attestation__should_accept_entry_within_cap() { - // Given - testing_env!(VMContextBuilder::new().build()); - let mut tee_state = TeeState::default(); - let node_id = node_id_for(&"alice.near".parse().unwrap()); - let verified_attestation = VerifiedAttestation::Mock(MockAttestation::Valid); - - // When - let result = tee_state.store_verified_attestation(node_id.clone(), verified_attestation); - - // Then - assert_matches!(result, Ok(ParticipantInsertion::NewlyInsertedParticipant)); - let stored = tee_state - .stored_attestations - .get(&node_id.tls_public_key) - .expect("attestation must be stored"); - assert_eq!(stored.node_id, node_id); - assert_matches!( - stored.verified_attestation, - VerifiedAttestation::Mock(MockAttestation::Valid) - ); - } - #[test] fn verify_and_store_mock__should_index_by_tls_key() { // given diff --git a/crates/contract/src/update.rs b/crates/contract/src/update.rs index 7df7dff961..022f2879e4 100644 --- a/crates/contract/src/update.rs +++ b/crates/contract/src/update.rs @@ -142,7 +142,7 @@ impl Default for ProposedUpdates { impl ProposedUpdates { pub fn required_deposit(update: &Update) -> NearToken { - env::storage_byte_cost().saturating_mul(bytes_used(update)) + required_deposit(bytes_used(update)) } /// Propose an update given the new contract code and/or config. @@ -276,6 +276,10 @@ fn bytes_used(update: &Update) -> u128 { bytes_used } +fn required_deposit(bytes_used: u128) -> NearToken { + env::storage_byte_cost().saturating_mul(bytes_used) +} + #[cfg(test)] mod tests { use crate::{ From 74ea6a0ea0963ac475e4a65f8d0293a550909ce4 Mon Sep 17 00:00:00 2001 From: Barakeinav1 Date: Tue, 28 Jul 2026 08:24:05 +0000 Subject: [PATCH 17/23] test(contract): pin the attestation entry size, tighten the cost ceiling With the runtime cap reverted, these tests are the only guard on how much storage the contract funds per attestation entry. Pin the exact measured size of each VerifiedAttestation variant (599 bytes Dstack, 604 bytes Mock) so any layout change fails CI and forces a deliberate decision, rather than silently changing what the contract pays per node. Add WORST_CASE_ENTRY_BYTES as the pinned worst case, and a doc comment stating what must be revisited before the numbers are updated. Tighten WORST_CASE_ENTRY_COST_CEILING from 0.1 to 0.01 NEAR. The old value was inherited from the flat deposit MINIMUM_ATTESTATION_STORAGE_DEPOSIT, where wide margin was wanted; as a regression ceiling it left 16x slack and would not have caught a schema change short of a 16x bloat. Extract the shared measurement into measure_stored_entry_bytes so both tests share one definition of the worst case. --- crates/contract/src/lib.rs | 93 ++++++++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 20 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 7af86f4b88..6a19c1f43c 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -8070,27 +8070,38 @@ mod tests { const MAX_HASH: [u8; 32] = [0xff; 32]; - // The contract-funded entry stays bounded; a schema change that bloats it fails here. - const WORST_CASE_ENTRY_COST_CEILING: NearToken = NearToken::from_millinear(100); + /// Charged storage of the largest attestation entry the contract can store, in bytes: + /// a 64-byte account id (NEAR's cap) plus fixed-width keys and the largest + /// [`VerifiedAttestation`] variant, including the `IterableMap` record overhead. + const WORST_CASE_ENTRY_BYTES: u64 = 604; + + /// Ceiling on one entry's storage cost at today's price, with headroom over + /// [`WORST_CASE_ENTRY_BYTES`] for storage-price changes. + const WORST_CASE_ENTRY_COST_CEILING: NearToken = NearToken::from_millinear(10); + + fn worst_case_dstack_attestation() -> VerifiedAttestation { + VerifiedAttestation::Dstack(ValidatedDstackAttestation { + mpc_image_hash: MAX_HASH.into(), + launcher_compose_hash: MAX_HASH.into(), + expiry_timestamp_seconds: u64::MAX, + measurements: default_measurements()[0], + }) + } - #[rstest] - #[case::dstack(VerifiedAttestation::Dstack(ValidatedDstackAttestation { - mpc_image_hash: MAX_HASH.into(), - launcher_compose_hash: MAX_HASH.into(), - expiry_timestamp_seconds: u64::MAX, - measurements: default_measurements()[0], - }))] - #[case::mock(VerifiedAttestation::Mock(MpcMockAttestation::WithConstraints { - mpc_docker_image_hash: Some(MAX_HASH.into()), - launcher_docker_compose_hash: Some(MAX_HASH.into()), - expiry_timestamp_seconds: Some(u64::MAX), - expected_measurements: Some(default_measurements()[0]), - }))] - fn submit_participant_info__should_bound_worst_case_entry_cost( - #[case] verified_attestation: VerifiedAttestation, - ) { + fn worst_case_mock_attestation() -> VerifiedAttestation { + VerifiedAttestation::Mock(MpcMockAttestation::WithConstraints { + mpc_docker_image_hash: Some(MAX_HASH.into()), + launcher_docker_compose_hash: Some(MAX_HASH.into()), + expiry_timestamp_seconds: Some(u64::MAX), + expected_measurements: Some(default_measurements()[0]), + }) + } + + /// Storage the contract pays for one stored attestation entry, measured the way the runtime + /// charges it. NEAR caps an account id at 64 bytes; every other `NodeId` field is fixed-size, + /// so this is the worst case for the given attestation variant. + fn measure_stored_entry_bytes(verified_attestation: VerifiedAttestation) -> u64 { testing_env!(VMContextBuilder::new().build()); - // NEAR caps an account id at 64 bytes; every other NodeId field is fixed-size. let node_id = create_node_id( &"a".repeat(64).parse().unwrap(), &bogus_ed25519_public_key(), @@ -8106,9 +8117,51 @@ mod tests { }, ); tee_state.stored_attestations.flush(); - let bytes_grown = env::storage_usage() - before; + + env::storage_usage() - before + } + + /// Pins the exact stored size of every attestation variant. + /// + /// Do not update these numbers just to make a failing case pass. The contract funds every + /// entry from its own balance, so a size change alters what the contract pays per node, and + /// everything derived from it must be revisited in the same change — today + /// [`WORST_CASE_ENTRY_BYTES`] and [`WORST_CASE_ENTRY_COST_CEILING`]. + /// + /// TODO(#3972): the flat onboarding deposit will be derived from these sizes too. + #[rstest] + #[case::dstack(599, worst_case_dstack_attestation())] + #[case::mock(604, worst_case_mock_attestation())] + fn submit_participant_info__should_store_exactly_the_pinned_entry_size( + #[case] expected_bytes: u64, + #[case] verified_attestation: VerifiedAttestation, + ) { + // Given / When + let bytes_stored = measure_stored_entry_bytes(verified_attestation); + + // Then + assert_eq!( + bytes_stored, expected_bytes, + "stored entry size changed; see this test's doc comment before updating the number" + ); + assert!( + bytes_stored <= WORST_CASE_ENTRY_BYTES, + "entry ({bytes_stored} bytes) exceeds the pinned worst case \ + ({WORST_CASE_ENTRY_BYTES} bytes)" + ); + } + + #[rstest] + #[case::dstack(worst_case_dstack_attestation())] + #[case::mock(worst_case_mock_attestation())] + fn submit_participant_info__should_bound_worst_case_entry_cost( + #[case] verified_attestation: VerifiedAttestation, + ) { + // Given / When + let bytes_grown = measure_stored_entry_bytes(verified_attestation); let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown)); + // Then assert!( cost <= WORST_CASE_ENTRY_COST_CEILING, "worst-case entry cost ({bytes_grown} bytes, {cost}) must stay under \ From 1428b4dc38ad8b60534e39c76ad4e239187c8086 Mon Sep 17 00:00:00 2001 From: Barakeinav1 Date: Tue, 28 Jul 2026 09:06:52 +0000 Subject: [PATCH 18/23] docs: mark the superseded yield-resume sections with TODO(#3825) The 'Handling failures' and 'Contract state changes' sections describe a yield-resume design that was replaced by the no-yield promise chain: PendingAttestation, pending_attestations, data_id, the 'already pending' guard and on_attestation_verified do not exist in the code. #3825 tracks rewriting them; mark them until it lands so readers are not misled. --- docs/design/attestation-verifier-contract.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 7437c29da4..b504a87d72 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -105,6 +105,11 @@ The only caller of `submit_participant_info` in production is `mpc-node`'s `peri #### Handling failures +> **TODO(#3825):** This subsection and [§Contract state changes](#contract-state-changes) below still +> describe the superseded yield-resume design. The shipped flow is a no-yield promise chain, and +> `PendingAttestation`, `pending_attestations`, `data_id`, the "already pending" guard, and +> `on_attestation_verified` do not exist in the code. + 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. 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. From d12a0c86b3693dd1d8bd373cf8217777496fed23 Mon Sep 17 00:00:00 2001 From: Barakeinav1 Date: Tue, 28 Jul 2026 09:16:09 +0000 Subject: [PATCH 19/23] docs: submit_participant_info is node-submission only The note claimed the call could be made by the node or the operator. The quote's report_data binds hash(tls_public_key, account_public_key) to env::signer_account_pk(), and verify_report_data enforces it, so a submission signed by an operator key fails verification. assert_caller_is_signer rules out a proxy contract as well. --- docs/securing-mpc-with-tee-design-doc.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/securing-mpc-with-tee-design-doc.md b/docs/securing-mpc-with-tee-design-doc.md index dd646e6d5a..999416ef80 100644 --- a/docs/securing-mpc-with-tee-design-doc.md +++ b/docs/securing-mpc-with-tee-design-doc.md @@ -386,7 +386,7 @@ pub struct Contract { } ``` -_Note_: submit_participant_info - can be called either by the node or by the operator. Attestation storage is funded by the contract's own balance, so submissions attach no deposit and the node's function-call access key works for both a first-time (join) submission and re-attestations. +_Note_: submit_participant_info must be called by the node itself - the quote's report_data binds hash(tls_public_key, account_public_key) to env::signer_account_pk(), so a submission signed by any other key fails verification. Attestation storage is funded by the contract's own balance, so submissions attach no deposit and the node's function-call access key works for both a first-time (join) submission and re-attestations. ## MPC Node changes: From aae164f9b8ee0703175215b8d5ffe647d749763a Mon Sep 17 00:00:00 2001 From: Barakeinav1 Date: Tue, 28 Jul 2026 10:12:01 +0000 Subject: [PATCH 20/23] docs: drop the stale deposit guidance, widen the superseded-design banner The operator runbook told first-time joiners to call submit_participant_info with --deposit. submit_participant_info is no longer payable, so near-sdk's generated guard panics with 'doesn't accept deposit' on any attached amount, and the 'Attached deposit is lower than required' error it documented can no longer come from this method. Remove that bullet and the 'contract's deposit logic' clause above it. Replace the per-subsection TODO(#3825) in the attestation-verifier design doc with a file-level Status banner. The subsection marker covered two of the roughly fifteen stale yield-resume and deposit references; the rest -- the submission-flow prose, the mermaid diagrams, and the mpc-contract API proposal -- sit outside it and read as current design. --- docs/design/attestation-verifier-contract.md | 7 ++----- docs/running-an-mpc-node-in-tdx-external-guide.md | 3 +-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index b504a87d72..1086b3e1e6 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -1,5 +1,7 @@ # Attestation Verifier Contract Breakout +**Status:** Partially superseded — TODO(#3825). The `mpc-contract` side shipped as a no-yield promise chain, so every mention below of yield-resume, `pending_attestations` / `PendingAttestation`, `data_id`, the "already pending" guard, `on_attestation_verified`, and the attached deposit and its refunds describes a design that was not built. The verifier-contract split, governance, crate layout, and the verifier's own API are accurate. + This document outlines the design for moving on-chain TDX quote verification out of `mpc-contract`'s WASM into a standalone verifier contract. It supersedes [#3160](https://github.com/near/mpc/pull/3160), which sketched a three-contract architecture (shared verifier + per-team policy contract + TEE-agnostic application contract) for Defuse, Proximity, and other teams. That direction was deferred: a shared policy contract presumes shared lifecycle conventions (the [launcher pattern][launcher-pattern] `mpc-contract` uses), and aligning the other teams on those conventions is a separate, longer [conversation][slack-launcher-discussion] that has not yet converged. @@ -105,11 +107,6 @@ The only caller of `submit_participant_info` in production is `mpc-node`'s `peri #### Handling failures -> **TODO(#3825):** This subsection and [§Contract state changes](#contract-state-changes) below still -> describe the superseded yield-resume design. The shipped flow is a no-yield promise chain, and -> `PendingAttestation`, `pending_attestations`, `data_id`, the "already pending" guard, and -> `on_attestation_verified` do not exist in the code. - 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. 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. 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 33084a79c3..f507e8d827 100644 --- a/docs/running-an-mpc-node-in-tdx-external-guide.md +++ b/docs/running-an-mpc-node-in-tdx-external-guide.md @@ -2067,11 +2067,10 @@ Invalid TEE Remote Attestation: TeeQuoteStatus is invalid: the submitted attestation failed verification, reason: Custom("...") ``` -The `reason` is the same `VerificationError` the client-side WARN reports (see section 1) — for example `Custom("the allowed mpc image hashes list is empty")`. Errors that **only** surface on-chain (because they're checked against the contract's allowed-measurements list, the contract's deposit logic, or the contract's caller assertion): +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 or the contract's caller assertion): - **`MeasurementsNotAllowed`** — your boot measurements (MRTD / RTMR0–2) are not in the contract's allowed set. Vote them in (see [OS measurement voting](#os-measurement-voting)). - **`EmptyMeasurementsList`** — the contract has no allowed measurements yet; the first set must be voted in before any node can attest. -- **`Attached deposit is lower than required. Attached: X, required: Y`** — first-time joiners must attach enough yoctoNEAR for storage; the node attaches `0`, so call `submit_participant_info` manually with `--deposit` once. Exact amount tracked in [#903](https://github.com/near/mpc/issues/903). - **`Caller is not the signer account.`** — the access key used to sign does not match the node's `my_near_account_id`. If you see no error logs at all but `get_attestation` still returns `null`, the node has not yet generated a quote. Check `mpc_tee_attestation_attempts_total` on the `/metrics` endpoint. From d8c35796ea0fe8a74f1c1d8903ae9d7ba5141b18 Mon Sep 17 00:00:00 2001 From: Barakeinav1 Date: Tue, 28 Jul 2026 10:12:01 +0000 Subject: [PATCH 21/23] test(contract): name the entry-size tests after their subject Both tests insert straight into tee_state.stored_attestations and never call submit_participant_info, so the old prefix promised coverage of an entry point they do not exercise. The doc comment also claimed to pin every attestation variant while only the largest of each kind is cased. --- crates/contract/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 85c567552a..dba93afa9d 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -8178,7 +8178,7 @@ mod tests { env::storage_usage() - before } - /// Pins the exact stored size of every attestation variant. + /// Pins the exact stored size of the largest variant of each attestation kind. /// /// Do not update these numbers just to make a failing case pass. The contract funds every /// entry from its own balance, so a size change alters what the contract pays per node, and @@ -8189,7 +8189,7 @@ mod tests { #[rstest] #[case::dstack(599, worst_case_dstack_attestation())] #[case::mock(604, worst_case_mock_attestation())] - fn submit_participant_info__should_store_exactly_the_pinned_entry_size( + fn stored_attestation_entry__should_have_the_pinned_size( #[case] expected_bytes: u64, #[case] verified_attestation: VerifiedAttestation, ) { @@ -8211,7 +8211,7 @@ mod tests { #[rstest] #[case::dstack(worst_case_dstack_attestation())] #[case::mock(worst_case_mock_attestation())] - fn submit_participant_info__should_bound_worst_case_entry_cost( + fn stored_attestation_entry__should_stay_under_the_cost_ceiling( #[case] verified_attestation: VerifiedAttestation, ) { // Given / When From 26c343bdd48f082096911e758c2a7b3313a94950 Mon Sep 17 00:00:00 2001 From: Barakeinav1 Date: Tue, 28 Jul 2026 10:31:12 +0000 Subject: [PATCH 22/23] test(contract): pin that submit_participant_info rejects an attached deposit Dropping #[payable] makes near-sdk's generated guard panic on any nonzero deposit rather than merely making it optional. Nothing pinned that: the old below-fee sandbox test was deleted with the deposit gate and no test asserted either direction. Assert the rejection and that nothing is stored. --- crates/contract/tests/sandbox/tee.rs | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index ba0e76440c..b717b5c45f 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -1058,3 +1058,48 @@ async fn submit_participant_info__should_store_new_entry_with_zero_deposit() -> assert!(stored.is_some(), "the entry should be stored on-chain"); Ok(()) } + +/// `submit_participant_info` is not payable: an attached deposit is rejected outright rather than +/// accepted and refunded, so a caller following stale guidance fails loudly instead of silently +/// no-opping. Pins the behaviour the removed `#[payable]` marker used to allow. +#[tokio::test] +async fn submit_participant_info__should_reject_an_attached_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(); + + // When + let result = outsider + .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) + .args_json(( + Attestation::Mock(MockAttestation::Valid), + fresh_tls_key.clone(), + )) + .deposit(NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await?; + + // Then + assert!( + !result.is_success(), + "a submission attaching a deposit must fail: {result:?}" + ); + let error_msg = format!("{:?}", result.into_result()); + assert!( + error_msg.contains("doesn't accept deposit"), + "expected a non-payable rejection, got: {error_msg}" + ); + let stored = get_participant_attestation(&contract, &fresh_tls_key).await?; + assert!( + stored.is_none(), + "nothing should be stored when the submission is rejected" + ); + Ok(()) +} From bf3fe13060b21ee2d5b4ce7b4be1b056f45795fa Mon Sep 17 00:00:00 2001 From: Barakeinav1 Date: Tue, 28 Jul 2026 11:06:08 +0000 Subject: [PATCH 23/23] docs: use the required TODO(#NNNN): format in the status banner scripts/check-todo-format.sh requires a colon immediately after the issue reference; the banner used a period, failing the fast CI checks. --- docs/design/attestation-verifier-contract.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 1086b3e1e6..77917da90e 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -1,6 +1,6 @@ # Attestation Verifier Contract Breakout -**Status:** Partially superseded — TODO(#3825). The `mpc-contract` side shipped as a no-yield promise chain, so every mention below of yield-resume, `pending_attestations` / `PendingAttestation`, `data_id`, the "already pending" guard, `on_attestation_verified`, and the attached deposit and its refunds describes a design that was not built. The verifier-contract split, governance, crate layout, and the verifier's own API are accurate. +**Status:** Partially superseded — TODO(#3825): rewrite the `mpc-contract`-side sections below. That side shipped as a no-yield promise chain, so every mention of yield-resume, `pending_attestations` / `PendingAttestation`, `data_id`, the "already pending" guard, `on_attestation_verified`, and the attached deposit and its refunds describes a design that was not built. The verifier-contract split, governance, crate layout, and the verifier's own API are accurate. This document outlines the design for moving on-chain TDX quote verification out of `mpc-contract`'s WASM into a standalone verifier contract.