Skip to content

Commit 7134f67

Browse files
committed
test(contract): port async attestation tests to the no-yield design
The tests were written against the old yield-resume resolve_verification (pending_attestations + on_attestation_verified yield-callback + ~200-block timeout). #3766 replaced that with a plain promise chain (verify_quote -> .then(resolve_verification)) whose failures refund and fire a separate fail_attestation_submission receipt, with no pending state and no timeout. Rewrite the sandbox tests to match: - tee_verifier.rs: drop YIELD_TIMEOUT_BLOCKS / fast_forward and the pending-entry assertions; observe failures via the chain's receipt outcomes (ExecutionFinalResult::failures) instead of a queryable pending state. The verifier-crash test now expects an immediate VerifierUnavailable rather than a timeout cleanup. Keep the Verified happy-path and the OOG-resolve test #[ignore]d (they need fixture-allowlist + signer-key setup to reach a successful store) and point them at #3738 rather than the wrong #3730. - Remove has_pending_attestation (it read the removed pending_attestations map) from sandbox_test_methods.rs, its sandbox helper, and the HAS_PENDING_ATTESTATION method-name constant. - docs/design/attestation-verifier-contract.md: rewrite the submission-flow, handling-failures, state, API, and testing sections to the promise-chain design. Also reflow one pre-existing rustfmt violation in participants_gas.rs.
1 parent e0f4be6 commit 7134f67

6 files changed

Lines changed: 344 additions & 349 deletions

File tree

crates/contract/src/sandbox_test_methods.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
use crate::MpcContract;
1212
use crate::primitives::ckd::CKDRequest;
1313
use crate::primitives::signature::SignatureRequest;
14-
use near_sdk::{AccountId, near};
14+
use near_sdk::near;
1515

1616
// Import the generated extension trait from near
1717
use crate::MpcContractExt;
@@ -48,8 +48,4 @@ impl MpcContract {
4848
u32::try_from(len)
4949
.expect("queue length must fit in u32 — bounded by MAX_PENDING_REQUEST_FAN_OUT")
5050
}
51-
52-
pub fn has_pending_attestation(&self, account_id: AccountId) -> bool {
53-
self.pending_attestations.contains_key(&account_id)
54-
}
5551
}

crates/contract/tests/sandbox/participants_gas.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,15 @@ async fn setup_test_env_with_state(n_participants: usize, running_state: bool) -
289289
let keyset = Keyset::new(EpochId::new(1), vec![key]);
290290
let domains = vec![domain];
291291
let next_domain_id = domains.len() as u64 + 1;
292-
init_contract_running(&contract, domains, next_domain_id, keyset, threshold_params, None)
293-
.await;
292+
init_contract_running(
293+
&contract,
294+
domains,
295+
next_domain_id,
296+
keyset,
297+
threshold_params,
298+
None,
299+
)
300+
.await;
294301
} else {
295302
init_contract(&contract, threshold_params, None).await;
296303
}

crates/contract/tests/sandbox/tee_verifier.rs

Lines changed: 141 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,24 @@
44
//! Each test deploys the `test-tee-verifier` stub, whose verify-quote returns a
55
//! response the test picks instead of running real `dcap-qvl`, votes it in as the
66
//! trusted verifier via [`vote_tee_verifier_change`], and then covers one branch
7-
//! of the yield-resume flow:
7+
//! of the promise-chain flow.
88
//!
9-
//! - verifier not configured → submission rejected, nothing stored.
10-
//! - [`StubResponse::Rejected`] → submission fails, deposit refunded, nothing stored.
11-
//! - no-verdict (stub panics) → the ~200-block yield timeout cleans up.
12-
//! - out-of-gas resolve → the receipt rolls back atomically and the same timeout
13-
//! cleans up (no half-committed state).
9+
//! A Dstack submission spawns `verify_quote` on the trusted verifier with
10+
//! [`MpcContract::resolve_verification`] chained as its callback. There is no
11+
//! yield-resume and no timeout: [`resolve_verification`] settles every outcome
12+
//! synchronously within the same chain.
13+
//!
14+
//! - verifier not configured → the submit tx fails synchronously with
15+
//! [`TeeError::VerifierNotConfigured`], nothing stored.
16+
//! - [`StubResponse::Rejected`] → [`resolve_verification`] refunds the deposit and
17+
//! fires `fail_attestation_submission`, which panics in a separate receipt to
18+
//! fail the submitter's transaction; nothing stored.
19+
//! - stub panics (verifier unreachable) → the callback observes a failed promise,
20+
//! resolves to [`TeeError::VerifierUnavailable`], and fails the same way.
21+
//!
22+
//! On failure the top-level submit call still returns its chained promise, so the
23+
//! failure surfaces on the chain's receipt outcomes
24+
//! ([`ExecutionFinalResult::failures`]), not on the top-level tx result.
1425
#![allow(non_snake_case)]
1526

