Skip to content

feat: integrate the TEE attestation verifier contract into mpc-contract#3540

Draft
pbeza wants to merge 26 commits into
mainfrom
feat/integrate-tee-verifier-contract
Draft

feat: integrate the TEE attestation verifier contract into mpc-contract#3540
pbeza wants to merge 26 commits into
mainfrom
feat/integrate-tee-verifier-contract

Conversation

@pbeza

@pbeza pbeza commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Overview

Integrates the standalone TEE attestation verifier (tee-verifier #3237, tee-verifier-interface #3235) into mpc-contract, following docs/design/attestation-verifier-contract.md. mpc-contract no longer runs dcap_qvl::verify inside its own WASM — it offloads DCAP verification to the verifier contract over a cross-contract call, bringing the WASM under the NEP-509 transaction-size pressure.

dcap-qvl is now absent from mpc-contract's normal dependency graph.

This is one branch built in four conceptual layers; it can be reviewed/split layer by layer. Each layer leaves main runnable.

What changed

L1 — split DCAP from post-DCAP (refactor(attestation))

  • attestation / mpc-attestation gain verify_with_report (pure, no dcap-qvl, used by the contract) and verify_locally (off-chain local-verify feature, used by node / tee-authority / attestation-cli).
  • Collapses the duplicate attestation::Collateral / QuoteBytes into re-exports of the tee-verifier-interface types (single source of truth). The interface crate gains an off-by-default serde feature for the node's /public_data payload; external callers and the verifier contract still link only borsh + derive_more.
  • dcap-qvl moves behind the local-verify feature, out of attestation's default graph. New dcap_conversions boundary translates between the interface DTOs and dcap-qvl types, pinned by byte-equal borsh-layout tests.

L2 — expiry window 7d → 1d (feat(attestation))

  • Lowers DEFAULT_EXPIRATION_DURATION_SECONDS to bound how long a wrongly-accepted attestation stays trusted after a verifier rotation, well above the node's hourly resubmit cadence.

L3 — verifier state + voting (feat(contract))

  • New state: tee_verifier_account_id, tee_verifier_votes, pending_attestations.
  • vote_tee_verifier_change(candidate, expected_code_hash) / withdraw_tee_verifier_vote, on the existing Votes primitive; expected_code_hash binds each yes-vote to audited code. Stale votes swept post-resharing. The placeholder account is rejected as a vote candidate, so a quorum can't roll the verifier back to the unconfigured state.
  • Migration starts deployed contracts from a placeholder verifier; participants vote in a real one. Fresh deploys may set it via InitConfig.

L4 — async submit_participant_info + tests (feat(contract) / test(contract))

  • Mock attestations stay synchronous. Dstack attestations go async: yield + cross-contract verify_quote whose .then bridges into resolve_verification, with on_attestation_verified as the yield-callback / timeout cleanup. Storage charging is preserved and relocated into the verified path.
  • Yield-flow correctness: timeout cleanup (remove pending + refund) commits in its own receipt and signals failure via a separate fail_on_attestation_timeout receipt, so cleanup is never rolled back; a failed storage charge reverts the just-stored attestation; a late verifier response after the timeout already cleaned up is a logged no-op rather than a panic. FinalOutcome's borsh wire layout is byte-pinned (it rides a live yield-resume payload).
  • New test-tee-verifier stub contract + sandbox tests for the rejection, crash/timeout, and not-configured branches.

Behavior compatibility

The sync→async change is transparent to the node: its submit_participant_info success criterion polls contract state (get_participant_attestation), not the transaction return value, and tolerates "not yet stored" via existing exponential backoff. E2E uses TeeAuthority::Local (Mock attestations), which stay synchronous, so no verifier deploy is needed there.

Verification

Locally green: unit + integration suites for attestation, mpc-attestation, tee-verifier-interface, tee-verifier, tee-authority, attestation-cli, test-tee-verifier, the in-process mpc-contract tests, and the full mpc-node suite. cargo tree confirms dcap-qvl is gone from mpc-contract and attestation, and tee-verifier-interface default edges are only borsh + derive_more. Sandbox tests, the contract ABI + borsh-schema snapshots (regenerated by CI — the new vote methods, async callbacks, and InitConfig fields change them), and e2e run in CI.

Follow-ups (deferred)

  • Remove serde TEE DTOs from near-mpc-contract-interface superseded by tee-verifier-interface #3494: collapse the JSON-facing near-mpc-contract-interface::Collateral into the interface type — an on-the-wire API migration that needs a coordinated node/contract rollout, kept separate.
  • A sandbox test for the Verified + post-DCAP-pass branch (needs a fixture-matching stub report); the other branches are covered and the post-DCAP logic is unit-tested.
  • No minimum deposit on the Dstack submit path (acknowledged in-code); a deposit floor to amortize the verifier round-trip is left as future hardening.

🤖 Generated with Claude Code

pbeza added 7 commits June 11, 2026 18:45
Move the heavy `dcap_qvl::verify::verify` call out of the always-compiled
attestation path and behind an off-chain `local-verify` feature, so the
post-DCAP checks can run against a `VerifiedReport` supplied by the verifier
contract (on-chain) or by local DCAP (off-chain).

- `DstackAttestation` / `Attestation` gain `verify_with_report` (pure,
  no dcap-qvl) and `verify_locally` (off-chain, `local-verify`). The contract
  temporarily keeps calling `verify_locally`; the cross-contract verifier call
  that removes dcap-qvl from the contract WASM lands in a later step.
- Collapse the duplicate `Collateral` / `QuoteBytes` definitions: `attestation`
  now re-exports the `tee-verifier-interface` types instead of wrapping
  `dcap_qvl::QuoteCollateralV3`, removing dcap-qvl from `attestation`'s default
  dependency graph. The interface crate gains an off-by-default `serde` feature
  (input types only) for the node's `/public_data` payload.
- dcap<->interface conversions get an `attestation`-local copy (kept out of the
  minimal verifier contract on purpose), pinned by a borsh-layout test.

Behavior-neutral: off-chain callers (node, tee-authority, attestation-cli) and
the contract verify exactly as before.
Bounds how long a wrongly-accepted attestation (e.g. one let through by a
since-rotated, buggy verifier) stays trusted before it ages out via re_verify,
without any sweep. The window stays far above the node's hourly resubmission
cadence (ATTESTATION_RESUBMISSION_INTERVAL = 1h), so honest nodes refresh with
comfortable margin.

The node's storage-time heuristic in tx_sender reads the same constant and the
checked_sub stays safe. Decouples the tee-authority MAX_COLLATERAL_AGE doc note,
which no longer needs to match this window.
…rmant)

Introduces the contract state and governance for the upcoming async
attestation flow, without yet routing submit_participant_info through it:

- New MpcContract fields: tee_verifier_account_id (the trusted verifier the
  contract will call verify_quote on), tee_verifier_votes, and an empty
  pending_attestations map. New append-only storage keys for each.
