Skip to content

Commit 03a9d59

Browse files
committed
test(contract): address review on the cross-contract attestation tests
- assert nothing-stored through the typed view: the untyped one cannot deserialize a stored Dstack entry, so the exact failure it guards for surfaced as a serde panic instead of the assertion - take the verifier gas budget from the contract's config view and assert headroom; a receipt cannot outspend its own prepaid gas, so comparing against a hand-copied 200 Tgas asserted nothing and could drift from DEFAULT_VERIFIER_TERA_GAS - match failure substrings that survive Debug formatting and workspaces' quote escaping - bounds-check initial_participant_indices in MpcClusterConfig::validate, which also covers the pre-existing indexing in init_contract and add_initial_domains - name the build task in the tee-verifier wasm panic, spell the intra-doc link in full so plain cargo doc resolves it, and fix a stale comment in Makefile.toml - state the current design in the verifier design doc rather than narrating how it was reached, and say plainly that the store-path coverage is inactive until the fixture key lands Also drops the TODO token from two prose references to #3787, which scripts/check-todo-format.sh requires to carry a colon.
1 parent 9161757 commit 03a9d59

6 files changed

Lines changed: 65 additions & 33 deletions

File tree

Makefile.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ args = ["scripts/check-sandbox-image-version.sh"]
165165

166166
# These build tasks are the single source of truth for both local and CI builds.
167167
# CI's `mpc-e2e-tests` job invokes them via `cargo make`.
168-
# All three are skipped when `E2E_SKIP_BUILD` is set (used by `e2e-tests-skip-build`).
168+
# Each is skipped when `E2E_SKIP_BUILD` is set (used by `e2e-tests-skip-build`).
169169

