Skip to content

Commit c67cea5

Browse files
committed
fix(contract): charge attestation storage only for new entries or non-participants
submit_participant_info required a deposit unconditionally, which broke nodes signing with a function-call access key: such keys cannot attach any deposit, so every re-attestation failed at tx validation with DepositWithFunctionCall. Restore the pre-#3714 conditional: a participant re-attesting an existing entry attaches no deposit and is charged nothing (so the node's function-call key can re-attest), while a new entry or a non-participant caller still pays the storage delta. The client and node stop attaching the deposit; an operator's full-access key funds a first-time (join) submission. Adds a sandbox regression test that drives submit_participant_info through a real function-call access key (zero deposit succeeds; a deposit is rejected). Closes #3925
1 parent c471323 commit c67cea5

12 files changed

Lines changed: 217 additions & 62 deletions

File tree

crates/contract/src/lib.rs

Lines changed: 54 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -161,19 +161,29 @@ fn refund_to(account_id: &AccountId, amount: NearToken) {
161161
}
162162
}
163163

164-
/// Charges the storage growth since `initial_storage` and refunds the rest. The
165-
/// [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`] floor guarantees the refund never underflows.
166-
fn keep_storage_delta_and_refund_rest(account_id: &AccountId, initial_storage: u64) {
164+
/// Requires at least [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`], then keeps the storage delta since
165+
/// `initial_storage` and refunds the excess.
166+
fn charge_storage(account_id: &AccountId, initial_storage: u64) -> Result<(), Error> {
167+
let attached = env::attached_deposit();
168+
if attached < MINIMUM_ATTESTATION_STORAGE_DEPOSIT {
169+
return Err(InvalidParameters::InsufficientDeposit {
170+
attached: attached.as_yoctonear(),
171+
required: MINIMUM_ATTESTATION_STORAGE_DEPOSIT.as_yoctonear(),
172+
}
173+
.into());
174+
}
175+
167176
// saturating_sub: a shrink charges nothing rather than underflowing.
168177
let bytes_grown = env::storage_usage().saturating_sub(initial_storage);
169178
let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown));
170-
match env::attached_deposit().checked_sub(cost) {
179+
match attached.checked_sub(cost) {
171180
Some(refund) => refund_to(account_id, refund),
172181
// Unreachable given the MINIMUM_ATTESTATION_STORAGE_DEPOSIT floor, which
173182
// minimum_attestation_storage_deposit__should_cover_worst_case_entry pins to the
174183
// worst-case entry cost.
175184
None => log!("attestation storage cost {cost} exceeded deposit for {account_id}"),
176185
}
186+
Ok(())
177187
}
178188