- vote_tee_verifier_change(candidate, expected_code_hash) and
  withdraw_tee_verifier_vote, mirroring the foreign-chain provider vote on the
  generic Votes primitive. Crossing threshold sets tee_verifier_account_id and
  clears the round; expected_code_hash binds each yes-vote to audited code.
- Post-resharing sweep of stale verifier votes via clean_foreign_chain_data.
- Migration starts deployed contracts from a placeholder verifier account;
  participants vote in a real one. Fresh deploys may set it via InitConfig.
- PendingAttestation / FinalOutcome types for the later async flow.

Unit-tested: vote threshold crossing, same-account-different-hash isolation,
re-vote replacement, withdraw, post-reshare retain, and borsh round-trips. The
contract ABI snapshot and sandbox migration/upgrade tests must be regenerated
in CI (the WASM build requires the contract toolchain).
submit_participant_info no longer runs dcap_qvl in the contract WASM. Mock
attestations are still verified synchronously (no DCAP); Dstack attestations now
go async:

- submit_participant_info returns PromiseOrValue<()>. The Dstack arm rejects a
  duplicate in-flight submission and an unset (placeholder) verifier, stashes a
  PendingAttestation, registers a yield, and fires a cross-contract verify_quote
  call whose .then bridges into resolve_verification.
- resolve_verification owns the answered outcomes: Verified runs the post-DCAP
  checks (via Attestation::verify_with_report) against fresh policy and stores +
  resumes Ok, or refunds + resumes Err on a post-DCAP failure; Rejected refunds
  + resumes Err immediately; a non-answer logs and defers to the yield timeout.
- on_attestation_verified is the trivial yield-callback; its timeout branch does
  the deferred cleanup + refund.
- tee_state gains add_mock_participant / finish_dstack_verify / store_verified_
  attestation (split from add_participant, which is now a test-only Mock shim).
- Storage charging is preserved (charge only when new or non-participant; actual
  delta; refund excess) and relocated into the verified path; the deposit and
  participant flag are stashed in PendingAttestation.
- New Config gas knobs for the verifier call and the two callbacks.

dcap-qvl is now absent from mpc-contract's normal dependency graph. The Dstack
end-to-end paths move to sandbox tests (added next); Mock paths and the post-DCAP
logic stay unit-tested. ABI snapshot + sandbox tests regenerate in CI.
…on flow

Adds the test-tee-verifier stub contract (returns a test-chosen verify_quote
answer instead of running dcap-qvl, speaking the same Borsh DTOs as the real
verifier) and sandbox tests that drive the async submit_participant_info paths:
verifier-not-configured rejection, verifier Rejected (refund, nothing stored),
and verifier crash / no-verdict (yield-timeout cleanup).

The Verified + post-DCAP-pass path needs a fixture-matching stub report and is a
tracked follow-up; the post-DCAP logic stays unit-tested in mpc-attestation.

Sandbox tests require the contract WASM toolchain and run in CI.
- attestation-cli full-verification test asserted the 7-day expiry literal;
  switch it to DEFAULT_EXPIRATION_DURATION_SECONDS so it tracks the constant
  (now 1 day) like the mpc-attestation integration tests.
- expect(large_enum_variant) on the StubResponse enums (stub crate + sandbox
  mirror): the Verified(VerifiedReport) variant is large by design.
Copilot AI review requested due to automatic review settings June 11, 2026 18:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Integrates the standalone TEE attestation verifier contract into mpc-contract by moving DCAP quote verification out of the contract WASM and into a cross-contract verify_quote call, while keeping post-DCAP policy checks in the contract/attestation crates. This aligns the on-chain attestation flow with docs/design/attestation-verifier-contract.md and reduces the contract’s dependency closure.

Changes:

  • Refactors attestation verification into a pure post-DCAP path (verify_with_report) plus an off-chain-only local DCAP path (verify_locally via local-verify feature).
  • Adds verifier governance + async Dstack submission flow in mpc-contract (trusted verifier account, voting, pending attestation state, yield/callback resolution).
  • Adds a test-only stub verifier contract and sandbox tests covering key async branches (not configured / rejected / crash-timeout).

Reviewed changes

