Skip to content

Commit 47655a4

Browse files
committed
test(contract): sandbox coverage + stub verifier for async attestation
Adds the test-tee-verifier stub contract (a wire-compatible verify_quote that returns a test-chosen response instead of running dcap-qvl) and sandbox tests exercising the async submit_participant_info branches: verifier-not-configured, Rejected, and no-verdict (yield timeout). Also adds the has_pending_attestation sandbox view and the design doc for the verifier-contract flow. Builds on the async-verification feature PR.
1 parent 68837a1 commit 47655a4

12 files changed

Lines changed: 488 additions & 60 deletions

File tree

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ members = [
3535
"crates/test-migration-contract",
3636
"crates/test-parallel-contract",
3737
"crates/test-port-allocator",
38+
"crates/test-tee-verifier",
3839
"crates/test-utils",
3940
"crates/threshold-signatures",
4041
"crates/tls",

crates/contract/src/sandbox_test_methods.rs

Lines changed: 5 additions & 1 deletion
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::near;
14+
use near_sdk::{AccountId, near};
1515

1616
// Import the generated extension trait from near
1717
use crate::MpcContractExt;
@@ -48,4 +48,8 @@ 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+
}
5155
}

crates/contract/tests/sandbox/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod participants_gas;
66
pub mod sign;
77
pub mod tee;
88
pub mod tee_cleanup_after_resharing;
9+
pub mod tee_verifier;
910
pub mod update_votes_cleanup_after_resharing;
1011
pub mod upgrade_from_current_contract;
1112
pub mod upgrade_to_current_contract;
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
//! Sandbox tests for the async `submit_participant_info` flow that offloads DCAP
2+
//! verification to a separate `tee-verifier` contract.
3+
//!
4+
//! These deploy the `test-tee-verifier` stub (which returns a test-chosen
5+
//! `verify_quote` answer instead of running real `dcap-qvl`) and point
6+
//! `mpc-contract` at it via `vote_tee_verifier_change`, then exercise each
7+
//! resolution branch of the yield-resume flow:
8+
//!
9+
//! - verifier not configured → submission rejected, nothing stored.
10+
//! - `Rejected` → submission fails, deposit refunded, no stored attestation.
11+
//! - no-verdict (stub panics) → the ~200-block yield timeout cleans up.
12+
#![allow(non_snake_case)]
13+
14+
use crate::sandbox::{
15+
common::SandboxTestSetup,
16+
utils::{
17+
consts::ALL_PROTOCOLS,
18+
contract_build::stub_tee_verifier_contract,
19+
mpc_contract::{
20+
get_participant_attestation, has_pending_attestation, submit_participant_info,
21+
submit_participant_info_with_deposit, vote_tee_verifier_change,
22+
},
23+
},
24+
};
25+
use anyhow::Result;
26+
use borsh::BorshSerialize;
27+
use near_mpc_contract_interface::types::{self as dtos, Attestation};
28+
use near_workspaces::{Account, Contract, Worker, network::Sandbox, types::NearToken};
29+
use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key};
30+
31+
/// Blocks to fast-forward past the ~200-block yield-resume timeout so the
32+
/// runtime fires `on_attestation_verified`'s timeout branch.
33+
const YIELD_TIMEOUT_BLOCKS: u64 = 250;
34+
35+
/// Mirror of `test_tee_verifier::StubResponse`. Re-declared here (rather than
36+
/// depending on the stub crate) so the test only needs its Borsh encoding to
37+
/// initialize the deployed stub; the stub is a separate `#[near]` contract and
38+
/// linking its crate into this test binary would collide on ABI symbols.
39+
#[expect(clippy::large_enum_variant)]
40+
#[derive(BorshSerialize)]
41+
enum StubResponse {
42+
#[expect(dead_code)]
43+
Verified(tee_verifier_interface::VerifiedReport),
44+
Rejected(String),
45+
Panic,
46+
}
47+
48+
/// Deploys the stub verifier with the given response, initializes it, and votes
49+
/// it in as `mpc-contract`'s trusted verifier (all participants vote so the
50+
/// change crosses threshold).
51+
async fn deploy_and_trust_stub(
52+
worker: &Worker<Sandbox>,
53+
contract: &Contract,
54+
participants: &[Account],
55+
response: StubResponse,
56+
) -> Result<Contract> {
57+
let stub = worker.dev_deploy(stub_tee_verifier_contract()).await?;
58+
stub.call("new")
59+
.args_borsh(response)
60+
.transact()
61+
.await?
62+
.into_result()?;
63+
64+
// The contract only consumes `candidate_account_id`; the hash is a voter
65+
// commitment, so any agreed value works for the test.
66+
let expected_code_hash = [7u8; 32];
67+
for account in participants {
68+
vote_tee_verifier_change(account, contract, stub.id(), expected_code_hash).await?;
69+
}
70+
Ok(stub)
71+
}
72+
73+
fn dstack_attestation() -> Attestation {
74+
mock_dto_dstack_attestation()
75+
}
76+
77+
fn tls_key() -> dtos::Ed25519PublicKey {
78+
p2p_tls_key().into()
79+
}
80+
81+
#[tokio::test]
82+
async fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() -> Result<()>
83+
{
84+
// Given: a running contract with no verifier voted in.
85+
let SandboxTestSetup {
86+
mpc_signer_accounts,
87+
contract,
88+
..
89+
} = SandboxTestSetup::builder()
90+
.with_protocols(ALL_PROTOCOLS)
91+
.build()
92+
.await;
93+
94+
// When: a participant submits a Dstack attestation.
95+
let result = submit_participant_info(
96+
&mpc_signer_accounts[0],
97+
&contract,
98+
&dstack_attestation(),
99+
&tls_key(),
100+
)
101+
.await?;
102+
103+
// Then: it is rejected (no verifier configured) and nothing is stored.
104+
assert!(
105+
result.is_failure(),
106+
"Dstack submit must fail when no verifier is configured: {result:#?}"
107+
);
108+
let stored = get_participant_attestation(&contract, &tls_key()).await?;
109+
assert!(stored.is_none(), "no attestation should be stored");
110+
Ok(())
111+
}
112+
113+
#[tokio::test]
114+
async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_rejection()
115+
-> Result<()> {
116+
// Given: a contract whose trusted verifier always rejects.
117+
let SandboxTestSetup {
118+
worker,
119+
mpc_signer_accounts,
120+
contract,
121+
..
122+
} = SandboxTestSetup::builder()
123+
.with_protocols(ALL_PROTOCOLS)
124+
.with_sandbox_test_methods()
125+
.build()
126+
.await;
127+
deploy_and_trust_stub(
128+
&worker,
129+
&contract,
130+
&mpc_signer_accounts,
131+
StubResponse::Rejected("test rejection".to_string()),
132+
)
133+
.await?;
134+
135+
// When: a participant submits a Dstack attestation with a 1 NEAR deposit.
136+
let submitter = &mpc_signer_accounts[0];
137+
let balance_before = submitter.view_account().await?.balance;
138+
let _ = submit_participant_info_with_deposit(
139+
submitter,
140+
&contract,
141+
&dstack_attestation(),
142+
&tls_key(),
143+
NearToken::from_near(1),
144+
)
145+
.await?;
146+
147+
// Then: nothing is stored, the pending entry is cleaned up, and the deposit
148+
// is refunded. The rejection resolves in the verifier's response receipt (a
149+
// later receipt than the original call), so the outcome is observable in
150+
// state rather than on the original transaction's result.
151+
let stored = get_participant_attestation(&contract, &tls_key()).await?;
152+
assert!(stored.is_none(), "a rejected quote must not be stored");
153+
assert!(
154+
!has_pending_attestation(&contract, submitter.id()).await?,
155+
"the pending entry must be cleaned up on rejection"
156+
);
157+
assert_deposit_refunded(submitter, balance_before).await?;
158+
Ok(())
159+
}
160+
161+
#[tokio::test]
162+
async fn submit_participant_info__should_clean_up_on_verifier_crash() -> Result<()> {
163+
// Given: a contract whose trusted verifier panics (no verdict).
164+
let SandboxTestSetup {
165+
worker,
166+
mpc_signer_accounts,
167+
contract,
168+
..
169+
} = SandboxTestSetup::builder()
170+
.with_protocols(ALL_PROTOCOLS)
171+
.with_sandbox_test_methods()
172+
.build()
173+
.await;
174+
deploy_and_trust_stub(
175+
&worker,
176+
&contract,
177+
&mpc_signer_accounts,
178+
StubResponse::Panic,
179+
)
180+
.await?;
181+
182+
// When: a participant submits, the verifier crashes (no resume lands), and
183+
// the chain advances past the ~200-block yield timeout so the runtime fires
184+
// `on_attestation_verified`'s timeout branch.
185+
let submitter = &mpc_signer_accounts[0];
186+
let balance_before = submitter.view_account().await?.balance;
187+
// Unlike the rejection test, the outer-tx result isn't asserted here: the
188+
// failure only resolves when the yield times out, which `near-workspaces`
189+
// does not surface on the original `transact()`, so we assert state instead.
190+
let _ = submit_participant_info_with_deposit(
191+
submitter,
192+
&contract,
193+
&dstack_attestation(),
194+
&tls_key(),
195+
NearToken::from_near(1),
196+
)
197+
.await?;
198+
worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await?;
199+
200+
// Then: nothing is stored, and the timeout cleanup actually committed: the
201+
// pending entry is gone and the deposit refunded. (Guards the regression
202+
// where the cleanup was rolled back by a panic in the same receipt, leaking
203+
// the entry and locking the account out of resubmitting.)
204+
let stored = get_participant_attestation(&contract, &tls_key()).await?;
205+
assert!(
206+
stored.is_none(),
207+
"nothing should be stored when the verifier crashes"
208+
);
209+
assert!(
210+
!has_pending_attestation(&contract, submitter.id()).await?,
211+
"the pending entry must be cleaned up after the yield timeout"
212+
);
213+
assert_deposit_refunded(submitter, balance_before).await?;
214+
Ok(())
215+
}
216+
217+
/// Asserts the 1 NEAR storage deposit was returned: the net spend since
218+
/// `balance_before` is well under 1 NEAR (only gas), rather than the full
219+
/// deposit being retained by the contract.
220+
async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) -> Result<()> {
221+
let balance_after = account.view_account().await?.balance;
222+
let net_spent = balance_before.saturating_sub(balance_after);
223+
assert!(
224+
net_spent < NearToken::from_near(1),
225+
"deposit should be refunded (net spent {net_spent} should be < 1 NEAR, gas only)"
226+
);
227+
Ok(())
228+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use test_utils::contract_build::ContractBuilder;
44
const MPC_CONTRACT_MANIFEST: &str = "crates/contract/Cargo.toml";
55
const MIGRATION_CONTRACT_MANIFEST: &str = "crates/test-migration-contract/Cargo.toml";
66
const PARALLEL_CONTRACT_MANIFEST: &str = "crates/test-parallel-contract/Cargo.toml";
7+
const STUB_TEE_VERIFIER_MANIFEST: &str = "crates/test-tee-verifier/Cargo.toml";
78
const MPC_CONTRACT_OUT_DIR: &str = "target/near/contract-noabi";
89
const MPC_CONTRACT_BENCH_OUT_DIR: &str = "target/near/contract-noabi-bench";
910
const MPC_CONTRACT_SANDBOX_OUT_DIR: &str = "target/near/contract-noabi-sandbox";
@@ -13,6 +14,7 @@ static CONTRACT_WITH_BENCH_METHODS: OnceLock<Vec<u8>> = OnceLock::new();
1314
static CONTRACT_WITH_SANDBOX_TEST_METHODS: OnceLock<Vec<u8>> = OnceLock::new();
1415
static MIGRATION_CONTRACT: OnceLock<Vec<u8>> = OnceLock::new();
1516
static PARALLEL_CONTRACT: OnceLock<Vec<u8>> = OnceLock::new();
17+
static STUB_TEE_VERIFIER_CONTRACT: OnceLock<Vec<u8>> = OnceLock::new();
1618

1719
/// Returns the current contract WASM without benchmark utilities.
1820
/// Use this for most sandbox tests.
@@ -54,3 +56,8 @@ pub fn migration_contract() -> &'static [u8] {
5456
pub fn parallel_contract() -> &'static [u8] {
5557
PARALLEL_CONTRACT.get_or_init(|| ContractBuilder::new(PARALLEL_CONTRACT_MANIFEST).build())
5658
}
59+
60+
pub fn stub_tee_verifier_contract() -> &'static [u8] {
61+
STUB_TEE_VERIFIER_CONTRACT
62+
.get_or_init(|| ContractBuilder::new(STUB_TEE_VERIFIER_MANIFEST).build())
63+
}

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

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ use std::collections::BTreeSet;
22