1627
use crate::sandbox::{
@@ -19,21 +30,19 @@ use crate::sandbox::{
1930
consts::ALL_PROTOCOLS,
2031
contract_build::stub_tee_verifier_contract,
2132
mpc_contract::{
22-
get_participant_attestation, has_pending_attestation, submit_participant_info,
33+
get_participant_attestation, submit_participant_info,
2334
submit_participant_info_with_deposit, vote_tee_verifier_change,
2435
},
2536
},
2637
};
2738
use mpc_contract::errors::TeeError;
2839
use near_mpc_contract_interface::types as dtos;
29-
use near_workspaces::{Account, Contract, Worker, network::Sandbox, types::NearToken};
40+
use near_workspaces::{
41+
Account, Contract, Worker, network::Sandbox, result::ExecutionFinalResult, types::NearToken,
42+
};
3043
use test_tee_verifier_types::StubResponse;
3144
use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key, verified_report};
3245

33-
/// Blocks to fast-forward past the ~200-block yield-resume timeout so the
34-
/// runtime fires the yield-callback's timeout branch.
35-
const YIELD_TIMEOUT_BLOCKS: u64 = 250;
36-
3746
/// Deposit attached to a Dstack submission: covers storage on success, fully
3847
/// refunded on failure.
3948
const SUBMIT_DEPOSIT: NearToken = NearToken::from_near(1);
@@ -72,9 +81,7 @@ async fn setup_with_stub(
7281
response: StubResponse,
7382
init_config: Option<dtos::InitConfig>,
7483
) -> (Worker<Sandbox>, Contract, Account, NearToken) {
75-
let mut builder = SandboxTestSetup::builder()
76-
.with_protocols(ALL_PROTOCOLS)
77-
.with_sandbox_test_methods();
84+
let mut builder = SandboxTestSetup::builder().with_protocols(ALL_PROTOCOLS);
7885
if let Some(init_config) = init_config {
7986
builder = builder.with_init_config(init_config);
8087
}
@@ -91,33 +98,45 @@ async fn setup_with_stub(
9198
(worker, contract, submitter, balance_before)
9299
}
93100

94-
async fn submit_dstack(submitter: &Account, contract: &Contract) {
95-
let _ = submit_participant_info_with_deposit(
101+
async fn submit_dstack(submitter: &Account, contract: &Contract) -> ExecutionFinalResult {
102+
submit_participant_info_with_deposit(
96103
submitter,
97104
contract,
98105
&mock_dto_dstack_attestation(),
99106
&p2p_tls_key().into(),
100107
SUBMIT_DEPOSIT,
101108
)
102109
.await
103-
.unwrap();
110+
.unwrap()
104111
}
105112

106-
/// Asserts a failed submission left no stored attestation, no pending entry, and
107-
/// refunded the deposit.
108-
async fn assert_submission_cleaned_up(
113+
/// Asserts a Dstack submission failed on the chain and left no committed state:
114+
/// the failure surfaces on a receipt (`fail_attestation_submission` panics in its
115+
/// own receipt), carries `expected_error`, nothing is stored, and the deposit is
116+
/// refunded.
117+
async fn assert_submission_failed_cleanly(
118+
result: &ExecutionFinalResult,
109119
contract: &Contract,
110120
submitter: &Account,
111121
balance_before: NearToken,
122+
expected_error: &TeeError,
112123
) {
124+
let failures = result.failures();
125+
assert!(
126+
!failures.is_empty(),
127+
"expected the promise chain to fail on a receipt, got: {result:#?}"
128+
);
129+
let rendered = format!("{failures:?}");
130+
let expected = expected_error.to_string();
131+
assert!(
132+
rendered.contains(&expected),
133+
"expected a receipt failure containing {expected:?}, got: {rendered}"
134+
);
135+
113136
let stored = get_participant_attestation(contract, &p2p_tls_key().into())
114137
.await
115138
.unwrap();
116139
assert!(stored.is_none(), "nothing should be stored on failure");
117-
assert!(
118-
!has_pending_attestation(contract, submitter.id()).await.unwrap(),
119-
"the pending entry must be cleaned up"
120-
);
121140
assert_deposit_refunded(submitter, balance_before).await;
122141
}
123142

@@ -143,8 +162,8 @@ async fn submit_participant_info__should_reject_dstack_when_verifier_not_configu
143162
.await
144163
.unwrap();
145164

146-
// Then: it fails synchronously (before any yield), so the error is on the tx
147-
// result.
165+
// Then: it fails synchronously (before any cross-contract call), so the error
166+
// is on the top-level tx result, not a later receipt.
148167
let err = result
149168
.into_result()
150169
.expect_err("Dstack submit must fail when no verifier is configured")
@@ -170,58 +189,121 @@ async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_re
170189
setup_with_stub(StubResponse::Rejected("test rejection".to_string()), None).await;
171190

172191
// When: a Dstack attestation is submitted.
173-
submit_dstack(&submitter, &contract).await;
192+
let result = submit_dstack(&submitter, &contract).await;
174193

175-
// Then: the submission is cleaned up. The rejection resolves in the verifier's
176-
// response receipt, so the outcome is observable in state, not the tx result.
177-
assert_submission_cleaned_up(&contract, &submitter, balance_before).await;
194+
// Then: resolve_verification refunds and fails the submission in a separate
195+
// receipt; the failure is on the chain, not the top-level tx result. The
196+
// stub wraps the reason in `VerifierError::DcapVerification`, whose Display
197+
// prefixes "dcap verification failed: ".
198+
assert_submission_failed_cleanly(
199+
&result,
200+
&contract,
201+
&submitter,
202+
balance_before,
203+
&TeeError::QuoteRejected {
204+
reason: "dcap verification failed: test rejection".to_string(),
205+
},
206+
)
207+
.await;
178208
}
179209

180210
#[tokio::test]
181-
async fn submit_participant_info__should_clean_up_on_verifier_crash() {
182-
// Given: a verifier that panics, so no resume lands.
183-
let (worker, contract, submitter, balance_before) =
211+
async fn submit_participant_info__should_fail_and_store_nothing_on_verifier_crash() {
212+
// Given: a verifier that panics, so the verify_quote promise fails.
213+
let (_worker, contract, submitter, balance_before) =
184214
setup_with_stub(StubResponse::Panic, None).await;
185215

186-
// When: a submission times out (no verdict within the yield window).
187-
submit_dstack(&submitter, &contract).await;
188-
worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await.unwrap();
216+
// When: a Dstack attestation is submitted.
217+
let result = submit_dstack(&submitter, &contract).await;
189218

190-
// Then: the timeout cleans up. Guards the regression where cleanup was rolled
191-
// back by a panic in the same receipt, leaking the entry and wedging the account.
192-
assert_submission_cleaned_up(&contract, &submitter, balance_before).await;
219+
// Then: the callback sees a failed promise, resolves to VerifierUnavailable,
220+
// refunds, and fails the submission in a separate receipt. No timeout: the
221+
// outcome settles synchronously within the same chain.
222+
assert_submission_failed_cleanly(
223+
&result,
224+
&contract,
225+
&submitter,
226+
balance_before,
227+
&TeeError::VerifierUnavailable,
228+
)
229+
.await;
193230
}
194231

195-
// TODO(#3730): un-ignore once the fixture allowlist setup lands. To OOG,
196-
// `resolve_verification` must reach the heavy RTMR3 replay, which needs the
197-
// post-DCAP allowlist checks to pass first (fixture image/launcher hashes and
198-
// measurements voted in, submitter using the fixture keys). With an empty
199-
// allowlist the check fails fast and `resolve_verification` completes at 1 TGas,
200-
// so this would re-test the rejection path.
201-
#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3730"]
232+
// TODO(#3738): un-ignore once the fixture allowlist setup lands. A Verified
233+
// verdict routes through `verify_post_dcap_and_store`, whose allowlist checks
234+
// (fixture image/launcher hashes and measurements voted in, submitter using the
235+
// fixture keys) must pass before the attestation is stored. With an empty
236+
// allowlist the post-DCAP check fails and the submission is rejected instead of
237+
// stored, so the happy path cannot be exercised here yet.
238+
#[ignore = "needs fixture allowlist setup to pass the post-DCAP checks; tracked in #3738"]
202239
#[tokio::test]
203-
async fn submit_participant_info__should_clean_up_when_resolve_verification_runs_out_of_gas() {
240+
async fn submit_participant_info__should_store_attestation_on_verified_quote() {
241+
// Given: a verifier that returns the report the real verifier would produce
242+
// for the fixture quote.
243+
let (_worker, contract, submitter, balance_before) =
244+
setup_with_stub(StubResponse::Verified(verified_report()), None).await;
245+
246+
// When: a Dstack attestation is submitted.
247+
let result = submit_dstack(&submitter, &contract).await;
248+
249+
// Then: the chain succeeds and the attestation is stored; storage is charged
250+
// and the excess deposit refunded (net spend is storage + gas, well under the
251+
// full deposit).
252+
assert!(
253+
result.failures().is_empty(),
254+
"the verified submission chain must succeed, got: {result:#?}"
255+
);
256+
let stored = get_participant_attestation(&contract, &p2p_tls_key().into())
257+
.await
258+
.unwrap();
259+
assert!(stored.is_some(), "a verified attestation must be stored");
260+
let balance_after = submitter.view_account().await.unwrap().balance;
261+
assert!(
262+
balance_after < balance_before,
263+
"storage must be charged from the attached deposit"
264+
);
265+
}
266+
267+
// TODO(#3738): un-ignore once the fixture allowlist setup lands. To OOG,
268+
// `resolve_verification` must reach the heavy RTMR3 replay in the post-DCAP
269+
// checks, which needs the allowlist populated and the submitter using the fixture
270+
// keys. With an empty allowlist the post-DCAP check fails fast and
271+
// `resolve_verification` completes well under 1 TGas, re-testing the rejection
272+
// path instead. Under the promise-chain model an OOG rolls the whole callback
273+
// receipt back atomically: nothing is stored, the runtime refunds the attached
274+
// deposit to the predecessor, and `fail_attestation_submission` never fires, so
275+
// the chain still surfaces a failed receipt. No timeout is involved.
276+
#[ignore = "needs fixture allowlist setup to reach the gas-heavy post-DCAP path; tracked in #3738"]
277+
#[tokio::test]
278+
async fn submit_participant_info__should_fail_and_store_nothing_when_resolve_verification_runs_out_of_gas()
279+
{
204280
// Given: a Verified stub and a resolve gas budget too small for the post-DCAP
205-
// work, so that branch OOGs and rolls back. (Rejected is too light to OOG.)
281+
// work, so that callback OOGs and rolls back atomically.
206282
let init_config = dtos::InitConfig {
207283
resolve_verification_tera_gas: Some(1),
208284
..Default::default()
209285
};
210-
let (worker, contract, submitter, balance_before) =
286+
let (_worker, contract, submitter, balance_before) =
211287
setup_with_stub(StubResponse::Verified(verified_report()), Some(init_config)).await;
212288

213-
// When: a submission is made; resolve rolls back rather than resuming.
214-
submit_dstack(&submitter, &contract).await;
289+
// When: a Dstack attestation is submitted.
290+
let result = submit_dstack(&submitter, &contract).await;
215291

216-
// Then: unlike the rejection path, the entry is still pending before the
217-
// timeout; the timeout then cleans up, proving an atomic rollback of a partial
218-
// resolve receipt cannot wedge the account.
292+
// Then: the callback receipt fails wholesale, nothing is stored, and the
293+
// runtime refunds the attached deposit. Proves an OOG in resolve cannot commit
294+
// partial state.
295+
assert!(
296+
!result.failures().is_empty(),
297+
"an OOG resolve_verification must fail the chain, got: {result:#?}"
298+
);
299+
let stored = get_participant_attestation(&contract, &p2p_tls_key().into())
300+
.await
301+
.unwrap();
219302
assert!(
220-
has_pending_attestation(&contract, submitter.id()).await.unwrap(),
221-
"pending entry must survive an OOG resolve_verification (cleanup is left to the timeout)"
303+
stored.is_none(),
304+
"nothing should be stored on an OOG resolve"
222305
);
223-
worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await.unwrap();
224-
assert_submission_cleaned_up(&contract, &submitter, balance_before).await;
306+
assert_deposit_refunded(&submitter, balance_before).await;
225307
}
226308

227309
/// Asserts the full 1 NEAR storage deposit was returned: the net spend is only

crates/contract/tests/sandbox/utils/mpc_contract.rs

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -74,17 +74,6 @@ pub async fn submit_participant_info_with_deposit(
7474
.await?)
7575
}
7676

77-
pub async fn has_pending_attestation(
78-
contract: &Contract,
79-
account_id: &AccountId,
80-
) -> anyhow::Result<bool> {
81-
Ok(contract
82-
.view(method_names::HAS_PENDING_ATTESTATION)
83-
.args_json(serde_json::json!({ "account_id": account_id }))
84-
.await?
85-
.json()?)
86-
}
87-
8877
pub async fn vote_tee_verifier_change(
8978
account: &Account,
9079
contract: &Contract,

crates/near-mpc-contract-interface/src/method_names.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,6 @@ pub const OS_MEASUREMENT_VOTES: &str = "os_measurement_votes";
9797
pub const ALLOWED_OS_MEASUREMENTS: &str = "allowed_os_measurements";
9898
pub const MIGRATION_INFO: &str = "migration_info";
9999

100-
// Sandbox-test-only methods (gated behind the contract's `sandbox-test-methods`
101-
// feature; never in the production wasm).
102-
pub const HAS_PENDING_ATTESTATION: &str = "has_pending_attestation";
103-
104100
// Deprecated methods
105101
#[deprecated(note = "https://github.com/near/mpc/issues/3079")]
106102
pub const REGISTER_FOREIGN_CHAIN_CONFIG: &str = "register_foreign_chain_config";

0 commit comments

Comments
 (0)