Copilot reviewed 49 out of 50 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
docs/design/attestation-verifier-contract.md Updates design doc status and remaining follow-ups.
crates/test-utils/src/contract_types.rs Extends dummy config with verifier/yield callback gas fields.
crates/test-utils/src/attestation.rs Updates quote/collateral test parsing to match new DTO ownership and serde constraints.
crates/test-tee-verifier/src/lib.rs Adds a stub verifier contract returning canned Borsh results for sandbox tests.
crates/test-tee-verifier/Cargo.toml Defines the stub verifier crate build (cdylib) and deps.
crates/tee-verifier-interface/src/lib.rs Adds optional off-by-default serde derives for input DTOs only.
crates/tee-verifier-interface/Cargo.toml Adds optional serde feature with no_std-compatible serde config.
crates/tee-authority/src/tee_authority.rs Switches to new local-verify API and explicit DCAP/interface conversion helpers.
crates/tee-authority/Cargo.toml Enables mpc-attestation/local-verify for off-chain verification.
crates/node/src/trait_extensions/convert_to_contract_dto.rs Adapts collateral DTO conversion to new interface-owned type.
crates/node/src/tee/remote_attestation.rs Switches node verification to verify_locally.
crates/node/Cargo.toml Enables mpc-attestation/local-verify for node-side verification.
crates/near-mpc-contract-interface/src/types/config.rs Adds init/config fields for verifier gas + optional verifier account id.
crates/near-mpc-contract-interface/src/method_names.rs Adds method-name constants for verifier voting + async callbacks + verify_quote.
crates/mpc-attestation/tests/test_attestation_verification.rs Updates integration tests to use verify_locally and adds equivalence test vs verify_with_report.
crates/mpc-attestation/src/report_data.rs Gates quote-parsing unit test behind local-verify and simplifies fixture handling.
crates/mpc-attestation/src/lib.rs Re-exports DCAP conversion helpers only under local-verify.
crates/mpc-attestation/src/attestation.rs Splits verification entry points (verify_with_report, verify_mock_only, verify_locally) and adjusts expiry window to 1 day.
crates/mpc-attestation/Cargo.toml Adds local-verify feature and wires dev-deps for local verification tests.
crates/contract/tests/sandbox/utils/mpc_contract.rs Adds sandbox helper for voting in a verifier.
crates/contract/tests/sandbox/utils/contract_build.rs Builds and caches the stub verifier WASM for sandbox tests.
crates/contract/tests/sandbox/upgrade_from_current_contract.rs Updates config proposal struct to include new gas fields.
crates/contract/tests/sandbox/tee_verifier.rs Adds sandbox tests for async Dstack submission branches using stub verifier.
crates/contract/tests/sandbox/mod.rs Registers the new sandbox test module.
crates/contract/tests/sandbox/contract_configuration.rs Updates init config test to include new gas fields and clarify verifier id behavior.
crates/contract/tests/inprocess/attestation_submission.rs Adjusts in-process tests for PromiseOrValue return type (mock path remains synchronous).
crates/contract/src/v3_11_2_state.rs Adds migration defaults for verifier account/votes/pending map.
crates/contract/src/tee/verifier_votes.rs Implements threshold voting primitive for selecting the trusted verifier account.
crates/contract/src/tee/tee_state.rs Splits participant insertion into mock-sync vs Dstack-post-verifier storage paths.
crates/contract/src/tee/pending_attestation.rs Introduces pending attestation state + yield final outcome type.
crates/contract/src/tee.rs Exposes new tee modules (pending attestation + verifier votes).
crates/contract/src/storage_keys.rs Adds storage keys for verifier votes and pending attestations.
crates/contract/src/lib.rs Implements async submit_participant_info for Dstack (yield + cross-contract verifier + callbacks) and adds verifier voting methods/state.
crates/contract/src/errors.rs Adds new TEE errors for verifier configuration and in-flight verification.
crates/contract/src/dto_mapping.rs Updates collateral mapping + config mapping for new gas fields.
crates/contract/src/config.rs Introduces default gas constants for verifier call + callbacks.
crates/contract/Cargo.toml Adds dependencies needed for verifier DTOs and refactored attestation usage.
crates/attestation/tests/collateral.rs Updates collateral parsing tests to use new parsing helpers.
crates/attestation/src/quote.rs Re-exports QuoteBytes from tee-verifier-interface.
crates/attestation/src/measurements.rs Switches Measurements conversion to interface VerifiedReport.
crates/attestation/src/lib.rs Adds dcap_conversions module behind local-verify.
crates/attestation/src/dcap_conversions.rs Adds explicit interface↔dcap conversion helpers + Borsh layout pinning tests.
crates/attestation/src/collateral.rs Re-exports interface Collateral and moves JSON/hex parsing into off-chain helpers.
crates/attestation/src/attestation.rs Splits Dstack verification into verify_with_report + verify_locally + dcap_report behind local-verify.
crates/attestation/Cargo.toml Makes dcap-qvl optional behind local-verify and enables interface serde for node /public_data.
crates/attestation-cli/tests/test_verification.rs Updates expiry expectation to use the new constant.
crates/attestation-cli/src/verify.rs Switches CLI to end-to-end local verification via verify_locally.
crates/attestation-cli/Cargo.toml Enables mpc-attestation/local-verify for the CLI.
Cargo.toml Adds the new test-tee-verifier crate to the workspace.
Cargo.lock Updates lockfile for new crates/features/dependencies.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1028 to +1032
match self.charge_attestation_storage(
&account_id,
initial_storage,
insertion,
caller_is_not_participant,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 186eb40. store_verified_attestation now returns the displaced previous entry, and finish_verified_attestation reverts the store (tee_state.revert_dstack_store) when charge_attestation_storage fails, so the receipt no longer commits a free store. Added a sandbox assertion that a rejected submission stores nothing and refunds the deposit.

Comment thread crates/contract/src/lib.rs Outdated
Comment on lines +961 to +981
let account_id = node_id.account_id.clone();
let final_outcome = match result {
Err(promise_err) => {
// No verdict; let the yield timeout clean up. Do NOT resume, or
// we'd race the timeout for ownership of the cleanup path.
log!("verifier did not answer for {account_id}: {promise_err:?}");
return;
}
Ok(VerificationResult::Rejected(reason)) => {
log!("verifier rejected quote for {account_id}: {reason}");
FinalOutcome::Err(format!("verifier rejected quote: {reason}"))
}
Ok(VerificationResult::Verified(report)) => {
self.finish_verified_attestation(node_id, &report)
}
};

let pending = self
.pending_attestations
.remove(&account_id)
.expect("PendingAttestation must exist while resolve_verification holds the yield");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 186eb40. resolve_verification now checks pending_attestations.contains_key up front and returns a logged no-op if the entry is gone (late response after the timeout already cleaned up), instead of .expect-panicking.

@claude

claude Bot commented Jun 11, 2026

Copy link
Copy Markdown

Pull request overview

Splits DCAP verification out of mpc-contract WASM by offloading the cryptographic step to a separate trusted tee-verifier contract. The post-DCAP checks remain in mpc-contract, which calls verify_quote cross-contract and resumes a yielded promise from the .then callback. Adds threshold-vote machinery (vote_tee_verifier_change + expected_code_hash commitment) so participants pick the verifier, drops the attestation expiry from 7d to 1d, and ships a test-only stub verifier for sandbox coverage. dcap-qvl is removed from mpc-contracts graph.

Changes:

  • New tee_verifier_account_id / TeeVerifierVotes / pending_attestations state on MpcContract, with vote_tee_verifier_change / withdraw_tee_verifier_vote entrypoints and migration to a placeholder verifier.
  • Async submit_participant_info for Dstack: yield + cross-contract verify_quote; Mock stays synchronous.
  • Attestation::verify split into pure verify_with_report (used on-chain) and verify_locally (off-chain, gated on local-verify); Collateral / QuoteBytes collapse into tee-verifier-interface re-exports; dcap_conversions is the new boundary.
  • DEFAULT_EXPIRATION_DURATION_SECONDS lowered from 7d to 1d.
  • New test-tee-verifier stub crate + 3 sandbox tests (not-configured / rejection / crash branches).

Blocking findings (must fix before merge):

  • crates/contract/src/lib.rs:998-1038 (finish_verified_attestation) — storage staking bypass. tee_state.finish_dstack_verify inserts a new entry into stored_attestations before charge_attestation_storage runs. If the charge fails (attached < cost), the function returns FinalOutcome::Err, then resolve_verification refunds the full attached_deposit and resumes the yield with Err, but the receipt commits successfully, so the new stored_attestations row stays. In the sync path the same Err propagated out of submit_participant_info and the receipt failure rolled the insertion back; the async path has no equivalent rollback. Net result: a caller who attaches an insufficient deposit but produces a valid attestation gets the storage for free and is fully refunded. Either roll back the insertion explicitly on charge failure (remove the just-inserted tls_public_key and any other state delta before returning FinalOutcome::Err) or compute the storage cost from the entry size before inserting and reject up-front.
  • crates/contract/src/lib.rs:978-981 and 1004-1007 — yield-timeout race panics in production. Both call sites do self.pending_attestations.get/remove(account_id).expect with the message PendingAttestation must exist while resolve_verification holds the yield. That invariant does not hold when the verifier responds after the 200-block yield timeout has already fired: on_attestation_verified Err(PromiseError) branch removes the pending entry at timeout (line 1059), and a late Ok(Verified | Rejected) response then panics the resolve_verification receipt. 200 blocks is roughly 3-4 minutes; a dcap_qvl verify under chain congestion can plausibly miss that window. Replace both expects with a graceful early-return that logs and exits — the receipt panic accomplishes nothing because the pending state is already cleaned up.

Non-blocking (nits, follow-ups, suggestions):

  • crates/contract/src/lib.rs:355-377 — the enqueue_yield_request doc-comment still says it must be the last operation performed in the enclosing contract method, but submit_dstack_attestation (line 901) deliberately calls it and then issues the cross-contract verify_quote plus .then(resolve_verification). Functionally fine (promise_return only fixes the return value), but the doc should be relaxed so the new pattern does not look like a violation on first read.
  • crates/contract/src/lib.rs:877-895 — submit_dstack_attestation re-parses UNSET_TEE_VERIFIER_ACCOUNT on every Dstack submission via initial_tee_verifier_account_id(None). Cheap individually, but a LazyLock or comparing on the str would avoid the per-call parse and the second expect.
  • crates/contract/src/lib.rs:877-895 — the call goes through assert_caller_is_signer and the pending-entry check but does not require any minimum deposit; a caller can fire arbitrarily many Dstack submits, each costing the contract a verifier round-trip worth of gas before failing at the eventual storage charge. Worth either a require_deposit up-front or at least a comment acknowledging the DoS surface.
  • crates/contract/src/lib.rs:1306-1314 — the new doc comment block on submit_participant_info is placed between the payable and handle_result attributes, with the existing terse doc above payable. Two doc blocks now describe the same method; consolidate them above the attribute stack so the rendered API doc is unambiguous.
  • crates/contract/tests/sandbox/tee_verifier.rs:1-202 — no sandbox coverage of the Verified plus insufficient-deposit branch or of the verifier-response-after-timeout case. Both correspond to the blocking findings above; once those are fixed, please add tests so the regressions do not slip back in. (The follow-up TODO for the Verified plus post-DCAP-pass happy path is already noted in the PR description.)

Issues found.

pbeza added 12 commits June 11, 2026 20:41
Under --all-features the contract enables `abi`, where #[near(serializers=[borsh])]
also derives BorshSchema. PendingAttestation embeds DstackAttestation, which has
no BorshSchema, so ABI generation failed to compile. These are internal-only
state types never present in a public method signature, so they don't need a
schema — switch them to plain #[derive(BorshSerialize, BorshDeserialize)].
…inalOutcome

- FinalOutcome reaches ABI schema generation as on_attestation_verified's
  #[callback_result] arg, so derive BorshSchema under `abi`.
- Give test-tee-verifier an `abi` feature (mirroring the real verifier) so
  --all-features enables borsh-schema for the wire DTOs, and derive BorshSchema
  on StubResponse under it.
- Use #[expect(dead_code)] over #[allow] on the sandbox stub mirror's unused
  Verified variant.
Reflects the added MpcContract fields (tee_verifier_account_id,
tee_verifier_votes, pending_attestations), the TeeVerifierVotes /
Votes<AuthenticatedParticipantId> schema types, and the new Config gas knobs.
Diff reviewed: only the intended additions.
The verifier integration added three gas fields to Config, changing its borsh
layout. The migration shadow struct embedded the *current* Config, so deployed
(pre-change) state failed to deserialize and migrate() panicked — the upgrade
was rolled back (caught by the contract_upgrade_compatibility e2e test).

Shadow the old 13-field Config as OldConfig and map it to the current Config,
filling the new gas fields from defaults.
The check requires `TODO(#NNNN):` (with colon) or `TODO:`; two parenthetical
prose mentions of issue numbers tripped it. Reworded to "see issue #NNNN".
The repo forbids `use` inside fn bodies. Move the dcap-conversion trait import
to a feature-gated module-level use in `attestation`, hoist the report_data
test's imports to the test module (aliasing the quote fixture to avoid
shadowing), and drop a redundant in-fn import in the mpc-attestation
integration test.
Reflects the new public/private methods (vote_tee_verifier_change,
withdraw_tee_verifier_vote, resolve_verification, on_attestation_verified) and
submit_participant_info's PromiseOrValue return, plus the InitConfig fields.

