feat(contract): async TEE attestation verification via verifier contract, drop dcap-qvl - #3664
Conversation
`submit_participant_info` now offloads DCAP verification for `Dstack` attestations to the standalone `tee-verifier` contract over a cross-contract call, resolved through the yield-resume pattern; `Mock` attestations stay synchronous. This drops `dcap-qvl` from the `mpc-contract` WASM. - New `pending_attestations` state (one in-flight verification per account) and `FinalOutcome` (byte-pinned borsh layout, rides the yield-resume payload). - `submit_dstack_attestation` yields and calls `verify_quote`; `resolve_verification` bridges the verifier response, runs the post-DCAP checks via `verify_with_report`, charges storage, and resumes. `on_attestation_verified` handles the yield timeout, cleaning up and refunding from a separate `fail_on_attestation_timeout` receipt. - A failed storage charge in the committing callback receipt is reverted explicitly. - `tee_verifier_account_id` stays `Option`; `None` rejects `Dstack` submits with `VerifierNotConfigured` (non-Option migration tracked in #3639). - Drops `local-verify`/`dcap-qvl` from `mpc-contract`; contract dispatches via the post-DCAP-only `verify_with_report`. - New `test-tee-verifier` stub + sandbox tests for the rejection, crash/timeout, and not-configured branches. Closes #3642
…ttestation-verification-through-the-verifier-contract-and-drop-dcap-qvl-from-mpc-contract
There was a problem hiding this comment.
Pull request overview
This PR completes the contract-side switch from in-WASM (sync) DCAP quote verification to an async cross-contract flow that calls a trusted tee-verifier contract, enabling mpc-contract to drop the dcap-qvl dependency and shrink its WASM. It introduces new pending-attestation state and callback plumbing to safely resume or timeout attestation submissions, plus a stub verifier contract and sandbox tests for key failure branches.
Changes:
- Make
submit_participant_infoasync forDstackattestations (yield →verify_quoteXCC →.thenbridge → yield callback), while keepingMocksynchronous. - Add
pending_attestationsstate andFinalOutcomewire type to arbitrate single-resume vs timeout cleanup and handle storage-charging/revert semantics in callback receipts. - Add a
test-tee-verifierstub contract and sandbox tests covering “verifier not configured”, “rejected”, and “verifier crash/timeout” branches; update config/interface DTOs and regenerate ABI/schema snapshots.
Reviewed changes
Copilot reviewed 28 out of 29 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/design/attestation-verifier-contract.md | Mark design as largely implemented and document remaining follow-ups. |
| crates/test-utils/src/contract_types.rs | Extend dummy config builder with new verifier-related gas fields. |
| crates/test-tee-verifier/src/lib.rs | Add a test-only stub verifier contract returning canned verify_quote outcomes. |
| crates/test-tee-verifier/Cargo.toml | Define stub verifier crate features/deps for ABI/schema generation. |
| crates/near-mpc-contract-interface/src/types/config.rs | Add new gas config fields to InitConfig/Config and extend serde tests. |
| crates/near-mpc-contract-interface/src/method_names.rs | Add method-name constants for new attestation callbacks and verify_quote. |
| crates/mpc-attestation/src/attestation.rs | Add verify_mock_only and update local-verify docs to reflect contract offloading. |
| crates/contract/tests/snapshots/abi__abi_has_not_changed.snap | Update ABI snapshot for new methods/return type and config fields. |
| crates/contract/tests/sandbox/utils/mpc_contract.rs | Add helpers for deposit-attached submit and has_pending_attestation view. |
| crates/contract/tests/sandbox/utils/contract_build.rs | Add builder/caching for stub verifier WASM. |
| crates/contract/tests/sandbox/upgrade_from_current_contract.rs | Update config proposal test data for new gas fields. |
| crates/contract/tests/sandbox/tee_verifier.rs | Add sandbox tests for async verifier-not-configured, rejection refund, and timeout cleanup. |
| crates/contract/tests/sandbox/mod.rs | Register new sandbox test module. |
| crates/contract/tests/sandbox/contract_configuration.rs | Update init-config test to include new gas fields. |
| crates/contract/tests/inprocess/attestation_submission.rs | Adjust in-process tests for PromiseOrValue<()> return on mock submissions. |
| crates/contract/src/v3_12_0_state.rs | Initialize pending_attestations on migration into the new state. |
| crates/contract/src/tee/tee_state.rs | Split sync mock path vs post-DCAP Dstack finishing; add explicit revert helper for async store. |
| crates/contract/src/tee/pending_attestation.rs | Add PendingAttestation state + FinalOutcome with pinned Borsh layout tests. |
| crates/contract/src/tee.rs | Export new pending_attestation module. |
| crates/contract/src/storage_keys.rs | Add storage key for pending_attestations. |
| crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap | Update contract borsh schema snapshot for new config/state fields. |
| crates/contract/src/sandbox_test_methods.rs | Add sandbox-only view has_pending_attestation. |
| crates/contract/src/lib.rs | Implement async Dstack submission, resolve callback, yield callback/timeout failure, deposit refund + storage charging refactor. |
| crates/contract/src/errors.rs | Add new TeeError variants for verifier-not-configured and already-pending verification. |
| crates/contract/src/dto_mapping.rs | Map new config fields between interface DTOs and contract config. |
| crates/contract/src/config.rs | Add defaults for verifier/callback gas attachments. |
| crates/contract/Cargo.toml | Drop mpc-attestation local-verify feature usage; add verifier-interface schema feature and new deps. |
| Cargo.toml | Add test-tee-verifier to workspace members/deps. |
| Cargo.lock | Lockfile updates for new crate and updated dependency graph. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let (quote, collateral) = (dstack.quote.clone(), dstack.collateral.clone()); | ||
| let attached_deposit = env::attached_deposit(); | ||
| let tls_public_key = node_id.tls_public_key.clone(); | ||
|
|
||
| self.enqueue_yield_request( | ||
| method_names::ON_ATTESTATION_VERIFIED, | ||
| borsh::to_vec(&account_id).expect("borsh serialization of account_id must succeed"), | ||
| Gas::from_tgas(self.config.on_attestation_verified_tera_gas), | ||
| |this, data_id| { | ||
| this.pending_attestations.insert( | ||
| account_id.clone(), | ||
| PendingAttestation { | ||
| dstack, | ||
| tls_public_key, | ||
| attached_deposit, | ||
| caller_is_not_participant, | ||
| data_id, | ||
| }, |
- Rename `fail_on_attestation_timeout` to `fail_attestation_submission`: it is reached for every failure outcome (verifier rejection, post-DCAP failure, and yield timeout), not just timeouts, so the old name was misleading. - Reorder `submit_dstack_attestation` so the detached `verify_quote` promise chain is built before `enqueue_yield_request`, keeping the latter's `promise_return` the final host call per its documented invariant. Behavior is unchanged; only statement order differs. - Regenerate the ABI snapshot for the renamed method.
Order the `attestation` and `tee-verifier-interface` dependencies added for the async verifier flow so `cargo sort --grouped` passes.
- `submit_participant_info` returns `Result<(), Error>` instead of `Result<PromiseOrValue<()>, Error>`, matching the `sign` / `ckd` / `verify_foreign_transaction` yield producers; `submit_dstack_attestation` ends on `enqueue_yield_request` so its `promise_return` is the final host call (resolves the "must be the last operation" invariant). - Flesh out the `submit_participant_info` rustdoc: sync `Mock` vs async `Dstack`, the `VerifierNotConfigured` / `VerificationAlreadyPending` errors, and refund-on-failure behavior. - `verify_mock_only`'s `Dstack` arm is `unreachable!()` (guaranteed-dead path). - Clarify the `on_attestation_verified` gas-budget docstring (covers the heavier timeout branch). - Serialize the verify_quote args by reference to avoid cloning the quote / collateral; read `tls_public_key` from the pending entry in `finish_verified_attestation` for rollback symmetry. - Rename the sandbox tests to the `<sut>__should_<assertion>` form. - Regenerate the ABI snapshot. A Dstack rejection resolves in the verifier's response receipt (a later receipt than the original call), so the outcome is asserted via contract state rather than the original transaction's result.
The sandbox tests build the stub by manifest path via `ContractBuilder`, not as a Cargo dependency, so the `[workspace.dependencies]` alias was never consumed and `cargo shear` flagged it. The workspace member entry is kept so the crate is still built and checked.
…ication `resolve_verification` is public, so linking `[`Self::submit_dstack_attestation`]` (a private fn) tripped `rustdoc::private_intra_doc_links` under `-D warnings`, failing `cargo doc`. Refer to it by name instead.
The doc-link fix changed `resolve_verification`'s rustdoc, which the ABI captures in its `doc` field; regenerate the snapshot to match.
- Drop the redundant `Attestation::verify_mock_only` wrapper: `add_mock_participant` already holds a `MockAttestation` and now calls `MockAttestation::verify` directly (the wrapper only round-tripped through the `Attestation` enum to match the Mock arm back out, and its `Dstack` arm was an unreachable panic). - Reword the `InvalidAttestation` error prefix from "TeeQuoteStatus is invalid" to "attestation verification failed": the wrapped error is an `attestation::VerificationError` (allowlist / measurement / report-data checks), not a `TeeQuoteStatus`.
…ttestation-verification-through-the-verifier-contract-and-drop-dcap-qvl-from-mpc-contract
The v3.13.0 release bumped existing crates' lockfile versions, but test-tee-verifier is new on this branch and was still pinned at 3.12.0.
…ording Replace bare-backtick code references in the async TEE attestation doc comments with [`...`] intra-doc links where the item is linkable and plain prose otherwise, so cargo doc catches drift instead of letting it rot silently. Tighten the same comments. Align the verifier-contract design doc with the shipped implementation (Result<(), Error> return, Option<AccountId> verifier, separate fail_attestation_submission receipt) and update the two TDX guides for the renamed attestation-verification error string.
Drop four throwaway intermediate bindings flagged as redundant: the millisecond temporary in current_time_seconds, and the let result; Ok(result) tails in the three new sandbox test helpers (submit_participant_info_with_deposit, has_pending_attestation, vote_tee_verifier_change). No behavior change.
dcap-qvl
…ttestation-verification-through-the-verifier-contract-and-drop-dcap-qvl-from-mpc-contract
…lone `finish_verified_attestation` cloned `pending.dstack` (the TDX quote and collateral buffers) into `finish_dstack_verify` even though verification only ever borrows it: the owned value was wrapped in `Attestation::Dstack(_)` to reach the `&self` `verify_with_report`, then dropped. Take `&DstackAttestation` and call the `DstackVerify::verify` trait method directly on the borrow, so the clone is gone and `resolve_verification`'s single-remove control flow is untouched.
| let pending = self.pending_attestations.remove(&account_id).expect( | ||
| "checked contains_key above; no host call between mutates pending_attestations", | ||
| ); |
| //! The `Verified` + post-DCAP-pass path (attestation stored) additionally needs | ||
| //! a stub report matching the fixture's post-DCAP expectations; it is a planned | ||
| //! follow-up once the off-chain report helper is wired into the sandbox harness. | ||
| //! The post-DCAP logic itself is unit-tested in `mpc-attestation`. |
The attestation flow resumed its yield with a Borsh-encoded `FinalOutcome`, while the contract's other three yield-resume callbacks (sign, CKD, foreign-tx) all use JSON. `FinalOutcome` is a private, internal payload that never leaves `mpc-contract`, so there is no reason for it to differ. Switch it and `on_attestation_verified`'s args to JSON, matching the existing convention. The verifier-interface leg (`verify_quote` args/return, `VerificationResult`, `resolve_verification`'s callback arg) stays Borsh: that is the cross-contract wire format shared with external verifier callers, not the internal resume leg.
The TEE verifier vote storage keys already dropped their V1 suffix on this branch; `PendingAttestationsV1` was the lone new key still carrying one. Rename it to `PendingAttestations` so the keys added in this PR are consistent. The borsh storage key derives from the enum variant's position, not its name, and this is the last variant, so the on-chain key is unchanged.
`resolve_verification` looked up the same `pending_attestations` entry three times (a `contains_key` guard, a `get().expect()` inside `finish_verified_attestation`, and a trailing `remove().expect()`), with two `expect()`s that only existed because the guard's presence guarantee was invisible to the type system. Remove the entry once, up front, into an owned binding via `let Some(...) else` (the idiom the sibling yield flows use), and pass it by reference to `finish_verified_attestation`. Three lookups collapse to one and both data-presence `expect()`s are gone; behavior is unchanged. The no-verdict arm is handled before the remove so the entry survives for the timeout path. The remaining `expect()` on `serde_json::to_vec` is a genuine can't-fail invariant.
`FinalOutcome` said nothing about what it was the outcome of. Rename it to `AttestationResult` to match the domain (it is the pass/fail result of an attestation submission, returned to the caller) and the codebase's operation-prefixed naming (`TeeValidationResult`, `SignatureResult`). The name also avoids confusion with `tee_verifier_interface::VerificationResult`, the verifier's Verified/Rejected answer that `resolve_verification` consumes one line away.
… helper
Drop `map_attestation_submission_error` and make `AttestationSubmissionError` a
transparent `#[from]` variant of `Error`, matching every other domain error in
the contract (RespondError, TeeError, DomainError, ...). The call site uses `?`.
This also removes a double-prefixed message ("attestation verification failed:
the submitted attestation failed verification, ..."); the user-facing error is
now the transparent domain message.
Also use `String::to_string` over `.clone()` in the test verifier stub.
… docs Flip `caller_is_not_participant` to `caller_is_participant` (a non-negated boolean is easier to reason about), inverting its derivation and simplifying the storage-charge guard from `!(is_new || not_participant)` to the positive `caller_is_participant && !is_new_attestation`. Also move the `tee_upgrade_deadline_duration` binding into the Mock arm that is its only user (the Dstack path recomputes it at resolution time), revert the unrelated `enqueue_yield_request` doc comment to match main, fix the `pending_attestations` intra-doc link to the resolvable `crate::`-prefixed form, and tighten the `submit_participant_info` and verifier-call comments.
The #[cfg(test)] add_participant helper only matched Attestation::Mock through to add_mock_participant and panicked on Dstack, a branch no test exercised. Delete it and have the tests call add_mock_participant directly, passing the inner MockAttestation. Also tidy nearby doc comments and inline a single-use binding in add_mock_participant.
store_verified_attestation and finish_dstack_verify returned (ParticipantInsertion, Option<NodeAttestation>), but the enum was just previous.is_some() re-encoded. Carry the overwritten entry inside the UpdatedExistingParticipant variant so both return a single value, and have revert_dstack_store recover it from there.
Tidy the async-attestation test code and docs: - reuse test_tee_verifier::StubResponse instead of a hand-copied mirror - collapse submit_participant_info into submit_participant_info_with_deposit - parametrize the AttestationResult round-trip test with rstest - import near_workspaces/wycheproof types instead of fully-qualified paths - drop obvious comments and trim verbose docs; remove em dashes - regenerate the ABI snapshot for the trimmed doc comments
…ttestation-verification-through-the-verifier-contract-and-drop-dcap-qvl-from-mpc-contract
Depending on the test-tee-verifier #[near] contract crate as a Rust dev-dependency broke 'cargo test --all-features': StubResponse's abi-gated BorshSchema bound went unsatisfied and the two contracts' __near_abi_contract_source_metadata symbols collided at link time. Revert to the local wire-only mirror (the stub stays a WASM artifact) and drop the dev-dependency. Also turn the dangling intra-doc links in pending_attestation.rs (left without their reference definitions after the module doc was shortened) into prose, fixing the broken-intra-doc-links doc check.
The overwrite-rejection test asserted the pre-refactor InvalidTeeRemoteAttestation error; this branch surfaces that rejection as the dedicated AttestationSubmissionError::TlsKeyOwnedByOtherAccount variant. Match the typed error and drop the now-unused InvalidParameters import.
…ttestation-verification-through-the-verifier-contract-and-drop-dcap-qvl-from-mpc-contract
These two test files come from a separately-merged PR, not this branch. They were edited during a branch-wide comment/import cleanup before main was merged, so the changes were out of scope. Restore them to match main.
The attestation fail-promise borrowed fail_on_timeout_tera_gas, a config field named for the sign/CKD/foreign-tx timeout path. Add a dedicated fail_attestation_submission_tera_gas (default 2 Tgas, the same trivial panic cost) so the budget matches the method it funds and tuning one no longer silently affects the other. Threaded through the interface DTO, Config, dto_mapping, defaults, and test configs; ABI and borsh-schema snapshots regenerated.
|
JFYI for anyone tracking this PR: I’ll split it into two smaller PRs, since I don’t expect reviewers to go through this much code at once: |
StubResponse was declared twice (stub contract + test mirror) kept aligned by a KEEP-IN-SYNC comment and a discriminant-pinning test, because importing the #[near] stub crate as a dep breaks cargo test --all-features (duplicate contract-ABI symbol + abi-feature unification; see PR #3664). Extract it into a new plain-lib crate test-tee-verifier-types that both the stub and the contract's test binary depend on. A non-#[near] crate emits no contract-ABI symbol, so it can be a dev-dep of mpc-contract without the collision, giving a real single source of truth: the mirror, the sync comment, and the stub_response_discriminants test are removed. Verified in nix: cargo test --no-run --all-features -p mpc-contract links cleanly (the step that regressed in #3664), and the sandbox tee_verifier suite passes driven through the shared type.
StubResponse was declared twice (stub contract + test mirror) kept aligned by a KEEP-IN-SYNC comment and a discriminant-pinning test, because importing the #[near] stub crate as a dep breaks cargo test --all-features (duplicate contract-ABI symbol + abi-feature unification; see PR #3664). Extract it into a new plain-lib crate test-tee-verifier-types that both the stub and the contract's test binary depend on. A non-#[near] crate emits no contract-ABI symbol, so it can be a dev-dep of mpc-contract without the collision, giving a real single source of truth: the mirror, the sync comment, and the stub_response_discriminants test are removed. Verified in nix: cargo test --no-run --all-features -p mpc-contract links cleanly (the step that regressed in #3664), and the sandbox tee_verifier suite passes driven through the shared type.
StubResponse was declared twice (stub contract + test mirror) kept aligned by a KEEP-IN-SYNC comment and a discriminant-pinning test, because importing the #[near] stub crate as a dep breaks cargo test --all-features (duplicate contract-ABI symbol + abi-feature unification; see PR #3664). Extract it into a new plain-lib crate test-tee-verifier-types that both the stub and the contract's test binary depend on. A non-#[near] crate emits no contract-ABI symbol, so it can be a dev-dep of mpc-contract without the collision, giving a real single source of truth: the mirror, the sync comment, and the stub_response_discriminants test are removed. Verified in nix: cargo test --no-run --all-features -p mpc-contract links cleanly (the step that regressed in #3664), and the sandbox tee_verifier suite passes driven through the shared type.
StubResponse was declared twice (stub contract + test mirror) kept aligned by a KEEP-IN-SYNC comment and a discriminant-pinning test, because importing the #[near] stub crate as a dep breaks cargo test --all-features (duplicate contract-ABI symbol + abi-feature unification; see PR #3664). Extract it into a new plain-lib crate test-tee-verifier-types that both the stub and the contract's test binary depend on. A non-#[near] crate emits no contract-ABI symbol, so it can be a dev-dep of mpc-contract without the collision, giving a real single source of truth: the mirror, the sync comment, and the stub_response_discriminants test are removed. Verified in nix: cargo test --no-run --all-features -p mpc-contract links cleanly (the step that regressed in #3664), and the sandbox tee_verifier suite passes driven through the shared type.
StubResponse was declared twice (stub contract + test mirror) kept aligned by a KEEP-IN-SYNC comment and a discriminant-pinning test, because importing the #[near] stub crate as a dep breaks cargo test --all-features (duplicate contract-ABI symbol + abi-feature unification; see PR #3664). Extract it into a new plain-lib crate test-tee-verifier-types that both the stub and the contract's test binary depend on. A non-#[near] crate emits no contract-ABI symbol, so it can be a dev-dep of mpc-contract without the collision, giving a real single source of truth: the mirror, the sync comment, and the stub_response_discriminants test are removed. Verified in nix: cargo test --no-run --all-features -p mpc-contract links cleanly (the step that regressed in #3664), and the sandbox tee_verifier suite passes driven through the shared type.
StubResponse was declared twice (stub contract + test mirror) kept aligned by a KEEP-IN-SYNC comment and a discriminant-pinning test, because importing the #[near] stub crate as a dep breaks cargo test --all-features (duplicate contract-ABI symbol + abi-feature unification; see PR #3664). Extract it into a new plain-lib crate test-tee-verifier-types that both the stub and the contract's test binary depend on. A non-#[near] crate emits no contract-ABI symbol, so it can be a dev-dep of mpc-contract without the collision, giving a real single source of truth: the mirror, the sync comment, and the stub_response_discriminants test are removed. Verified in nix: cargo test --no-run --all-features -p mpc-contract links cleanly (the step that regressed in #3664), and the sandbox tee_verifier suite passes driven through the shared type.
StubResponse was declared twice (stub contract + test mirror) kept aligned by a KEEP-IN-SYNC comment and a discriminant-pinning test, because importing the #[near] stub crate as a dep breaks cargo test --all-features (duplicate contract-ABI symbol + abi-feature unification; see PR #3664). Extract it into a new plain-lib crate test-tee-verifier-types that both the stub and the contract's test binary depend on. A non-#[near] crate emits no contract-ABI symbol, so it can be a dev-dep of mpc-contract without the collision, giving a real single source of truth: the mirror, the sync comment, and the stub_response_discriminants test are removed. Verified in nix: cargo test --no-run --all-features -p mpc-contract links cleanly (the step that regressed in #3664), and the sandbox tee_verifier suite passes driven through the shared type.
StubResponse was declared twice (stub contract + test mirror) kept aligned by a KEEP-IN-SYNC comment and a discriminant-pinning test, because importing the #[near] stub crate as a dep breaks cargo test --all-features (duplicate contract-ABI symbol + abi-feature unification; see PR #3664). Extract it into a new plain-lib crate test-tee-verifier-types that both the stub and the contract's test binary depend on. A non-#[near] crate emits no contract-ABI symbol, so it can be a dev-dep of mpc-contract without the collision, giving a real single source of truth: the mirror, the sync comment, and the stub_response_discriminants test are removed. Verified in nix: cargo test --no-run --all-features -p mpc-contract links cleanly (the step that regressed in #3664), and the sandbox tee_verifier suite passes driven through the shared type.
|
superseded by #3714 |
Closes #3642.
Today the MPC contract verifies a node's Intel TDX attestation synchronously, inside its own WASM, by linking the
dcap-qvllibrary. This PR moves that crypto verification out into a separatetee-verifiercontract and makessubmit_participant_infoasynchronous. The MPC contract stops linkingdcap-qvlentirely.Final slice of #3540, after #3590 (split DCAP from post-DCAP) and #3626 (verifier-account voting).
WASM size impact
Dropping
dcap-qvlshrinks the contract WASM by ~296 KB (-19.6%). Both binaries built identically (cargo near build non-reproducible-wasm --features abi --profile=release-contract --locked, samewasm-opt -Opost-step):main(v3.13.0)cargo tree -p mpc-contract -i dcap-qvlreturns no match on this branch (it resolves onmain), confirmingdcap-qvlis no longer in the contract's build graph. The crypto-verification weight moves to the already-mergedtee-verifiercontract.