Skip to content

Commit b1ab8eb

Browse files
committed
fix(contract): charge attestation storage by measured delta; fund e2e onboarding
The fixed MINIMUM_ATTESTATION_STORAGE_DEPOSIT floor rejected zero-deposit submissions outright. Restore the pre-#3714 behavior: charge only the measured storage delta and reject only when the attached deposit is below it. A participant re-attesting an unchanged entry grows nothing, so it pays nothing and its function-call key works; a new entry costs the measured delta, funded by a deposit-capable caller. In e2e, fund each new participant's first attestation before resharing, signed with the node's near_signer_key so the stored account_public_key matches the key the node later uses for key-event votes (an operator key would store the wrong key and stall resharing). Add MpcNodeState::near_signer_key for this. Repurpose the worst-case test to assert the entry cost stays under a bounded ceiling, and regenerate the ABI snapshot.
1 parent e7128f2 commit b1ab8eb

4 files changed

Lines changed: 79 additions & 39 deletions

File tree

crates/contract/src/lib.rs

Lines changed: 23 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,6 @@ const MINIMUM_CKD_REQUEST_DEPOSIT: NearToken = NearToken::from_yoctonear(1);
117117
/// node key cannot invoke these methods.
118118
pub const MINIMUM_NODE_MANAGEMENT_DEPOSIT: NearToken = NearToken::from_yoctonear(1);
119119

120-
/// Minimum a node must attach to [`MpcContract::submit_participant_info`],
121-
/// sized to cover the worst-case stored entry. Only the actual storage delta is
122-
/// kept; the excess is refunded.
123-
pub const MINIMUM_ATTESTATION_STORAGE_DEPOSIT: NearToken = NearToken::from_millinear(100);
124-
125120
/// Entries to scan in the post-reshare `clean_invalid_attestations` sweep. External
126121
/// callers may pick a different value; this only governs the automatic invocation.
127122
const RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN: u32 = 100;
@@ -161,28 +156,23 @@ fn refund_to(account_id: &AccountId, amount: NearToken) {
161156
}
162157
}
163158

164-
/// Requires at least [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`], then keeps the storage delta since
165-
/// `initial_storage` and refunds the excess.
159+
/// Charges `account_id` the storage growth since `initial_storage` and refunds the excess. A
160+
/// participant re-attesting an unchanged entry grows nothing and pays nothing, so the node's
161+
/// function-call access key can re-attest; a new entry costs the measured delta, funded by the
162+
/// caller (an operator's full-access key for a first submission).
166163
fn charge_storage(account_id: &AccountId, initial_storage: u64) -> Result<(), Error> {
164+
// saturating_sub: a shrink charges nothing rather than underflowing.
165+
let bytes_grown = env::storage_usage().saturating_sub(initial_storage);
166+
let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown));
167167
let attached = env::attached_deposit();
168-
if attached < MINIMUM_ATTESTATION_STORAGE_DEPOSIT {
168+
if attached < cost {
169169
return Err(InvalidParameters::InsufficientDeposit {
170170
attached: attached.as_yoctonear(),
171-
required: MINIMUM_ATTESTATION_STORAGE_DEPOSIT.as_yoctonear(),
171+
required: cost.as_yoctonear(),
172172
}
173173
.into());
174174
}
175-
176-
// saturating_sub: a shrink charges nothing rather than underflowing.
177-
let bytes_grown = env::storage_usage().saturating_sub(initial_storage);
178-
let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown));
179-
match attached.checked_sub(cost) {
180-
Some(refund) => refund_to(account_id, refund),
181-
// Unreachable given the MINIMUM_ATTESTATION_STORAGE_DEPOSIT floor, which
182-
// minimum_attestation_storage_deposit__should_cover_worst_case_entry pins to the
183-
// worst-case entry cost.
184-
None => log!("attestation storage cost {cost} exceeded deposit for {account_id}"),
185-
}
175+
refund_to(account_id, attached.saturating_sub(cost));
186176
Ok(())
187177
}
188178