Reconstructed from the CI-generated ABI (the local toolchain can't run
`cargo near build`: the WASM build needs rustc <=1.86 while ABI generation needs
>=1.88); every unchanged and deleted line was verified byte-for-byte against the
prior snapshot, so the diff is exactly the intended additions.
CI runs `cargo fmt` under the nightly toolchain, which formats a few constructs
differently from stable (import-block wrapping, comment spacing). Reformat the
three affected files to match.
- resolve_verification no longer links the private submit_dstack_attestation.
- AcceptedDstackAttestation / AcceptedAttestation docs point at
  verify_with_report (verify was split into verify_with_report / verify_locally).
- dcap_conversions module doc uses plain code spans for the sibling file path
  and the private IntoDcapType/IntoInterfaceType traits instead of intra-doc
  links.
The doc-link fix to resolve_verification changed its rustdoc, which the ABI
embeds; update the snapshot's matching doc string. (Only the doc text differs;
no schema change.)
The verifier-rejection and verifier-crash sandbox tests asserted
ExecutionFinalResult::is_failure() on the original submit_participant_info call,
but those outcomes are resolved in the yield-resume receipt (not the outer
transaction), so that flag is runtime-dependent. Assert the reliable invariant
instead: a rejected/non-verdict quote is never stored. (The not-configured test
keeps is_failure() — it rejects synchronously.)
@pbeza pbeza requested a review from Copilot June 12, 2026 10:45
@pbeza

pbeza commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 51 out of 52 changed files in this pull request and generated 3 comments.