170170
[tasks.build-mpc-node-network-hardship-simulation]
171171
description = "Build the mpc-node binary used by the E2E tests"
@@ -203,6 +203,9 @@ args = [
203203

204204
[tasks.build-tee-verifier-optimized]
205205
description = "Build the tee-verifier WASM for localnet and the E2E tests"
206+
# `scripts/launch-localnet.sh` tells operators to run this task by hand; with
207+
# `E2E_SKIP_BUILD` exported it no-ops (cargo-make logs "Skipping Task"), and the E2E
208+
# loader's panic names this task so a missing WASM points back here.
206209
condition = { env_not_set = ["E2E_SKIP_BUILD"] }
207210
command = "cargo"
208211
args = [

crates/contract/tests/sandbox/tee_verifier.rs

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
//! Verified-path tests that store an attestation must sign as the fixture
1212
//! account (the quote's report_data binds the fixture account key, and the
1313
//! contract reads that key from the transaction signer). They are ignored
14-
//! until the fixture secret key asset lands; see TODO(#3787).
14+
//! until the fixture secret key asset lands; see #3787.
1515
#![allow(non_snake_case)]
1616

1717
use crate::sandbox::{
@@ -20,7 +20,7 @@ use crate::sandbox::{
2020
consts::ALL_PROTOCOLS,
2121
contract_build::{tee_verifier_contract, tee_verifier_contract_with_sandbox_test_hooks},
2222
mpc_contract::{
23-
get_participant_attestation, get_tee_accounts, get_verified_attestation,
23+
get_config, get_participant_attestation, get_tee_accounts, get_verified_attestation,
2424
submit_participant_info, tee_verifier_account_id, total_gas_fee,
2525
vote_add_launcher_hash, vote_add_os_measurement, vote_for_hash,
2626
vote_tee_verifier_change,
@@ -162,15 +162,15 @@ async fn submit_dstack(submitter: &Account, contract: &Contract) -> ExecutionFin
162162
.unwrap()
163163
}
164164

165-
/// Asserts a Dstack submission failed cleanly: a receipt failed carrying
166-
/// `expected_error` (`fail_attestation_submission` panics in its own receipt), no
167-
/// attestation was stored, and the caller spent only gas.
165+
/// Asserts a Dstack submission failed cleanly: a receipt failed mentioning every
166+
/// string in `expected_error` (`fail_attestation_submission` panics in its own
167+
/// receipt), no attestation was stored, and the caller spent only gas.
168168
async fn assert_submission_failed_cleanly(
169169
result: &ExecutionFinalResult,
170170
contract: &Contract,
171171
submitter: &Account,
172172
balance_before: NearToken,
173-
expected_error: &str,
173+
expected_error: &[&str],
174174
) {
175175
let failures = result.failures();
176176
assert!(
@@ -180,12 +180,16 @@ async fn assert_submission_failed_cleanly(
180180
// Substring-match: near-workspaces keeps `ExecutionOutcome.status`
181181
// `pub(crate)`, so the error is only reachable via the Debug dump.
182182
let rendered = format!("{failures:?}");
183-
assert!(
184-
rendered.contains(expected_error),
185-
"expected a receipt failure containing {expected_error:?}, got: {rendered}"
186-
);
183+
for expected in expected_error {
184+
assert!(
185+
rendered.contains(expected),
186+
"expected a receipt failure containing {expected:?}, got: {rendered}"
187+
);
188+
}
187189

188-
let stored = get_participant_attestation(contract, &p2p_tls_key().into())
190+
// Typed as `VerifiedAttestation`: a wrongly stored Dstack entry must surface as
191+
// this assertion, not as a deserialization panic in the view helper.
192+
let stored = get_verified_attestation(contract, &p2p_tls_key().into())
189193
.await
190194
.unwrap();
191195
assert!(stored.is_none(), "nothing should be stored on failure");
@@ -291,10 +295,10 @@ async fn submit_participant_info__should_store_nothing_on_verifier_rejection() {
291295
&contract,
292296
&submitter,
293297
balance_before,
294-
&TeeError::QuoteRejected {
298+
&[&TeeError::QuoteRejected {
295299
reason: String::new(),
296300
}
297-
.to_string(),
301+
.to_string()],
298302
)
299303
.await;
300304
}
@@ -321,15 +325,11 @@ async fn submit_participant_info__should_fail_and_store_nothing_when_verifier_un
321325
&contract,
322326
&submitter,
323327
balance_before,
324-
&TeeError::VerifierUnavailable.to_string(),
328+
&[&TeeError::VerifierUnavailable.to_string()],
325329
)
326330
.await;
327331
}
328332

329-
/// Mirrors the contract's default `verifier_tera_gas`; the config constants are
330-
/// crate-private, so the production budget is pinned here by value.
331-
const VERIFIER_TERA_GAS_BUDGET: u64 = 200;
332-
333333
/// Tolerance for comparing an on-chain expiry stamp against this process's
334334
/// wall clock (sandbox block time tracks it loosely).
335335
const EXPIRY_SLACK_SECONDS: u64 = 600;
@@ -354,7 +354,7 @@ async fn submit_participant_info__should_run_dcap_within_verifier_gas_budget() {
354354
// Then: the real DCAP run succeeds within the production gas budget and its
355355
// Verified verdict reaches the callback. The submission then fails at the
356356
// post-DCAP report_data binding, because the submitter does not hold the
357-
// fixture account key (TODO(#3787)); that terminal error is asserted to pin
357+
// fixture account key (see #3787); that terminal error is asserted to pin
358358
// that the verdict was Verified, not Rejected.
359359
let outcomes = result.outcomes();
360360
let verify_quote_outcome = outcomes
@@ -365,19 +365,28 @@ async fn submit_participant_info__should_run_dcap_within_verifier_gas_budget() {
365365
verify_quote_outcome.is_success(),
366366
"verify_quote must succeed, got: {verify_quote_outcome:#?}"
367367
);
368+
// The receipt is created with exactly `verifier_tera_gas` of static gas, so
369+
// succeeding already proves it fit the budget. Assert headroom instead, which
370+
// is the regression that matters: `dcap-qvl` growing until it OOGs in
371+
// production. Read the budget from the contract so it cannot drift from
372+
// `DEFAULT_VERIFIER_TERA_GAS`.
373+
let budget = Gas::from_tgas(get_config(&contract).await.unwrap().verifier_tera_gas);
374+
let headroom = Gas::from_gas(budget.as_gas() / 10);
368375
assert!(
369-
verify_quote_outcome.gas_burnt <= Gas::from_tgas(VERIFIER_TERA_GAS_BUDGET),
370-
"verify_quote burnt {} but the production budget is {VERIFIER_TERA_GAS_BUDGET} Tgas",
376+
verify_quote_outcome.gas_burnt <= budget.saturating_sub(headroom),
377+
"verify_quote burnt {} of the configured {budget}, leaving less than the {headroom} \
378+
headroom this test exists to protect. Raise DEFAULT_VERIFIER_TERA_GAS (config.rs) \
379+
before the real cost reaches the budget",
371380
verify_quote_outcome.gas_burnt,
372381
);
373-
// The receipt carries the error's Debug form, with the inner panic message
374-
// quote-escaped once by the outer dump.
375382
assert_submission_failed_cleanly(
376383
&result,
377384
&contract,
378385
&submitter,
379386
balance_before,
380-
r#"WrongHash { name: \"report_data\""#,
387+
// Substrings that survive the error's Debug formatting and
388+
// near-workspaces' quote escaping.
389+
&["failed verification", "report_data"],
381390
)
382391
.await;
383392
}
@@ -412,7 +421,7 @@ async fn submit_participant_info__should_fail_cleanly_when_verifier_gas_budget_t
412421
&contract,
413422
&submitter,
414423
balance_before,
415-
&TeeError::VerifierUnavailable.to_string(),
424+
&[&TeeError::VerifierUnavailable.to_string()],
416425
)
417426
.await;
418427
}
@@ -494,7 +503,7 @@ async fn submit_participant_info__should_fail_and_store_nothing_when_resolve_ver
494503
&contract,
495504
&submitter,
496505
balance_before,
497-
"Exceeded the prepaid gas",
506+
&["Exceeded the prepaid gas"],
498507
)
499508
.await;
500509
}

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ use near_mpc_contract_interface::{
77
client::MpcContractHandle,
88
method_names,
99
types::{
10-
Attestation, Ed25519PublicKey, GovernanceThreshold, Participants, ProtocolContractState,
11-
VerifiedAttestation,
10+
Attestation, Config, Ed25519PublicKey, GovernanceThreshold, Participants,
11+
ProtocolContractState, VerifiedAttestation,
1212
},
1313
};
1414
use near_workspaces::{
@@ -23,6 +23,10 @@ pub fn total_gas_fee(result: &ExecutionFinalResult) -> NearToken {
2323
.fold(NearToken::from_yoctonear(0), NearToken::saturating_add)
2424
}
2525

26+
pub async fn get_config(contract: &Contract) -> anyhow::Result<Config> {
27+
Ok(contract.view(method_names::CONFIG).await?.json()?)
28+
}
29+
2630
pub async fn get_state(contract: &Contract) -> ProtocolContractState {
2731
contract
2832
.view(method_names::STATE)

crates/e2e-tests/src/cluster.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,15 @@ impl MpcClusterConfig {
231231
self.num_nodes,
232232
);
233233
}
234+
// Startup indexes the key vectors by participant index, so an out-of-range
235+
// entry here would otherwise surface as a panic mid-startup.
236+
for (i, &participant_idx) in self.initial_participant_indices.iter().enumerate() {
237+
anyhow::ensure!(
238+
participant_idx < self.num_nodes,
239+
"initial_participant_indices[{i}]: index {participant_idx} must be < num_nodes ({})",
240+
self.num_nodes,
241+
);
242+
}
234243
Ok(())
235244
}
236245
}
@@ -245,7 +254,8 @@ pub fn must_load_tee_verifier_wasm() -> Vec<u8> {
245254
let wasm_path = PathBuf::from(&path);
246255
return std::fs::read(&wasm_path).unwrap_or_else(|e| {
247256
panic!(
248-
"failed to read tee-verifier WASM at {}: {e}",
257+
"failed to read tee-verifier WASM at {}: {e}. Build it with \
258+
`cargo make build-tee-verifier-optimized` (skipped when E2E_SKIP_BUILD is set)",
249259
wasm_path.display()
250260
)
251261
});
@@ -263,7 +273,8 @@ pub fn must_load_tee_verifier_wasm() -> Vec<u8> {
263273
}
264274

265275
tracing::info!(
266-
"MPC_TEE_VERIFIER_WASM not set and pre-built WASM not found — building tee-verifier"
276+
"MPC_TEE_VERIFIER_WASM not set and pre-built WASM not found — building tee-verifier. \
277+
Build it up front with `cargo make build-tee-verifier-optimized` to skip this."
267278
);
268279
test_utils::contract_build::ContractBuilder::new("crates/tee-verifier/Cargo.toml").build()
269280
}

crates/tee-verifier/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ impl TeeVerifier {
6767
}
6868

6969
/// The timestamp quotes are verified against: block time, unless a sandbox test
70-
/// pinned one under [`SANDBOX_TEST_PINNED_NOW_STORAGE_KEY`]. The pin exists
70+
/// pinned one under [`tee_verifier_interface::SANDBOX_TEST_PINNED_NOW_STORAGE_KEY`]
71+
/// (spelled in full because the import is feature-gated). The pin exists
7172
/// because sandbox chain time is wall-clock and forward-only, so it can never
7273
/// fall inside the validity window of a checked-in collateral fixture.
7374
fn now_seconds() -> u64 {

docs/design/attestation-verifier-contract.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -611,7 +611,11 @@ The yield-resume split adds four resolution branches the synchronous version nev
611611

612612
The verifier-rotation design changes the test surface in three ways. First, the expiration window itself: an entry whose `expiry_timestamp_seconds` is in the past must be rejected by `re_verify` even when every post-DCAP allowlist invariant still holds, and an entry within the (shortened) window must still pass — this is the existing expiry check, now exercised against the lowered `DEFAULT_EXPIRATION_DURATION_SECONDS`. Second, rotation routing: after `vote_tee_verifier_change` crosses threshold, the next `submit_participant_info` must call `verify_quote` on the new `tee_verifier_account_id`, and existing stored entries must remain present (no purge) until they expire. Third, the in-flight case: a verification scheduled against the old verifier that resolves after the vote crosses threshold must still be stored as a normal entry — it is not treated specially and ages out via the same expiration window as any other entry.
613613

614-
What shipped instead of the stub verifier this section originally proposed: the sandbox tests in `crates/contract/tests/sandbox/tee_verifier.rs` deploy the real `tee-verifier` WASM and drive each verdict through `vote_tee_verifier_change` + `submit_participant_info`: `Rejected` with a malformed quote, no-verdict with an undeployed verifier account, and `Verified` with the fixture quote against a verifier built with the `sandbox-test-hooks` feature, which lets the test pin the timestamp `verify_quote` verifies against (the fixture collateral is expired against live chain time, and sandbox time cannot be wound back). A stub verifier was briefly built and then dropped: it duplicated the real verifier and added maintenance surface for little gain. Verified-path tests that store an attestation additionally sign as the fixture account, because the quote's report_data binds the fixture account key (issue #3787 tracks the key asset).
614+
Status: no stub verifier exists. The design below was superseded during implementation, because a second contract mirroring the real one duplicated it for little gain.
615+
616+
Sandbox tests in `crates/contract/tests/sandbox/tee_verifier.rs` deploy the real `tee-verifier` WASM and drive each verdict through `vote_tee_verifier_change` + `submit_participant_info`: `Rejected` with a malformed quote, no-verdict with an undeployed verifier account, and `Verified` with the fixture quote. `Verified` needs the verifier built with the `sandbox-test-hooks` feature, which lets the test pin the timestamp `verify_quote` verifies against: the fixture collateral is valid only inside a fixed window, while sandbox time is wall-clock and forward-only.
617+
618+
Tests that assert the *store* additionally sign as the fixture account, because the quote's report_data binds that account key. They are inactive until the fixture secret key asset lands (#3787); until then, the store path is covered in-process instead.
615619

616620
E2E tests in `crates/e2e-tests` deploy the real `tee-verifier` and vote it in during cluster startup for topology parity; nodes there submit mock attestations, which the MPC contract verifies without calling the verifier, so the cross-contract flow is covered at the sandbox layer.
617621

0 commit comments

Comments
 (0)