179189
impl Default for MpcContract {
@@ -798,11 +808,12 @@ impl MpcContract {
798808
/// `verify_quote` call, with [`Self::resolve_verification`] chained as its
799809
/// callback to run the post-DCAP checks and store the attestation.
800810
///
801-
/// The caller must attach at least [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`],
802-
/// enough to cover the worst-case stored entry. On success only the actual
803-
/// storage delta is kept and the excess is refunded, so a re-submission that
804-
/// changes no stored bytes is charged nothing. The full deposit is refunded if
805-
/// the attestation is not accepted.
811+
/// Storage is charged to the caller only when a new entry is stored or the caller is not a
812+
/// current participant. A participant re-attesting an existing entry is charged nothing and
813+
/// need not attach any deposit, so the node's function-call access key can re-attest. When a
814+
/// charge applies, the caller must attach at least [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`]
815+
/// (enough to cover the worst-case stored entry); only the actual storage delta is kept and
816+
/// the excess is refunded. The full deposit is refunded if the attestation is not accepted.
806817
#[payable]
807818
#[handle_result]
808819
pub fn submit_participant_info(
@@ -839,30 +850,30 @@ impl MpcContract {
839850
account_public_key,
840851
};
841852

842-
let attached = env::attached_deposit();
843-
if attached < MINIMUM_ATTESTATION_STORAGE_DEPOSIT {
844-
return Err(InvalidParameters::InsufficientDeposit {
845-
attached: attached.as_yoctonear(),
846-
required: MINIMUM_ATTESTATION_STORAGE_DEPOSIT.as_yoctonear(),
847-
}
848-
.into());
849-
}
853+
// Non-participants pay per entry so an outsider cannot drain the contract; participants
854+
// re-attest for free.
855+
let caller_is_not_participant = !self
856+
.protocol_state
857+
.is_existing_or_prospective_participant(&account_id)
858+
.unwrap_or(false);
850859

851860
match proposed_participant_attestation {
852861
Attestation::Mock(mock) => {
853862
let tee_upgrade_deadline_duration =
854863
Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds);
855864
let initial_storage = env::storage_usage();
856-
self.tee_state.verify_and_store_mock(
865+
let insertion = self.tee_state.verify_and_store_mock(
857866
node_id,
858867
mock,
859868
tee_upgrade_deadline_duration,
860869
)?;
861-
keep_storage_delta_and_refund_rest(&account_id, initial_storage);
870+
if insertion.is_new() || caller_is_not_participant {
871+
charge_storage(&account_id, initial_storage)?;
872+
}
862873
Ok(PromiseOrValue::Value(()))
863874
}
864875
Attestation::Dstack(attestation) => Ok(PromiseOrValue::Promise(
865-
self.submit_dstack_attestation(node_id, attestation)?,
876+
self.submit_dstack_attestation(node_id, attestation, caller_is_not_participant)?,
866877
)),
867878
}
868879
}
@@ -874,6 +885,7 @@ impl MpcContract {
874885
&mut self,
875886
node_id: NodeId,
876887
attestation: DstackAttestation,
888+
caller_is_not_participant: bool,
877889
) -> Result<Promise, Error> {
878890
let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else {
879891
return Err(TeeError::VerifierNotConfigured.into());
@@ -894,6 +906,7 @@ impl MpcContract {
894906
.resolve_verification(VerificationContext {
895907
node_id,
896908
attestation,
909+
caller_is_not_participant,
897910
}),
898911
))
899912
}
@@ -2383,8 +2396,8 @@ impl MpcContract {
23832396
}
23842397

23852398
/// Runs the post-DCAP checks and stores the attestation for a
2386-
/// [`VerificationResult::Verified`] response, then keeps the storage delta
2387-
/// and refunds the excess deposit.
2399+
/// [`VerificationResult::Verified`] response, then charges the storage delta unless a
2400+
/// participant re-attested an existing entry.
23882401
fn verify_post_dcap_and_store(
23892402
&mut self,
23902403
context: &VerificationContext,
@@ -2395,17 +2408,22 @@ impl MpcContract {
23952408
Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds);
23962409

23972410
let initial_storage = env::storage_usage();
2398-
if let Err(err) = self.tee_state.verify_and_store_dstack(
2411+
let insertion = match self.tee_state.verify_and_store_dstack(
23992412
context.node_id.clone(),
24002413
&context.attestation,
24012414
report,
24022415
tee_upgrade_deadline_duration,
24032416
) {
2404-
log!("post-DCAP check failed for {account_id}: {err}");
2405-
return Err(err.into());
2406-
}
2417+
Ok(insertion) => insertion,
2418+
Err(err) => {
2419+
log!("post-DCAP check failed for {account_id}: {err}");
2420+
return Err(err.into());
2421+
}
2422+
};
24072423

2408-
keep_storage_delta_and_refund_rest(account_id, initial_storage);
2424+
if insertion.is_new() || context.caller_is_not_participant {
2425+
charge_storage(account_id, initial_storage)?;
2426+
}
24092427
Ok(())
24102428
}
24112429

@@ -4679,6 +4697,9 @@ mod tests {
46794697
VerificationContext {
46804698
node_id,
46814699
attestation,
4700+
// Fixed, not parametrized: this flag only affects charging, which the mock VM
4701+
// can't observe, so true and false would store identical state here.
4702+
caller_is_not_participant: true,
46824703
},
46834704
)
46844705
}
@@ -8116,19 +8137,21 @@ mod tests {
81168137
assert!(configs.contains_key(&tls_key_b), "node B config must exist");
81178138
}
81188139

8140+
const MAX_HASH: [u8; 32] = [0xff; 32];
8141+
81198142
// Catches entry-size growth: fails if a schema change makes the largest storable entry
81208143
// cost more than the deposit at today's storage_byte_cost. It cannot see a future
81218144
// storage_byte_cost increase on a live contract; the deposit's margin covers that.
81228145
#[rstest]
81238146
#[case::dstack(VerifiedAttestation::Dstack(ValidatedDstackAttestation {
8124-
mpc_image_hash: [0xff; 32].into(),
8125-
launcher_compose_hash: [0xff; 32].into(),
8147+
mpc_image_hash: MAX_HASH.into(),
8148+
launcher_compose_hash: MAX_HASH.into(),
81268149
expiry_timestamp_seconds: u64::MAX,
81278150
measurements: default_measurements()[0],
81288151
}))]
81298152
#[case::mock(VerifiedAttestation::Mock(MpcMockAttestation::WithConstraints {
8130-
mpc_docker_image_hash: Some([0xff; 32].into()),
8131-
launcher_docker_compose_hash: Some([0xff; 32].into()),
8153+
mpc_docker_image_hash: Some(MAX_HASH.into()),
8154+
launcher_docker_compose_hash: Some(MAX_HASH.into()),
81328155
expiry_timestamp_seconds: Some(u64::MAX),
81338156
expected_measurements: Some(default_measurements()[0]),
81348157
}))]