Comment on lines +963 to +968
Err(promise_err) => {
// No verdict; let the yield timeout clean up. Do NOT resume, or
// we'd race the timeout for ownership of the cleanup path.
log!("verifier did not answer for {account_id}: {promise_err:?}");
return;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept the early-return here by design (rather than resuming immediately on Err(PromiseError)). Resuming from resolve_verification would race the runtime's yield timeout for ownership of the cleanup: the timeout always fires on_attestation_verified exactly once per data_id, and that callback now does the cleanup + refund itself (fixed in 186eb40). If resolve_verification also resumed on a non-answer, both paths could try to resolve/clean the same yield. So the non-answer case is intentionally routed to a single owner — the timeout callback. The trade-off you note is real (the PendingAttestation lingers up to ~200 blocks), but it's bounded and avoids the double-ownership hazard; this mirrors the existing sign-request flow. The genuine bug nearby — the late answer after timeout — is fixed separately by the contains_key guard.

Comment thread crates/contract/src/lib.rs Outdated
attached_deposit,
) {
Ok(()) => FinalOutcome::Ok,
Err(err) => FinalOutcome::Err(format!("{err:?}")),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 186eb40 — uses err.to_string() (Display) instead of {err:?}.

Comment thread crates/contract/tests/sandbox/tee_verifier.rs Outdated
@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown

Pull request overview

Splits DCAP verification out of mpc-contract by offloading the cryptographic step to a separate trusted tee-verifier contract; post-DCAP checks remain in mpc-contract and run from a .then callback against the verifier's verify_quote response. Adds threshold-vote machinery (vote_tee_verifier_change with an expected_code_hash commitment) so participants pick the verifier, drops the attestation expiry from 7d to 1d, and ships a test-tee-verifier stub plus sandbox tests for the rejection / not-configured / crash branches. dcap-qvl leaves mpc-contract's default dependency graph.

Changes:

  • submit_participant_info becomes Result<PromiseOrValue<()>, Error>: Mock stays synchronous, Dstack yields + cross-contract verify_quote resolved by resolve_verification / on_attestation_verified.
  • New tee_verifier_account_id + TeeVerifierVotes + pending_attestations state with migration to a placeholder verifier and matching ABI snapshots.
  • Attestation API split into verify_with_report (pure, on-chain) and verify_locally (off-chain, gated by local-verify); Collateral / QuoteBytes collapse into tee-verifier-interface re-exports.
  • New test-tee-verifier cdylib + sandbox tests covering not-configured, rejection, and crash branches.

Findings

Blocking (must fix before merge):

  • crates/contract/src/lib.rs on_attestation_verified (the Err(PromiseError) timeout branch) — the timeout cleanup is silently undone. The function uses [handle_result] -> Result<(), String> and returns Err after pending_attestations.remove(&account_id) + refund_attestation_deposit(...). Per engineering-standards.md ("don't panic"), [handle_result] panics in the smart contract precisely so "no side-effects happen in the transaction" — which here rolls back the remove(...) AND discards the not-yet-scheduled Promise::new(account_id).transfer(deposit).detach() inside refund_attestation_deposit. Net effect on a verifier timeout: the PendingAttestation row stays in storage, the deposit is stuck on the contract, and the affected account is permanently locked out of resubmitting because the pending_attestations.contains_key(&account_id) gate at the top of submit_dstack_attestation keeps firing. The existing yield-callback pattern in this same file (return_signature_and_clean_state_on_success -> fail_on_timeout) deliberately does the cleanup in this receipt and then returns PromiseOrValue::Promise(...fail_on_timeout.as_return()) so the panic happens in a SEPARATE receipt — please mirror that here (cleanup, then chain to a tiny fail_on_attestation_timeout helper that just env::panic_strs the reason). The current sandbox crash test only checks get_participant_attestation(...).is_none(), which is still trivially true under the buggy behavior, so it does not protect against this regression; once fixed, please also assert that pending_attestations.get(account_id) is None and that the refund landed.
  • The two blocking issues already raised in the prior Claude / Copilot review threads (storage-staking bypass in finish_verified_attestation -> charge_attestation_storage, and the .expect("PendingAttestation must exist...") panic risk at resolve_verification / finish_verified_attestation on a late verifier response) are real and confirmed. Not re-raising the bodies; please address those threads alongside the fix above. Note that the late-response race interacts with the timeout-cleanup bug above: with cleanup rolled back, the .expect() may not blow up in the exact sequence Copilot described, but a late Verified after a timeout will then ALSO succeed in storing the attestation while the original caller has already seen the timeout error — another reason the timeout branch needs to actually commit its cleanup.

Non-blocking (nits, follow-ups, suggestions):

  • crates/contract/tests/sandbox/tee_verifier.rs submit_participant_info__refunds_and_stores_nothing_on_verifier_rejection — the test name promises a refund assertion but the body only checks stored.is_none(). Once the storage-staking bypass fix lands, this is the natural place to also assert the deposit returned to the submitter; right now the name overstates what the test enforces.
  • crates/contract/src/lib.rs submit_dstack_attestationcaller_is_not_participant is captured at submit time and consumed in finish_verified_attestation minutes later, after the verifier responds. A resharing that drops the submitter in the interim will not reclassify the storage charge. Probably acceptable (the alternative — re-deriving participant status in a callback receipt where predecessor_account_id is the contract itself — is worse), but worth a one-line comment explaining the "frozen at submit time" semantics so a future reader does not try to re-derive it from voter_account() and get the wrong answer.
  • crates/contract/src/lib.rs vote_tee_verifier_change — successful rotation leaves any in-flight .then(resolve_verification) callbacks from the previous verifier still able to land and store attestations. The 1d expiry bounds the trust window (and the docs call out the rotation semantics for stored entries), but in-flight requests against a rotated-away-from verifier are not addressed in the design doc. A short note in docs/design/attestation-verifier-contract.md that the rotation is "future-submissions only; in-flight requests use whichever verifier they were dispatched to" would close the gap.
  • crates/contract/src/tee/pending_attestation.rs final_outcome__should_round_trip_borsh — the round-trip is fine; consider also pinning the Borsh layout (1-byte variant tag, Ok = 0, Err(String) = 1) so a future re-ordering of the FinalOutcome variants does not silently break in-flight receipts mid-upgrade.

Issues found

pbeza added 2 commits June 12, 2026 14:09
Addresses review findings on the async submit_participant_info flow (both
Copilot and claude[bot] flagged these; confirmed against the code):

- Timeout cleanup was rolled back: on_attestation_verified did remove + refund
  and then returned Err under #[handle_result], panicking the same receipt and
  undoing the cleanup — leaking the PendingAttestation, stranding the deposit,
  and locking the account out (the contains_key gate keeps firing). Mirror the
  sign-flow pattern: clean up in this receipt, then fail via a separate
  fail_on_attestation_timeout receipt so the cleanup commits.
- Storage-staking bypass: finish_dstack_verify inserted into stored_attestations
  before the deposit check; on InsufficientDeposit the receipt still committed,
  so a valid attestation with too small a deposit got stored for free and fully
  refunded. store_verified_attestation now returns the displaced entry and the
  callback reverts the store (revert_dstack_store) when the charge fails.
- Late-response panic: a verifier response arriving after the yield timeout
  cleaned up the pending entry hit .expect(...) and panicked. resolve_verification
  now bails gracefully when no pending entry exists.

Nits: use Display (not Debug) for the charge error; consolidate the
submit_participant_info doc above its attributes; relax the enqueue_yield_request
doc; drop a wrong issue ref; document the frozen caller_is_not_participant and the
no-min-deposit DoS surface.

Tests: add a sandbox-only has_pending_attestation view; the rejection and crash
tests now assert the pending entry is cleaned up and the deposit refunded (the
crash test fast-forwards past the yield timeout), guarding the regressions above.
Reflects on_attestation_verified's new PromiseOrValue<()> return + doc, the new
fail_on_attestation_timeout method, and the consolidated submit_participant_info
doc. Reconstructed from the CI-generated ABI; unchanged/deleted lines verified
byte-for-byte. (has_pending_attestation is sandbox-only, correctly absent.)
…rifier-contract

# Conflicts:
#	crates/contract/src/lib.rs
#	crates/contract/src/v3_11_2_state.rs
@pbeza pbeza requested a review from Copilot June 12, 2026 13:48
@pbeza

pbeza commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 52 out of 53 changed files in this pull request and generated 2 comments.

Comment on lines +911 to +930
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,
},
);
},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — this is the known DoS surface, and it's already called out with an in-code comment at the top of submit_dstack_attestation (the one-in-flight-per-account guard bounds per-account concurrency but not aggregate gas burn). Charging for the temporary pending-entry storage at submit time and refunding on resolution, or imposing a minimum-deposit floor, is the right hardening, but the exact deposit policy is a deliberate team decision I'd rather not bake in unilaterally here. Leaving it as an explicit, documented follow-up rather than expanding this PR's scope.