@@ -809,11 +799,11 @@ impl MpcContract {
809799
/// callback to run the post-DCAP checks and store the attestation.
810800
///
811801
/// 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.
802+
/// current participant. A participant re-attesting an existing entry grows nothing and is charged
803+
/// nothing, so the node's function-call access key can re-attest with no deposit. A new entry
804+
/// costs the measured storage delta, which the caller must cover (an operator's full-access key
805+
/// funds a first submission); the excess is refunded, as is the full deposit on a rejected
806+
/// attestation.
817807
#[payable]
818808
#[handle_result]
819809
pub fn submit_participant_info(
@@ -4674,7 +4664,7 @@ mod tests {
46744664
let context = VMContextBuilder::new()
46754665
.current_account_id(contract_account_id.clone())
46764666
.predecessor_account_id(contract_account_id)
4677-
.attached_deposit(MINIMUM_ATTESTATION_STORAGE_DEPOSIT)
4667+
.attached_deposit(NearToken::from_near(1))
46784668
.block_timestamp(VALID_ATTESTATION_TIMESTAMP * 1_000_000_000)
46794669
.build();
46804670
testing_env!(context);
@@ -8139,9 +8129,9 @@ mod tests {
81398129

81408130
const MAX_HASH: [u8; 32] = [0xff; 32];
81418131

8142-
// Catches entry-size growth: fails if a schema change makes the largest storable entry
8143-
// cost more than the deposit at today's storage_byte_cost. It cannot see a future
8144-
// storage_byte_cost increase on a live contract; the deposit's margin covers that.
8132+
// A stored entry a caller must fund stays bounded; a schema change that bloats it fails here.
8133+
const WORST_CASE_ENTRY_COST_CEILING: NearToken = NearToken::from_millinear(100);
8134+
81458135
#[rstest]
81468136
#[case::dstack(VerifiedAttestation::Dstack(ValidatedDstackAttestation {
81478137
mpc_image_hash: MAX_HASH.into(),
@@ -8155,7 +8145,7 @@ mod tests {
81558145
expiry_timestamp_seconds: Some(u64::MAX),
81568146
expected_measurements: Some(default_measurements()[0]),
81578147
}))]
8158-
fn minimum_attestation_storage_deposit__should_cover_worst_case_entry(
8148+
fn submit_participant_info__should_bound_worst_case_entry_cost(
81598149
#[case] verified_attestation: VerifiedAttestation,
81608150
) {
81618151
testing_env!(VMContextBuilder::new().build());
@@ -8179,9 +8169,9 @@ mod tests {
81798169
let cost = env::storage_byte_cost().saturating_mul(u128::from(bytes_grown));
81808170

81818171
assert!(
8182-
MINIMUM_ATTESTATION_STORAGE_DEPOSIT >= cost,
8183-
"minimum deposit {MINIMUM_ATTESTATION_STORAGE_DEPOSIT} must cover the worst-case entry \
8184-
({bytes_grown} bytes, {cost}) at today's storage price"
8172+
cost <= WORST_CASE_ENTRY_COST_CEILING,
8173+
"worst-case entry cost ({bytes_grown} bytes, {cost}) must stay under \
8174+
{WORST_CASE_ENTRY_COST_CEILING} at today's storage price"
81858175
);
81868176
}
81878177
}

crates/contract/tests/sandbox/tee.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,10 +1050,10 @@ async fn verify_tee__should_keep_participants_and_stop_signing_when_kickout_drop
10501050
Ok(())
10511051
}
10521052

1053-
/// A submission attaching less than the flat storage fee is rejected before the
1053+
/// A new-entry submission attaching less than the measured storage cost is rejected before the
10541054
/// entry is stored.
10551055
#[tokio::test]
1056-
async fn submit_participant_info__should_reject_new_attestation_below_flat_fee() -> Result<()> {
1056+
async fn submit_participant_info__should_reject_new_attestation_below_storage_cost() -> Result<()> {
10571057
// Given
10581058
let SandboxTestSetup {
10591059
worker, contract, ..
@@ -1064,7 +1064,8 @@ async fn submit_participant_info__should_reject_new_attestation_below_flat_fee()
10641064
let outsider = worker.dev_create_account().await?;
10651065
let fresh_tls_key = bogus_ed25519_public_key();
10661066
let storage_before = worker.view_account(contract.id()).await?.storage_usage;
1067-
let below_fee = SUBMIT_PARTICIPANT_INFO_DEPOSIT.saturating_sub(NearToken::from_yoctonear(1));
1067+
// 1 yoctoNEAR is below any nonzero entry cost.
1068+
let below_cost = NearToken::from_yoctonear(1);
10681069

10691070
// When
10701071
let result = outsider
@@ -1073,15 +1074,15 @@ async fn submit_participant_info__should_reject_new_attestation_below_flat_fee()
10731074
Attestation::Mock(MockAttestation::Valid),
10741075
fresh_tls_key.clone(),
10751076
))
1076-
.deposit(below_fee)
1077+
.deposit(below_cost)
10771078
.max_gas()
10781079
.transact()
10791080
.await?;
10801081

10811082
// Then
10821083
assert!(
10831084
!result.is_success(),
1084-
"submission below the flat fee must fail: {result:?}"
1085+
"submission below the storage cost must fail: {result:?}"
10851086
);
10861087
let error_msg = format!("{:?}", result.into_result());
10871088
assert!(

crates/contract/tests/snapshots/abi__abi_has_not_changed.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2349,7 +2349,7 @@ expression: abi
23492349
},
23502350
{
23512351
"name": "submit_participant_info",
2352-
"doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously via a cross-contract\n `verify_quote` call, with [`Self::resolve_verification`] chained as its\n callback to run the post-DCAP checks and store the attestation.\n\n Storage is charged to the caller only when a new entry is stored or the caller is not a\n current participant. A participant re-attesting an existing entry is charged nothing and\n need not attach any deposit, so the node's function-call access key can re-attest. When a\n charge applies, the caller must attach at least [`MINIMUM_ATTESTATION_STORAGE_DEPOSIT`]\n (enough to cover the worst-case stored entry); only the actual storage delta is kept and\n the excess is refunded. The full deposit is refunded if the attestation is not accepted.",
2352+
"doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously via a cross-contract\n `verify_quote` call, with [`Self::resolve_verification`] chained as its\n callback to run the post-DCAP checks and store the attestation.\n\n Storage is charged to the caller only when a new entry is stored or the caller is not a\n current participant. A participant re-attesting an existing entry grows nothing and is charged\n nothing, so the node's function-call access key can re-attest with no deposit. A new entry\n costs the measured storage delta, which the caller must cover (an operator's full-access key\n funds a first submission); the excess is refunded, as is the full deposit on a rejected\n attestation.",
23532353
"kind": "call",
23542354
"modifiers": [
23552355
"payable"

crates/e2e-tests/src/cluster.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ pub fn cluster_poll_retry() -> ConstantBuilder {
4949
}
5050

5151
const NODE_MANAGEMENT_DEPOSIT: near_kit::NearToken = near_kit::NearToken::from_yoctonear(1);
52+
// Covers a new attestation entry's storage cost; the contract refunds the excess. The contract test
53+
// submit_participant_info__should_bound_worst_case_entry_cost asserts the worst-case entry stays
54+
// under this amount.
55+
const ATTESTATION_STORAGE_DEPOSIT: near_kit::NearToken = near_kit::NearToken::from_millinear(100);
5256
// The contract's default `key_event_timeout_blocks = 30` is ~18 s on
5357
// mainnet (~600 ms blocks). The e2e sandbox runs ~8 blocks/s, so the
5458
// same 30 collapses to ~3.7 s — too tight for the resharing
@@ -559,6 +563,8 @@ impl MpcCluster {
559563
new_participants: &[usize],
560564
new_threshold: usize,
561565
) -> anyhow::Result<()> {
566+
self.fund_new_participant_attestations(new_participants)
567+
.await?;
562568
self.wait_for_participant_attestations(new_participants)
563569
.await?;
564570

@@ -611,6 +617,42 @@ impl MpcCluster {
611617
.map(|_| ())
612618
}
613619

620+
/// Submit each new participant's first attestation with a deposit covering the new entry's
621+
/// storage cost, which the node's own function-call key cannot attach on-chain. Signed with the
622+
/// node's near_signer_key so the stored account_public_key matches the key it later signs
623+
/// key-event votes with. Idempotent: the node's later self-submit of the same entry is free.
624+
async fn fund_new_participant_attestations(
625+
&self,
626+
node_indices: &[usize],
627+
) -> anyhow::Result<()> {
628+
for &idx in node_indices {
629+
let node = &self.nodes[idx];
630+
let client = self
631+
.blockchain
632+
.client_for(node.account_id().as_ref(), node.near_signer_key())?;
633+
let pubkey = node.p2p_public_key();
634+
let outcome = self
635+
.contract
636+
.call_from_deposit(
637+
&client,
638+
method_names::SUBMIT_PARTICIPANT_INFO,
639+
json!({
640+
"proposed_participant_attestation": Attestation::Mock(MockAttestation::Valid),
641+
"tls_public_key": pubkey,
642+
}),
643+
ATTESTATION_STORAGE_DEPOSIT,
644+
)
645+
.await
646+
.with_context(|| format!("failed to fund attestation for node {idx}"))?;
647+
anyhow::ensure!(
648+
outcome.is_success(),
649+
"funding attestation for node {idx} reverted: {:?}",
650+
outcome.failure_message()
651+
);
652+
}
653+
Ok(())
654+
}
655+
614656
/// Poll until all proposed participants have TEE attestations on-chain.
615657
async fn wait_for_participant_attestations(
616658
&self,
@@ -1182,6 +1224,13 @@ impl MpcNodeState {
11821224
}
11831225
}
11841226

1227+
pub fn near_signer_key(&self) -> &SigningKey {
1228+
match self {
1229+
MpcNodeState::Running(n) => n.setup().near_signer_key(),
1230+
MpcNodeState::Stopped(s) => s.near_signer_key(),
1231+
}
1232+
}
1233+
11851234
pub fn p2p_public_key_str(&self) -> String {
11861235
String::from(&self.p2p_public_key())
11871236
}

0 commit comments

Comments
 (0)