crates/contract/src/tee/tee_state.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ pub(crate) enum ParticipantInsertion {
5353
UpdatedExistingParticipant,
5454
}
5555

56+
impl ParticipantInsertion {
57+
pub(crate) fn is_new(&self) -> bool {
58+
matches!(self, Self::NewlyInsertedParticipant)
59+
}
60+
}
61+
5662
#[derive(Debug)]
5763
pub enum TeeValidationResult {
5864
/// All participants are valid

crates/contract/src/tee/verification_context.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ use super::tee_state::NodeId;
1111
pub struct VerificationContext {
1212
pub(crate) node_id: NodeId,
1313
pub(crate) attestation: DstackAttestation,
14+
pub(crate) caller_is_not_participant: bool,
1415
}

crates/contract/tests/inprocess/attestation_submission.rs

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -278,18 +278,18 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() {
278278
assert_eq!(stored_before, stored_after);
279279
}
280280

281-
/// Rejects a submission whose attached deposit is below the storage cost, so a caller
282-
/// cannot store an attestation without paying for it.
281+
/// A non-participant storing a new entry must cover its storage cost, so an outsider cannot
282+
/// store an attestation without paying for it.
283283
#[test]
284-
fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() {
284+
fn submit_participant_info__should_reject_when_non_participant_deposit_is_below_storage_cost() {
285285
// Given
286286
let mut setup = TestSetupBuilder::new().build();
287-
let node = setup.get_participant_node_ids()[0].clone();
287+
let newcomer = node_id_for(&"newcomer.near".parse().unwrap());
288288
let attached_deposit = NearToken::from_yoctonear(1);
289289
testing_env!(
290290
VMContextBuilder::new()
291-
.signer_account_id(node.account_id.clone())
292-
.predecessor_account_id(node.account_id.clone())
291+
.signer_account_id(newcomer.account_id.clone())
292+
.predecessor_account_id(newcomer.account_id.clone())
293293
.attached_deposit(attached_deposit)
294294
.build()
295295
);
@@ -299,7 +299,7 @@ fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() {
299299
.contract
300300
.submit_participant_info(
301301
Attestation::Mock(MockAttestation::Valid),
302-
node.tls_public_key.clone(),
302+
newcomer.tls_public_key.clone(),
303303
)
304304
.map(|_| ());
305305

@@ -311,6 +311,38 @@ fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() {
311311
);
312312
}
313313

314+
/// A current participant re-attesting an existing entry is charged nothing and need not attach
315+
/// any deposit, so the node's function-call access key can re-attest.
316+
#[test]
317+
fn submit_participant_info__should_charge_nothing_when_participant_reattests_with_zero_deposit() {
318+
// Given
319+
let mut setup = TestSetupBuilder::new().build();
320+
let node = setup.get_participant_node_ids()[0].clone();
321+
let attestation = Attestation::Mock(MockAttestation::Valid);
322+
setup.submit_attestation_for_node(&node, attestation.clone());
323+
let stored_before = setup
324+
.contract
325+
.get_attestation(node.tls_public_key.clone())
326+
.unwrap()
327+
.expect("participant attestation should be stored");
328+
329+
// When: the same participant re-attests with no attached deposit.
330+
testing_env!(common::participant_context(&node.account_id));
331+
let result = setup
332+
.contract
333+
.submit_participant_info(attestation, node.tls_public_key.clone())
334+
.map(|_| ());
335+
336+
// Then: the submission succeeds and the stored entry is unchanged.
337+
assert_matches!(&result, Ok(()));
338+
let stored_after = setup
339+
.contract
340+
.get_attestation(node.tls_public_key)
341+
.unwrap()
342+
.expect("participant attestation should still be stored");
343+
assert_eq!(stored_before, stored_after);
344+
}
345+
314346
/// Test that a `Dstack` submission is rejected when no verifier is configured.
315347
#[test]
316348
fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() {

crates/contract/tests/sandbox/tee.rs

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use crate::sandbox::{
88
mpc_contract::{
99
assert_running_return_participants, assert_running_return_threshold,
1010
get_participant_attestation, get_state, get_tee_accounts, submit_participant_info,
11-
submit_participant_info_and_measure_kept_deposit, vote_add_launcher_hash,
12-
vote_for_hash,
11+
submit_participant_info_and_measure_kept_deposit, submit_participant_info_with_deposit,
12+
vote_add_launcher_hash, vote_for_hash,
1313
},
1414
resharing_utils::conclude_resharing,
1515
sign_utils::DomainResponseTest,
@@ -21,8 +21,8 @@ use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash, NodeIma
2121
use near_mpc_contract_interface::method_names;
2222
use near_mpc_contract_interface::types::Protocol;
2323
use near_mpc_contract_interface::types::{self as dtos, Attestation, MockAttestation};
24-
use near_workspaces::Contract;
25-
use near_workspaces::types::NearToken;
24+
use near_workspaces::types::{KeyType, NearToken, SecretKey};
25+
use near_workspaces::{AccessKey, Account, Contract};
2626
use rand::SeedableRng;
2727
use test_utils::attestation::{image_digest, p2p_tls_key};
2828

@@ -280,6 +280,75 @@ async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result
280280
Ok(())
281281
}
282282

283+
/// Regression test for #3925: a participant re-attesting through a function-call access key (which
284+
/// cannot attach a deposit) must succeed with zero deposit, and the same call with a deposit must
285+
/// be rejected by the protocol.
286+
#[tokio::test]
287+
async fn submit_participant_info__should_accept_zero_deposit_via_function_call_key() -> Result<()> {
288+
let SandboxTestSetup {
289+
worker,
290+
contract,
291+
mpc_signer_accounts,
292+
..
293+
} = SandboxTestSetup::builder()
294+
.with_protocols(ALL_PROTOCOLS)
295+
.build()
296+
.await;
297+
298+
// Setup already stored an attestation for this participant, so re-attesting is the free path.
299+
let account = &mpc_signer_accounts[0];
300+
let participants = assert_running_return_participants(&contract).await?;
301+
let tls_key = participants
302+
.participants
303+
.iter()
304+
.find(|(id, _, _)| id == account.id())
305+
.map(|(_, _, info)| info.tls_public_key.clone())
306+
.expect("participant must exist");
307+
let attestation = Attestation::Mock(MockAttestation::Valid);
308+
309+
let fc_sk = SecretKey::from_random(KeyType::ED25519);
310+
account
311+
.batch(account.id())
312+
.add_key(
313+
fc_sk.public_key(),
314+
AccessKey::function_call_access(contract.id(), &[], None),
315+
)
316+
.transact()
317+
.await?
318+
.into_result()?;
319+
let fc_account = Account::from_secret_key(account.id().clone(), fc_sk, &worker);
320+
321+
// Zero deposit through the fc key succeeds.
322+
let zero = submit_participant_info_with_deposit(
323+
&fc_account,
324+
&contract,
325+
&attestation,
326+
&tls_key,
327+
NearToken::from_yoctonear(0),
328+
)
329+
.await?;
330+
assert!(
331+
zero.is_success(),
332+
"zero-deposit fc submission must succeed: {zero:?}"
333+
);
334+
335+
// A deposit through the fc key is rejected at tx validation (the original bug).
336+
let with_deposit = submit_participant_info_with_deposit(
337+
&fc_account,
338+
&contract,
339+
&attestation,
340+
&tls_key,
341+
SUBMIT_PARTICIPANT_INFO_DEPOSIT,
342+
)
343+
.await;
344+
let err = format!("{with_deposit:?}");
345+
assert!(
346+
err.contains("DepositWithFunctionCall"),
347+
"fc submission with a deposit must be rejected: {err}"
348+
);
349+
Ok(())
350+
}
351+
283352
/// **Access control validation** - Tests that external accounts cannot call the private clean_tee_status contract method.
284353
/// This verifies the security boundary: only the contract itself should be able to perform internal cleanup operations.
285354
#[tokio::test]

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ pub const MAX_GAS_FOR_THRESHOLD_VOTE: Gas = Gas::from_tgas(190);
4747
/// TODO(#2756): Reduce this to the minimal value possible
4848
pub const CURRENT_CONTRACT_DEPLOY_DEPOSIT: NearToken = NearToken::from_millinear(17000);
4949

50-
/// Attached to `submit_participant_info`; the contract requires exactly this flat
51-
/// fee to store the bounded attestation entry, with no refund.
50+
/// Attached by sandbox onboarding submissions to `submit_participant_info`, which store a new
51+
/// entry the contract charges for; the excess over the actual storage delta is refunded.
5252
pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT: NearToken =
5353
NearToken::from_millinear(SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR);
5454

0 commit comments

Comments
 (0)