Comment on lines +47 to +58
Ok(Collateral {
pck_crl_issuer_chain: get_str(&v, "pck_crl_issuer_chain")?,
root_ca_crl: get_hex(&v, "root_ca_crl")?,
pck_crl: get_hex(&v, "pck_crl")?,
tcb_info_issuer_chain: get_str(&v, "tcb_info_issuer_chain")?,
tcb_info: get_str(&v, "tcb_info")?,
tcb_info_signature: get_hex(&v, "tcb_info_signature")?,
qe_identity_issuer_chain: get_str(&v, "qe_identity_issuer_chain")?,
qe_identity: get_str(&v, "qe_identity")?,
qe_identity_signature: get_hex(&v, "qe_identity_signature")?,
pck_crl_issuer_chain: get_str(&v, "pck_crl_issuer_chain")?,
root_ca_crl: get_hex(&v, "root_ca_crl")?,
pck_crl: get_hex(&v, "pck_crl")?,
pck_certificate_chain: get_str(&v, "pck_certificate_chain").ok(),
};
Ok(Self(quote_collateral))
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good observation, but the .ok() on the optional pck_certificate_chain predates this PR (it was already present in c8f8186); this PR only moved the collateral parser, preserving the behavior byte-for-byte. The field is informational/passthrough — it's carried through the DTO and conversions, not consumed by DCAP verification itself — so a wrong-typed value can't weaken quote validation, only get silently dropped. Tightening optional-field parsing to reject wrong JSON types (vs. missing/null) is a reasonable hardening, but it's a pre-existing behavior change to the attestation crate that's out of scope for this integration PR; better as its own follow-up so it can be reviewed and tested on its own.

@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown

Pull request overview

Integrates the standalone tee-verifier contract into mpc-contract: DCAP cryptographic verification is moved out of the contract WASM into a trusted verifier account, with mpc-contract calling verify_quote cross-contract and resuming a yielded promise from the .then callback. Adds threshold-vote machinery (vote_tee_verifier_change + expected_code_hash commitment) so participants pick the verifier; drops the attestation expiry from 7d to 1d to bound the trust window after a rotation; ships a test-tee-verifier stub crate + sandbox coverage for the not-configured / rejection / crash branches. dcap-qvl leaves mpc-contract's default dependency graph.

Changes:

  • submit_participant_info becomes Result<PromiseOrValue<()>, Error>: Mock stays synchronous; Dstack yields + cross-contract verify_quote, with resolve_verification (the .then bridge) and on_attestation_verified (yield-callback + timeout) handling resolution.
  • New tee_verifier_account_id / TeeVerifierVotes / pending_attestations state on MpcContract, with vote_tee_verifier_change / withdraw_tee_verifier_vote entrypoints and a v3_11_2_state migration to the placeholder verifier.
  • Attestation::verify split into verify_with_report (pure, used on-chain) and verify_locally (off-chain, gated on local-verify); Collateral / QuoteBytes collapse into tee-verifier-interface re-exports; new dcap_conversions boundary with byte-equal Borsh-layout pin tests.
  • DEFAULT_EXPIRATION_DURATION_SECONDS lowered 7d to 1d, with rationale comment.
  • Storage-charge path lifted into charge_attestation_storage (shared by mock + dstack); the async path captures caller_is_not_participant at submit time and explicitly reverts the store on charge failure via revert_dstack_store.
  • New test-tee-verifier cdylib + 3 sandbox tests + has_pending_attestation sandbox-only view to assert cleanup.

Findings

I verified that the two previously-flagged blocking issues (storage-staking bypass in finish_verified_attestation and the .expect("PendingAttestation must exist…") panic on a late verifier response) are correctly addressed by 186eb40tee_state::finish_dstack_verify now returns the displaced previous entry and an explicit revert_dstack_store is called when charge_attestation_storage fails; and resolve_verification now guards on pending_attestations.contains_key(&account_id) up-front and bails as a logged no-op for a stale entry. I also verified that the second Claude review's concern about the #[handle_result] timeout branch silently rolling back its cleanup is addressed by switching on_attestation_verified to do the remove + refund in its own receipt and chain the failure into a separate fail_on_attestation_timeout panic, mirroring the existing return_signature_and_clean_state_on_success / fail_on_timeout pattern. The new submit_participant_info__cleans_up_on_verifier_crash sandbox test asserts the post-timeout state (no pending entry, deposit refunded), so the fix is regression-protected.

Blocking (must fix before merge):

  • None.

Non-blocking (nits, follow-ups, suggestions):

  • crates/contract/src/lib.rs vote_tee_verifier_change — the vote does not reject the placeholder unset.tee-verifier.invalid as a candidate. If participants reach threshold on a proposal naming the placeholder (accidentally or maliciously), tee_verifier_account_id is set back to the placeholder and every subsequent Dstack submit reverts to VerifierNotConfigured — an effective rollback that the rest of the design treats as "no verifier ever voted in". Cheap to defend: reject candidate_account_id == initial_tee_verifier_account_id(None) up front in the vote, or at apply time. Same shape as submit_dstack_attestation's placeholder comparison.
  • crates/contract/src/tee/pending_attestation.rs final_outcome__should_round_trip_borsh — the round-trip test is fine, but it does not pin the wire layout: a future re-ordering of FinalOutcome::Ok / Err(String) would still round-trip locally while silently breaking in-flight callback receipts mid-upgrade (the variant tag would flip). Pin the bytes explicitly. Mirrors the prior thread; cheap and matches the dcap_conversions pin pattern this PR introduces.
  • docs/design/attestation-verifier-contract.md — the new "Status: largely implemented" banner names the two follow-ups but does not document the in-flight semantics during a vote_tee_verifier_change rotation. A .then(resolve_verification) chain dispatched against the old verifier still lands and stores after the rotation completes (the lookup goes through node_id / pending, not tee_verifier_account_id), bounded only by the 1d expiry window. A one-line note ("future-submissions only; in-flight requests use whichever verifier they were dispatched to") would make the rotation's exact security boundary unambiguous.
  • crates/contract/src/lib.rs resolve_verification — the .expect("checked contains_key above, and no host call clears it in between") message reads "no host call clears it in between", but the Ok(VerificationResult::Verified) branch does run finish_verified_attestation, which makes host calls. Those calls don't touch pending_attestations, so the invariant holds — but the message reads like it asserts no host calls happen at all. Tighten to "no host call mutates pending_attestations in between" for accuracy.
  • crates/contract/src/lib.rs submit_dstack_attestationinitial_tee_verifier_account_id(None) re-parses UNSET_TEE_VERIFIER_ACCOUNT on every Dstack submit. Cheap individually but pure overhead on a hot path; a LazyLock (or storing the placeholder AccountId once at init) avoids the per-call parse and the second expect. Already noted in the first Claude review.
  • crates/contract/src/lib.rs submit_dstack_attestation — the path takes no minimum deposit, so a participant can fire many Dstack submits, each costing the contract a verifier round-trip's gas before failing at the eventual storage charge. The one-in-flight-per-account guard caps concurrency per account but not aggregate burn. The PR explicitly acknowledges this; a small require_deposit floor would be a cheap future hardening so the burn is at least amortized by attached funds.

Issues found.

pbeza added 2 commits June 12, 2026 17:53
…ome borsh layout

Reject the placeholder account (unset.tee-verifier.invalid) as a
vote_tee_verifier_change candidate so a quorum cannot roll the trusted
verifier back to the unconfigured state. Add a byte-level borsh pin for
FinalOutcome (serialized into the live yield-resume payload) so a future
variant reorder fails loudly instead of silently breaking in-flight
callback receipts across an upgrade. Tighten the resolve_verification
expect message and cross-reference the rotation in-flight semantics from
the design doc's status banner.
@pbeza pbeza requested a review from Copilot June 15, 2026 09:47
@pbeza

pbeza commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 52 out of 53 changed files in this pull request and generated 2 comments.

Comment on lines 813 to +877
@@ -765,10 +830,6 @@ impl MpcContract {
account_key
);

// Save the initial storage usage to know how much to charge the proposer for the storage
// used
let initial_storage = env::storage_usage();

let tee_upgrade_deadline_duration =
Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds);

