Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3790000
fix(contract): charge attestation storage by actual delta
pbeza Jul 23, 2026
c0886f0
fix(contract): make attestation deposit const pub, address review
pbeza Jul 23, 2026
d35db9b
Merge remote-tracking branch 'origin/main' into 3857-attestation-stor…
pbeza Jul 23, 2026
ba694a9
fix(contract): regenerate ABI snapshot, use fail-loud balance math in…
pbeza Jul 23, 2026
6b4cbeb
test(contract): parametrize worst-case storage test, tighten attestat…
pbeza Jul 23, 2026
c471323
Merge remote-tracking branch 'origin/main' into 3857-attestation-stor…
pbeza Jul 23, 2026
c67cea5
fix(contract): charge attestation storage only for new entries or non…
pbeza Jul 23, 2026
e7128f2
Merge remote-tracking branch 'origin/main' into 3857-attestation-stor…
pbeza Jul 23, 2026
b1ab8eb
fix(contract): charge attestation storage by measured delta; fund e2e…
pbeza Jul 24, 2026
c8d62f9
Merge remote-tracking branch 'origin/main' into 3857-attestation-stor…
pbeza Jul 24, 2026
3efa6e4
fix(contract): fund attestation storage from the contract balance
pbeza Jul 24, 2026
8af54e5
Merge remote-tracking branch 'origin/main' into 3857-attestation-stor…
pbeza Jul 24, 2026
7b7444c
refactor(contract): drop now-vestigial attestation-store flush
pbeza Jul 24, 2026
e693f1c
test(contract): drop dead deposit context, fix stale refund naming
pbeza Jul 24, 2026
0ca1cc9
test(contract): restore MpcContractHandle sandbox helper, add given/w…
pbeza Jul 24, 2026
29e7520
docs: correct attestation-verifier deposit note to contract-funded st…
pbeza Jul 24, 2026
c88ff3e
Merge remote-tracking branch 'origin/main' into 3857-attestation-stor…
pbeza Jul 24, 2026
afacfef
refactor(node): drop deposit threading from the tx path
pbeza Jul 24, 2026
296539d
refactor(contract): reuse storage-cost idiom for attestation entry cap
pbeza Jul 24, 2026
5a164a8
test(contract): drop tautological entry-cost test, inline single-use …
pbeza Jul 24, 2026
e845e90
refactor(contract): measure real storage delta for the attestation en…
pbeza Jul 24, 2026
158e8e4
revert(contract): drop the runtime attestation entry-size cap
barakeinav1 Jul 28, 2026
74ea6a0
test(contract): pin the attestation entry size, tighten the cost ceiling
barakeinav1 Jul 28, 2026
a80955c
Merge remote-tracking branch 'origin/main' into 3857-attestation-stor…
barakeinav1 Jul 28, 2026
1428b4d
docs: mark the superseded yield-resume sections with TODO(#3825)
barakeinav1 Jul 28, 2026
d12a0c8
docs: submit_participant_info is node-submission only
barakeinav1 Jul 28, 2026
aae164f
docs: drop the stale deposit guidance, widen the superseded-design ba…
barakeinav1 Jul 28, 2026
d8c3579
test(contract): name the entry-size tests after their subject
barakeinav1 Jul 28, 2026
26c343b
test(contract): pin that submit_participant_info rejects an attached …
barakeinav1 Jul 28, 2026
bf3fe13
docs: use the required TODO(#NNNN): format in the status banner
barakeinav1 Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 72 additions & 37 deletions crates/contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,9 @@ const MINIMUM_CKD_REQUEST_DEPOSIT: NearToken = NearToken::from_yoctonear(1);
/// node key cannot invoke these methods.
pub const MINIMUM_NODE_MANAGEMENT_DEPOSIT: NearToken = NearToken::from_yoctonear(1);

/// Flat fee a node attaches to [`MpcContract::submit_participant_info`] for its
/// stored attestation entry. The entry is bounded, so the fee is fixed and
/// nothing is refunded; its margin over the true cost absorbs storage-price and
/// layout changes. A unit test asserts it covers the worst-case entry.
/// Minimum a node must attach to [`MpcContract::submit_participant_info`],
/// sized to cover the worst-case stored entry. Only the actual storage delta is
/// kept; the excess is refunded.
const MINIMUM_ATTESTATION_STORAGE_DEPOSIT: NearToken = NearToken::from_millinear(100);

/// Entries to scan in the post-reshare `clean_invalid_attestations` sweep. External
Expand Down Expand Up @@ -160,6 +159,19 @@ fn refund_to(account_id: &AccountId, amount: NearToken) {
}
}

/// Charges this submission's storage delta and refunds the excess. `initial_storage`
/// must be captured before the store has flushed (see
/// [`TeeState::store_verified_attestation`]); the [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`]
/// floor guarantees the refund never underflows.
fn keep_storage_delta_and_refund_rest(account_id: &AccountId, initial_storage: u64) {
// saturating_sub: a shrink charges nothing rather than underflowing.
let bytes_grown = env::storage_usage().saturating_sub(initial_storage);
let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown));
if let Some(refund) = env::attached_deposit().checked_sub(cost) {
refund_to(account_id, refund);
}
}
Comment thread
pbeza marked this conversation as resolved.
Outdated

impl Default for MpcContract {
fn default() -> Self {
env::panic_str("Calling default not allowed.");
Expand Down Expand Up @@ -782,8 +794,11 @@ impl MpcContract {
/// `verify_quote` call, with [`Self::resolve_verification`] chained as its
/// callback to run the post-DCAP checks and store the attestation.
///
/// The caller must attach a flat 0.1 NEAR fee for the stored entry; the whole
/// fee is kept on success and refunded if the attestation is not accepted.
/// The caller must attach at least [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`],
/// enough to cover the worst-case stored entry. On success only the actual
/// storage delta is kept and the excess is refunded, so a re-submission that
/// changes no stored bytes is charged nothing. The full deposit is refunded if
/// the attestation is not accepted.
#[payable]
#[handle_result]
Comment on lines +781 to 783

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.

It’s effectively the same as the pre-#3714 version of the contract. IIUC, v3.13.0 charged the measured delta, but add_participant never flushed the storage, so the delta was always read as 0 and new Mock entries were effectively free. The drain existed there too.

I’m not quite sure what a better funding model for the new attestations would be, given that the nodes’ function-call access keys can’t pay for storage.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agreed this isn't a regression and shouldn't block. One correction to the framing though, since #3972 is being written against it: this needs no TEE at all.

MockAttestation::Valid returns Ok(()) unconditionally from verify_constraints, submit_participant_info has no participant gate (just assert_caller_is_signer, which only rules out cross-contract calls), and entries are keyed by tls_public_key with no per-account limit — so a single ordinary account can mint unbounded entries with no attestation hardware, for gas only.

They're also permanent: clean_invalid_attestations removes only entries that fail re-verification, and Mock::Valid re-verifies as valid forever with no expiry field to age out. So the "bounded and self-healing, reclaimed by clean_invalid_attestations" argument holds for Dstack (which does expire and does get swept) but not for the cheap path.

So the Slack framing of "an arbitrary entity with a single TEE can slowly drain" is understating it. Worth #3972 pricing out the mock path explicitly — and gating Attestation::Mock behind a feature flag would remove the cheap variant independently of the deposit work.

pub fn submit_participant_info(
Expand Down Expand Up @@ -833,11 +848,13 @@ impl MpcContract {
Attestation::Mock(mock) => {
let tee_upgrade_deadline_duration =
Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds);
let initial_storage = env::storage_usage();
self.tee_state.verify_and_store_mock(
node_id,
mock,
tee_upgrade_deadline_duration,
)?;
keep_storage_delta_and_refund_rest(&account_id, initial_storage);
Ok(PromiseOrValue::Value(()))
}
Attestation::Dstack(attestation) => Ok(PromiseOrValue::Promise(
Expand Down Expand Up @@ -2301,8 +2318,9 @@ impl MpcContract {
}

/// Verify-quote callback: on a verifier verdict it runs the post-DCAP
/// checks and stores the attestation, refunding the flat fee if the
/// attestation is not accepted.
/// checks and stores the attestation, keeping the storage delta and
/// refunding the excess. Refunds the full deposit if the attestation is not
/// accepted.
#[private]
#[payable]
pub fn resolve_verification(
Expand Down Expand Up @@ -2352,9 +2370,8 @@ impl MpcContract {
}

/// Runs the post-DCAP checks and stores the attestation for a
/// [`VerificationResult::Verified`] response. The deposit was already
/// checked against the flat fee in [`Self::submit_participant_info`], so this
/// only verifies and stores.
/// [`VerificationResult::Verified`] response, then keeps the storage delta
/// and refunds the excess deposit.
fn verify_post_dcap_and_store(
&mut self,
context: &VerificationContext,
Expand All @@ -2364,6 +2381,7 @@ impl MpcContract {
let tee_upgrade_deadline_duration =
Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds);

let initial_storage = env::storage_usage();
if let Err(err) = self.tee_state.verify_and_store_dstack(
context.node_id.clone(),
&context.attestation,
Expand All @@ -2374,6 +2392,7 @@ impl MpcContract {
return Err(err.into());
}

keep_storage_delta_and_refund_rest(account_id, initial_storage);
Ok(())
}

Expand Down Expand Up @@ -8049,43 +8068,59 @@ mod tests {
assert!(configs.contains_key(&tls_key_b), "node B config must exist");
}

// Catches only entry-size growth: fails if a schema change makes the stored entry
// cost more than the fee at today's storage_byte_cost. It cannot see a future
// storage_byte_cost increase on a live contract; the fee's margin covers that.
// Catches entry-size growth: fails if a schema change makes the largest storable entry
// cost more than the deposit at today's storage_byte_cost. It cannot see a future
// storage_byte_cost increase on a live contract; the deposit's margin covers that.
#[test]
fn minimum_attestation_storage_deposit__should_cover_worst_case_entry() {
// Given: the largest entry a submission can store. NEAR caps an account id
// at 64 bytes; every other field is fixed-size, so this is the worst case.
testing_env!(VMContextBuilder::new().build());
// NEAR caps an account id at 64 bytes; every other NodeId field is fixed-size.
let node_id = create_node_id(
&"a".repeat(64).parse().unwrap(),
&bogus_ed25519_public_key(),
);
let worst_case = NodeAttestation {
node_id: node_id.clone(),
verified_attestation: VerifiedAttestation::Dstack(ValidatedDstackAttestation {
mpc_image_hash: [0xff; 32].into(),
launcher_compose_hash: [0xff; 32].into(),
expiry_timestamp_seconds: u64::MAX,
measurements: default_measurements()[0],
}),
let cost_of = |verified_attestation| {
let mut tee_state = TeeState::default();
let before = env::storage_usage();
tee_state.stored_attestations.insert(
node_id.tls_public_key.clone(),
NodeAttestation {
node_id: node_id.clone(),
verified_attestation,
},
);
tee_state.stored_attestations.flush();
let bytes_grown = env::storage_usage() - before;
let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown));
(bytes_grown, cost)
};
Comment thread
pbeza marked this conversation as resolved.
Outdated

// When: the entry is inserted and flushed, so storage_usage reflects it.
let mut tee_state = TeeState::default();
let storage_before = env::storage_usage();
tee_state
.stored_attestations
.insert(node_id.tls_public_key.clone(), worst_case);
tee_state.stored_attestations.flush();
let bytes_grown = env::storage_usage() - storage_before;
let worst_case_cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown));
// Given: the largest entry each variant can store. The Mock arm is not
// feature-gated, so a caller can force either variant.
let dstack = VerifiedAttestation::Dstack(ValidatedDstackAttestation {
mpc_image_hash: [0xff; 32].into(),
launcher_compose_hash: [0xff; 32].into(),
expiry_timestamp_seconds: u64::MAX,
measurements: default_measurements()[0],
});
let mock = VerifiedAttestation::Mock(MpcMockAttestation::WithConstraints {
mpc_docker_image_hash: Some([0xff; 32].into()),
launcher_docker_compose_hash: Some([0xff; 32].into()),
expiry_timestamp_seconds: Some(u64::MAX),
expected_measurements: Some(default_measurements()[0]),
});

// When
let (dstack_bytes, dstack_cost) = cost_of(dstack);
let (mock_bytes, mock_cost) = cost_of(mock);
let (worst_bytes, worst_cost) =
std::cmp::max((dstack_bytes, dstack_cost), (mock_bytes, mock_cost));

// Then: the flat fee covers the worst-case cost with headroom to spare.
// Then: the minimum deposit covers the worst case with headroom to spare.
assert!(
MINIMUM_ATTESTATION_STORAGE_DEPOSIT >= worst_case_cost,
"flat fee {MINIMUM_ATTESTATION_STORAGE_DEPOSIT} must cover the worst-case entry \
({bytes_grown} bytes, {worst_case_cost}) at today's storage price"
MINIMUM_ATTESTATION_STORAGE_DEPOSIT >= worst_cost,
"minimum deposit {MINIMUM_ATTESTATION_STORAGE_DEPOSIT} must cover the worst-case entry \
({worst_bytes} bytes, {worst_cost}) at today's storage price"
);
}
}
30 changes: 30 additions & 0 deletions crates/contract/src/tee/tee_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,10 @@ impl TeeState {
/// entry was newly inserted or updated an existing one. Rejects a submission whose
/// TLS key is already registered to a different account with
/// [`AttestationSubmissionError::TlsKeyOwnedByOtherAccount`].
///
/// Flushes the insert before returning, so a caller's subsequent
/// [`env::storage_usage`] reflects the stored bytes and can be used to charge
/// the storage delta.
fn store_verified_attestation(
&mut self,
node_id: NodeId,
Expand All @@ -236,6 +240,11 @@ impl TeeState {
},
);

// `IterableMap` defers writes to flush-on-Drop, so without an explicit
// flush the just-inserted entry reads as a zero storage delta and the
// caller would charge nothing for it.
self.stored_attestations.flush();

Ok(match previous {
Some(_) => ParticipantInsertion::UpdatedExistingParticipant,
None => ParticipantInsertion::NewlyInsertedParticipant,
Expand Down Expand Up @@ -846,6 +855,27 @@ mod tests {
);
}

#[test]
fn verify_and_store_mock__should_flush_so_storage_usage_grows() {
// given
testing_env!(VMContextBuilder::new().build());
let mut tee_state = TeeState::default();
let node_id = node_id_for(&"alice.near".parse().unwrap());
let storage_before = env::storage_usage();

// when
tee_state
.verify_and_store_mock(node_id, MockAttestation::Valid, Duration::from_secs(0))
.unwrap();

// then: without the internal flush the deferred write reads as a zero delta.
let storage_after = env::storage_usage();
assert!(
storage_after > storage_before,
"env::storage_usage() should grow after the store ({storage_before} -> {storage_after})"
);
}

#[test]
fn verify_and_store_mock__should_index_by_tls_key() {
// given
Expand Down
74 changes: 54 additions & 20 deletions crates/contract/tests/sandbox/tee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ use crate::sandbox::{
mpc_contract::{
assert_running_return_participants, assert_running_return_threshold,
get_participant_attestation, get_state, get_tee_accounts, submit_participant_info,
total_gas_fee, vote_add_launcher_hash, vote_for_hash,
submit_participant_info_and_measure_kept_deposit, vote_add_launcher_hash,
vote_for_hash,
},
resharing_utils::conclude_resharing,
sign_utils::DomainResponseTest,
Expand Down Expand Up @@ -1031,13 +1032,12 @@ async fn submit_participant_info__should_reject_new_attestation_below_flat_fee()
Ok(())
}

/// A submission attaching exactly the flat fee is stored, and the caller is
/// charged the whole fee with no excess refunded (the fee far exceeds the true
/// storage cost by design).
/// A first submission is stored, and only the actual storage delta is kept; the
/// excess over the true cost is refunded.
#[tokio::test]
async fn submit_participant_info__should_store_new_attestation_and_charge_the_flat_fee()
async fn submit_participant_info__should_store_new_attestation_and_keep_only_the_storage_delta()
-> Result<()> {
// Given
// given
let SandboxTestSetup {
worker, contract, ..
Comment thread
pbeza marked this conversation as resolved.
Outdated
} = SandboxTestSetup::builder()
Expand All @@ -1046,30 +1046,64 @@ async fn submit_participant_info__should_store_new_attestation_and_charge_the_fl
.await;
let outsider = worker.dev_create_account().await?;
let fresh_tls_key = bogus_ed25519_public_key();
let balance_before = outsider.view_account().await?.balance;

// When
let result = submit_participant_info(
// when
let kept = submit_participant_info_and_measure_kept_deposit(
&outsider,
Comment thread
pbeza marked this conversation as resolved.
Outdated
&contract,
&Attestation::Mock(MockAttestation::Valid),
&fresh_tls_key,
)
.await?;

// Then
assert!(
result.is_success(),
"submission attaching the flat fee should succeed: {result:?}"
);
// then
let stored = get_participant_attestation(&contract, &fresh_tls_key).await?;
assert!(stored.is_some(), "the entry should be stored on-chain");
assert!(
Comment thread
pbeza marked this conversation as resolved.
Outdated
stored.is_some(),
"the attestation entry should be stored on-chain"
kept > NearToken::from_yoctonear(0) && kept < SUBMIT_PARTICIPANT_INFO_DEPOSIT,
"a new entry keeps a nonzero storage delta below the full deposit, refunding the rest: \
kept {kept}"
);
Ok(())
}

/// Re-submitting an identical entry changes no stored bytes, so the second call
/// keeps nothing.
#[tokio::test]
async fn submit_participant_info__should_not_overcharge_identical_resubmission() -> Result<()> {
// given
let SandboxTestSetup {
worker, contract, ..
} = SandboxTestSetup::builder()
.with_protocols(ALL_PROTOCOLS)
.build()
.await;
let outsider = worker.dev_create_account().await?;
let fresh_tls_key = bogus_ed25519_public_key();
let attestation = Attestation::Mock(MockAttestation::Valid);
let first_kept = submit_participant_info_and_measure_kept_deposit(
&outsider,
&contract,
&attestation,
&fresh_tls_key,
)
.await?;

// when
let resubmission_kept = submit_participant_info_and_measure_kept_deposit(
&outsider,
&contract,
&attestation,
&fresh_tls_key,
)
.await?;

// then
assert_eq!(
resubmission_kept,
NearToken::from_yoctonear(0),
"an identical re-submission stores no new bytes and must keep nothing: \
first kept {first_kept}, resubmission kept {resubmission_kept}"
);
let balance_after = outsider.view_account().await?.balance;
let net_spent = balance_before.saturating_sub(balance_after);
let non_gas_spent = net_spent.saturating_sub(total_gas_fee(&result));
assert_eq!(non_gas_spent, SUBMIT_PARTICIPANT_INFO_DEPOSIT);
Ok(())
}
16 changes: 16 additions & 0 deletions crates/contract/tests/sandbox/utils/mpc_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ pub async fn submit_participant_info(
.map_err(Into::into)
}

/// Submits an attestation and returns the non-gas amount the caller was left
/// out of pocket, i.e. the kept storage deposit after subtracting gas fees.
pub async fn submit_participant_info_and_measure_kept_deposit(
account: &Account,
contract: &Contract,
attestation: &Attestation,
tls_key: &Ed25519PublicKey,
) -> anyhow::Result<NearToken> {
let balance_before = account.view_account().await?.balance;
let result = submit_participant_info(account, contract, attestation, tls_key).await?;
assert!(result.is_success(), "submission should succeed: {result:?}");
let balance_after = account.view_account().await?.balance;
let net_spent = balance_before.saturating_sub(balance_after);
Ok(net_spent.saturating_sub(total_gas_fee(&result)))
}
Comment thread
pbeza marked this conversation as resolved.
Outdated

pub async fn vote_tee_verifier_change(
account: &Account,
contract: &Contract,
Expand Down
5 changes: 3 additions & 2 deletions crates/near-mpc-contract-interface/src/deposits.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Deposit amounts to attach to contract methods, in milli-NEAR. One shared
//! value for node, tests, and e2e.

/// Deposit for `submit_participant_info`. The contract requires exactly this
/// flat fee to store the bounded attestation entry; nothing is refunded.
/// Deposit for `submit_participant_info`. Sized to cover the worst-case
/// attestation entry; the contract keeps only the actual storage delta and
/// refunds the rest.
pub const SUBMIT_PARTICIPANT_INFO_DEPOSIT_MILLINEAR: u128 = 100;
2 changes: 1 addition & 1 deletion docs/design/attestation-verifier-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ Each [`PendingAttestation`](#mpc-contractsubmit_participant_info) holds:

- **The submitter's `Attestation::Dstack` payload** — the RTMR3 event log, app-compose, and report-data the post-DCAP checks consume.
- **The submitter's TLS public key** — the callback hashes it with the submitter's account public key and compares to the quote's `report_data` field, proving the enclave produced the quote for this specific submitter.
- **The attached deposit** — covers storage staking on success, refunded to the signer of the original `submit_participant_info` transaction on failure. `env::attached_deposit()` is not visible from the callback receipt, so the value is stashed at submit time and the recipient `AccountId` is the same one used to key the entry (set by `Self::assert_caller_is_signer()`).
- **The attached deposit** — on success the contract keeps only the actual storage delta of the stored entry and refunds the excess; on failure the full deposit is refunded to the signer of the original `submit_participant_info` transaction. `env::attached_deposit()` is not visible from the callback receipt, so the value is stashed at submit time and the recipient `AccountId` is the same one used to key the entry (set by `Self::assert_caller_is_signer()`).
Comment thread
pbeza marked this conversation as resolved.
Outdated
- **`data_id: CryptoHash`** — the yield handle returned by [`env::promise_yield_create`][promise-yield-create]. The intermediate `.then` callback (`resolve_verification`) reads this back to call [`env::promise_yield_resume`][promise-yield-resume] with a `FinalOutcome` after the post-DCAP checks have run.

Entries are removed by `resolve_verification` on every branch where the verifier returned an answer — `Verified` (post-DCAP success or failure) and `Rejected`; only when the verifier gave no verdict (`Err(PromiseError::Failed)` — unreachable or crashed) does it return early and leave the entry in place. `on_attestation_verified` then removes the entry on its `Err(PromiseError::Failed)` branch, which covers that unreachable case and the no-response timeout.
Expand Down
Loading