33
use super::transactions::all_receipts_successful;
44
use mpc_contract::tee::tee_state::NodeId;
5-
use mpc_primitives::hash::{LauncherImageHash, NodeImageHash};
6-
use near_mpc_contract_interface::method_names;
7-
use near_mpc_contract_interface::types::{
8-
Attestation, Ed25519PublicKey, Participants, ProtocolContractState, Threshold,
5+
use mpc_primitives::hash::{LauncherImageHash, NodeImageHash, TeeVerifierCodeHash};
6+
use near_mpc_contract_interface::{
7+
method_names,
8+
types::{Attestation, Ed25519PublicKey, Participants, ProtocolContractState, Threshold},
9+
};
10+
use near_workspaces::{
11+
Account, AccountId, Contract, result::ExecutionFinalResult, types::NearToken,
912
};
10-
use near_workspaces::{Account, Contract, result::ExecutionFinalResult};
1113

1214
pub async fn get_state(contract: &Contract) -> ProtocolContractState {
1315
contract
@@ -40,21 +42,66 @@ pub async fn get_tee_accounts(contract: &Contract) -> anyhow::Result<BTreeSet<No
4042
.collect())
4143
}
4244

43-
/// Helper function to submit participant info with TEE attestation.
4445
pub async fn submit_participant_info(
4546
account: &Account,
4647
contract: &Contract,
4748
attestation: &Attestation,
4849
tls_key: &Ed25519PublicKey,
4950
) -> anyhow::Result<ExecutionFinalResult> {
50-
let result = account
51+
submit_participant_info_with_deposit(
52+
account,
53+
contract,
54+
attestation,
55+
tls_key,
56+
NearToken::from_near(0),
57+
)
58+
.await
59+
}
60+
61+
pub async fn submit_participant_info_with_deposit(
62+
account: &Account,
63+
contract: &Contract,
64+
attestation: &Attestation,
65+
tls_key: &Ed25519PublicKey,
66+
deposit: NearToken,
67+
) -> anyhow::Result<ExecutionFinalResult> {
68+
Ok(account
5169
.call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO)
5270
.args_json((attestation, tls_key))
71+
.deposit(deposit)
5372
.max_gas()
5473
.transact()
55-
.await?;
56-
dbg!(&result);
57-
Ok(result)
74+
.await?)
75+
}
76+
77+
pub async fn has_pending_attestation(
78+
contract: &Contract,
79+
account_id: &AccountId,
80+
) -> anyhow::Result<bool> {
81+
Ok(contract
82+
.view("has_pending_attestation")
83+
.args_json(serde_json::json!({ "account_id": account_id }))
84+
.await?
85+
.json()?)
86+
}
87+
88+
pub async fn vote_tee_verifier_change(
89+
account: &Account,
90+
contract: &Contract,
91+
candidate_account_id: &AccountId,
92+
expected_code_hash: [u8; 32],
93+
) -> anyhow::Result<()> {
94+
let expected_code_hash = TeeVerifierCodeHash::new(expected_code_hash);
95+
all_receipts_successful(
96+
account
97+
.call(contract.id(), method_names::VOTE_TEE_VERIFIER_CHANGE)
98+
.args_json(serde_json::json!({
99+
"candidate_account_id": candidate_account_id,
100+
"expected_code_hash": expected_code_hash,
101+
}))
102+
.transact()
103+
.await?,
104+
)
58105
}
59106

60107
pub async fn get_participant_attestation(

0 commit comments

Comments
 (0)