@@ -782,62 +843,329 @@ impl MpcContract {
}
})?;

// Add the participant information to the contract state
let attestation_insertion_result = self
.tee_state
.add_participant(
NodeId {
account_id: account_id.clone(),
tls_public_key,
account_public_key,
},
proposed_participant_attestation,
tee_upgrade_deadline_duration,
let node_id = NodeId {
account_id: account_id.clone(),
tls_public_key,
account_public_key,
};
// Frozen at submit time and consumed later in the resolution callback.
// The callback receipt's predecessor is the contract itself, so participant
// status cannot be re-derived there — capture it now. A resharing that
// drops this submitter mid-flight therefore won't reclassify the storage
// charge, which is acceptable (the alternative is unavailable).
let caller_is_not_participant = self.voter_account().is_err();

match proposed_participant_attestation {
Attestation::Mock(mock) => {
// Synchronous path: no DCAP, store immediately.
let initial_storage = env::storage_usage();
let insertion = self
.tee_state
.add_mock_participant(node_id, mock, tee_upgrade_deadline_duration)
.map_err(map_attestation_submission_error)?;
self.charge_attestation_storage(
&account_id,
initial_storage,
insertion,
caller_is_not_participant,
env::attached_deposit(),
)?;
Ok(PromiseOrValue::Value(()))
}
Attestation::Dstack(dstack) => {
Ok(self.submit_dstack_attestation(node_id, dstack, caller_is_not_participant)?)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good instinct to scrutinize this, but I dug into the near-sdk codegen + nearcore VM semantics and the yield/timeout/callback flow is not broken — returning Ok(PromiseOrValue::Value(())) after enqueue_yield_request is safe here.

You're right about the first half: for a Call method, #[handle_result] codegen unwraps Ok(x), serializes x, and calls env::value_return(&result). For PromiseOrValue::Value(()) the bytes are empty, so a redundant value_return(&[]) does fire after our promise_return. And in nearcore (near-vm-runner .../logic.rs) both promise_return and value_return just overwrite return_data with no guard (last writer wins), so the method's return_data ends up Value([]) rather than the yield receipt index.

The part that makes it harmless: promise_yield_create enqueues the yield as a runtime receipt — with its ~200-block timeout and its callback — via ext.create_promise_yield_receipt(...), tracked independently of return_data. Overwriting return_data to Value([]) does not cancel that receipt. The yield still times out and fires on_attestation_verified, and resolve_verification's .then still resumes it via promise_yield_resume(data_id, ..). return_data only governs what the current execution returns to its caller; it has no bearing on receipts already queued by promise_yield_create.

This is also covered empirically. submit_participant_info__cleans_up_on_verifier_crash submits a Dstack attestation against a panicking stub verifier, fast_forward(250) past the timeout, and asserts the pending entry is cleaned up and the deposit refunded — which can only happen if the yield was created and its timeout callback fired. If value_return([]) had completed the call immediately and broken the yield, that callback would never run and the test would fail. It passes in CI, as does the rejection test (which depends on the .then resume landing). Those two are the regression guard for exactly this concern.

So the only residual is cosmetic: an empty value_return that gets overwritten-but-unused (nothing reads this method's tx-return today — the node observes the stored/pending state, not the return value). I considered refactoring the Dstack path to return PromiseOrValue::Promise(..) and drop the manual promise_return to match sign, but enqueue_yield_request is shared with sign and the rework has real blast radius for no functional gain, so I'm leaving it as-is.

}

/// Result of a successful [`Attestation::verify`] call.
/// Result of a successful [`Attestation::verify_with_report`] call.
@claude

claude Bot commented Jun 15, 2026

Copy link
Copy Markdown

Pull request overview

Integrates the standalone tee-verifier into mpc-contract: DCAP cryptographic verification moves out of the contract WASM into a trusted verifier account, called cross-contract via verify_quote and resumed from a .then callback. Adds threshold-vote machinery (vote_tee_verifier_change with an expected_code_hash commitment), lowers the attestation expiry from 7d to 1d to bound the trust window after a verifier rotation, and ships a test-tee-verifier stub + sandbox coverage. dcap-qvl leaves mpc-contract's default dependency graph.

Changes:

  • submit_participant_info returns Result<PromiseOrValue<()>, Error>: Mock stays synchronous; Dstack yields + cross-contract verify_quote, with resolve_verification (.then bridge) and on_attestation_verified (yield-callback + timeout) handling resolution. Timeout cleanup commits in its own receipt and chains a separate fail_on_attestation_timeout panic.
  • New tee_verifier_account_id / TeeVerifierVotes / pending_attestations state on MpcContract, with vote_tee_verifier_change / withdraw_tee_verifier_vote entrypoints, placeholder-candidate rejection, and a v3_11_2_state migration to the placeholder verifier.
  • Attestation::verify split into pure verify_with_report (on-chain) and verify_locally (off-chain, gated on local-verify); Collateral / QuoteBytes collapse into tee-verifier-interface re-exports; new dcap_conversions boundary with byte-equal Borsh-layout pin tests. FinalOutcome also gets a pinned-bytes test so a future variant reorder cannot silently break in-flight callback receipts mid-upgrade.
  • Storage-charge path lifted into charge_attestation_storage (shared by mock + dstack); the async path captures caller_is_not_participant at submit time and explicitly reverts the store on charge failure via revert_dstack_store.
  • New test-tee-verifier cdylib (Verified / Rejected / Panic responses), 3 sandbox tests (not-configured / rejection / crash-timeout), and a has_pending_attestation sandbox-only view to assert cleanup.

Reviewed changes

Per-file summary (key files)
File Description
crates/contract/src/lib.rs Async submit_participant_info (Mock sync, Dstack via verifier), resolve_verification / finish_verified_attestation, on_attestation_verified + fail_on_attestation_timeout, vote_tee_verifier_change / withdraw_tee_verifier_vote, placeholder rejection, charge_attestation_storage.
crates/contract/src/tee/pending_attestation.rs PendingAttestation + FinalOutcome enum with round-trip and pinned-layout tests.
crates/contract/src/tee/verifier_votes.rs VerifierChangeProposal + threshold Votes wrapper with unit tests for threshold/withdraw/retain.
crates/contract/src/tee/tee_state.rs Splits insertion into add_mock_participant (sync) and finish_dstack_verify (post-verifier); adds store_verified_attestation returning displaced previous entry, and revert_dstack_store.
crates/contract/src/v3_11_2_state.rs Migration: shadowed OldConfig carrying pre-verifier gas-field layout, defaults the new fields, starts verifier state from the placeholder.
crates/contract/src/config.rs, crates/near-mpc-contract-interface/src/types/config.rs New verifier_tera_gas / resolve_verification_tera_gas / on_attestation_verified_tera_gas and InitConfig::tee_verifier_account_id.
crates/contract/src/errors.rs VerificationAlreadyPending, VerifierNotConfigured, VerifierCandidateIsPlaceholder.
crates/contract/tests/sandbox/tee_verifier.rs Sandbox tests against test-tee-verifier for not-configured / rejection / crash-timeout, asserting cleanup + refund.
crates/attestation/src/attestation.rs DstackAttestation::verify_with_report (pure) + verify_locally / dcap_report (off-chain).
crates/attestation/src/dcap_conversions.rs IntoDcapType / IntoInterfaceType boundary, pinned by byte-equal Borsh tests.
crates/attestation/src/collateral.rs Collateral re-exported from tee-verifier-interface; collateral_from_json / _str test helpers behind test-utils.
crates/mpc-attestation/src/attestation.rs Three entry points: verify_with_report, verify_mock_only, verify_locally; DEFAULT_EXPIRATION_DURATION_SECONDS lowered 7d → 1d.
crates/tee-verifier-interface/src/lib.rs Off-by-default serde feature on input DTOs (QuoteBytes / Collateral) for off-chain callers; report/output types stay Borsh-only.
crates/test-tee-verifier/src/lib.rs Stub verifier cdylib: returns Verified(report) / Rejected(reason) / panics.

Findings

I re-verified that the three previously flagged blocking issues are correctly addressed by 186eb40 and cc18ebb:

  • Storage-staking bypass: tee_state::finish_dstack_verify returns the displaced previous entry; finish_verified_attestation calls tee_state.revert_dstack_store(&tls_public_key, previous) on charge failure, so the receipt no longer commits a free store (crates/contract/src/lib.rs finish_verified_attestation).
  • Late verifier response panic: resolve_verification guards on pending_attestations.contains_key(&account_id) up front and bails as a logged no-op for a stale entry (crates/contract/src/lib.rs resolve_verification).
  • Silently rolled-back timeout cleanup: on_attestation_verified does the remove + refund in its own receipt and chains a separate fail_on_attestation_timeout panic, mirroring the existing return_signature_and_clean_state_on_success / fail_on_timeout pattern. The new submit_participant_info__cleans_up_on_verifier_crash sandbox test asserts has_pending_attestation == false and net-spent < 1 NEAR (deposit refunded) after the timeout, so the fix is regression-protected.

Two prior nits also landed: vote_tee_verifier_change rejects the placeholder candidate up front (VerifierCandidateIsPlaceholder), and FinalOutcome has a byte-pinned layout test in crates/contract/src/tee/pending_attestation.rs so a future variant reorder cannot silently flip the wire tag mid-upgrade.

Blocking (must fix before merge):

  • None.

Non-blocking (nits, follow-ups, suggestions):

  • crates/contract/src/lib.rs finish_verified_attestation (lib.rs:1599-1602) — self.pending_attestations.get(&account_id).expect("resolve_verification confirmed the pending entry before calling us") is an inter-function invariant: finish_verified_attestation is correct only because its single caller (resolve_verification) did the contains_key check first. Per engineering-standards.md ("Maintain Local Reasonability"), this is a sequential dependency a future refactor could quietly break. Cheap to defend without changing behavior: pass the PendingAttestation (or just dstack + attached_deposit + caller_is_not_participant + tls_public_key) into finish_verified_attestation as parameters from resolve_verification, since the latter already holds the entry under contains_key. Removes the .expect and the implicit ordering.

  • crates/contract/src/lib.rs resolve_verification (lib.rs:1571-1573) — the .expect("checked contains_key above; no host call between mutates pending_attestations") invariant is true but the message ("no host call between mutates pending_attestations") reads as garbled grammar; "no host call between mutates pending_attestations" loses the word "above" or "in between". Tighten to e.g. "checked contains_key above and no intervening call mutates pending_attestations" so a future reader can parse it. Pure prose fix.

  • crates/contract/tests/sandbox/tee_verifier.rsvote_tee_verifier_change is exercised only via the deploy_and_trust_stub setup helper, and withdraw_tee_verifier_vote is not exercised in sandbox at all (only its internal Votes primitive in crates/contract/src/tee/verifier_votes.rs unit tests). A small vote_tee_verifier_change__should_route_future_submissions_to_new_verifier + a withdraw round-trip sandbox test would cover the public-method surface end-to-end; both are cheap given the stub already exists.

  • crates/contract/src/lib.rs submit_dstack_attestation (lib.rs:1437) — initial_tee_verifier_account_id(None) re-parses UNSET_TEE_VERIFIER_ACCOUNT on every Dstack submit and on every vote_tee_verifier_change. Cheap individually but pure overhead on a hot path with two expects; a LazyLock<AccountId> (or a singleton constructed once at init) eliminates both. Carried over from the first review round; not raised again unless you want to track it as a follow-up.

  • crates/contract/src/lib.rs submit_dstack_attestation (lib.rs:1441-1445) — the DoS surface (no minimum deposit on the Dstack submit path, so each call burns a verifier round-trip's gas) is documented in-line and acknowledged as a deliberate deferral. A require_deposit floor would be the cheap mitigation when the team is ready; the in-code comment is doing the right thing for now.

  • docs/design/attestation-verifier-contract.md — the new "Status: largely implemented" banner now correctly points to the "Governance and upgrades" section for in-flight rotation semantics (line 356 explicitly describes the dispatched-to-old-verifier behavior). Looks aligned with the shipped code.

✅ Approved

/// `report` must be the verifier's output for *this* attestation's quote
/// and collateral; the checks below bind it to the expected report data,
/// the embedded TCB info, and the accepted measurement sets.
pub fn verify_with_report(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if there is a better name, I had to read the description to understand what "with_report" means.
but I don't have any suggestions :-(

/// offloads to the verifier) and the post-DCAP checks
/// ([`verify_with_report`](Self::verify_with_report)).
#[cfg(feature = "local-verify")]
pub fn dcap_report(&self, timestamp_seconds: u64) -> Result<VerifiedReport, VerificationError> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe call it dcap_verify_report or dcap_verify_quote?

@gilcu3

gilcu3 commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

@pbeza marking this as draft as I believe this was split in several PRs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants