Skip to content

feat(contract): operator-prepaid attestation storage (grant counter) - #4016

Merged
gilcu3 merged 18 commits into
mainfrom
4015-operator-prepaid-attestation-storage
Aug 7, 2026
Merged

feat(contract): operator-prepaid attestation storage (grant counter)#4016
gilcu3 merged 18 commits into
mainfrom
4015-operator-prepaid-attestation-storage

Conversation

@barakeinav1

@barakeinav1 barakeinav1 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes #4015. Implements docs/design/operator-prepaid-attestation-storage.md, agreed in #3972 / #4011 — read that for the model, the alternatives considered and the fee derivation.

An operator prepays for attestation-entry storage in a separate transaction; the node keeps self-submitting with its deposit-less function-call key. One prepayment buys one grant, and the grant returns when the entry it paid for is reclaimed.

  • prepay_attestation_storage(account_id, grants) — payable, permissionless, requires exactly fee × grants.
  • available_attestation_grants(account_id) view. The fee is read from config(); there is deliberately no dedicated view (why).
  • Config.attestation_storage_fee_millinear, default 20 (0.02 NEAR), votable via ConfigExt.
  • Charging: re-attestation consumes nothing; a new entry consumes one grant; clean_invalid_attestations returns one grant per entry it removes.

Worth a reviewer's attention

  • The precondition is read-only and runs before any verification, and compares the owning account rather than just testing key presence — otherwise a submission for somebody else's key would be classified as needing no grant and would still reach verify_quote. It is re-checked inside resolve_verification, because that callback runs a receipt later, where the grant may since have been consumed.
  • TeeState::clean_invalid_attestations now returns the owners of the entries it removed rather than a count, so the caller can credit them. MpcContract::clean_invalid_attestations still returns the count, so the external interface is unchanged.
  • Entries predating the fee need no handling — they already hold a slot no grant was bought for, and re-attestation is free. Migration only initialises the map. See the doc's Existing nodes.
  • All three test harnesses prepay only when a submission would actually consume a grant. The e2e harness prepays for every node, not just initial participants: a node joining by resharing attests from its own process with a key that cannot attach a deposit, so its grant must exist beforehand.

Out of scope

Two follow-ups, neither affecting the contract:

  • e2e cluster prepays attestation grants for initial participants that never consume them #4082 — the e2e cluster prepays a grant for every node, including initial participants, whose sentinel entry from init means they never consume it. Wasteful, not harmful.
  • clean_invalid_attestations: gas budget cannot cover max_scan #4035 — sweep gas. This adds a per-removal write taking the marginal cost from 0.347 to 0.504 TGas, but clean_invalid_attestations_tera_gas was already short of RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN on main. Nothing to do here, though: those two constants govern only the promise vote_reshared schedules. clean_invalid_attestations is permissionless and takes max_scan as a parameter, so an external caller is bound by neither — one manual call at ~300 TGas clears roughly 550 entries. The worst this PR contributes is a manual invocation, not a stuck backlog.

@jackson-harris-iii
jackson-harris-iii force-pushed the 4015-operator-prepaid-attestation-storage branch from 54e2e95 to 93c9ab5 Compare August 1, 2026 11:14
@andrei-near
andrei-near force-pushed the 4015-operator-prepaid-attestation-storage branch from 93c9ab5 to 54e2e95 Compare August 1, 2026 16:02
@barakeinav1
barakeinav1 force-pushed the 4015-operator-prepaid-attestation-storage branch from 59b9b69 to 1affa28 Compare August 3, 2026 12:08
@barakeinav1
barakeinav1 marked this pull request as ready for review August 3, 2026 12:28
@barakeinav1
barakeinav1 force-pushed the 4015-operator-prepaid-attestation-storage branch from 1affa28 to 1f80a7e Compare August 3, 2026 12:28
Copilot AI review requested due to automatic review settings August 3, 2026 12:28
@barakeinav1
barakeinav1 force-pushed the 4015-operator-prepaid-attestation-storage branch from 1f80a7e to 263426a Compare August 3, 2026 12:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Implements the operator-prepaid attestation storage “grant counter” model in the mpc-contract, shifting the cost of stored attestation entries from contract-funded storage to operator prepayment while keeping nodes self-submitting using deposit-less function-call keys.

Changes:

  • Adds an attestation-storage grant counter (attestation_grants) plus prepay_attestation_storage and available_attestation_grants contract methods, and wires grant consumption/return into attestation insertions and sweep cleanup.
  • Extends contract configuration and DTO plumbing with attestation_storage_fee_millinear (defaulting to 20 milliNEAR) and updates migration/state keys accordingly.
  • Updates docs and expands sandbox/in-process tests and ABI snapshots to cover the new prepay-and-consume behavior.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
docs/running-an-mpc-node-in-tdx-external-guide.md Adds an operator prepayment step and updates the submit note to reflect prepaid storage requirements.
crates/test-utils/src/contract_types.rs Extends dummy config with attestation_storage_fee_millinear.
crates/near-mpc-contract-interface/src/types/config.rs Adds attestation_storage_fee_millinear to InitConfig/Config plus serialization tests.
crates/contract/tests/snapshots/abi__abi_has_not_changed.snap Updates ABI snapshot for new public methods and config field.
crates/contract/tests/sandbox/utils/mpc_contract.rs Adds sandbox helper to prepay grants and updates submit helper to prepay when needed.
crates/contract/tests/sandbox/upgrade_from_current_contract.rs Updates config update test data to include the new fee field.
crates/contract/tests/sandbox/tee_verifier.rs Adjusts sandbox verifier tests to separate prepayment from submission for balance assertions.
crates/contract/tests/sandbox/contract_configuration.rs Includes attestation_storage_fee_millinear in initialization config test.
crates/contract/tests/inprocess/attestation_submission.rs Adds in-process coverage for grant rejection, consumption, multi-entry, TLS-key ownership, exact deposit multiple, and sweep grant return.
crates/contract/src/v3_13_0_state.rs Migration initializes the new attestation_grants map and updates cleanup return type usage.
crates/contract/src/tee/tee_state.rs Makes sweep return entry owners (not just a count) and adds attestation_owner helper for early grant classification.
crates/contract/src/storage_keys.rs Adds a storage key variant for the new grants map.
crates/contract/src/lib.rs Core implementation: grant accounting, prepay entrypoint, early precondition, callback re-check, and sweep crediting.
crates/contract/src/errors.rs Adds new InvalidParameters variants for exact-deposit mismatch and missing grants.
crates/contract/src/dto_mapping.rs Wires fee field between contract config and interface DTOs.
crates/contract/src/config.rs Adds default attestation_storage_fee_millinear to contract config.
crates/contract/README.md Documents the new prepay and grants view endpoints in the contract API list.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/contract/src/lib.rs Outdated
Comment thread crates/contract/src/lib.rs
Comment thread crates/contract/tests/sandbox/utils/mpc_contract.rs Outdated
Comment thread crates/contract/src/lib.rs
@barakeinav1
barakeinav1 force-pushed the 4015-operator-prepaid-attestation-storage branch 3 times, most recently from c8eb6e9 to 70bc171 Compare August 3, 2026 13:30
@barakeinav1

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Aug 3, 2026

Copy link
Copy Markdown

Pull request overview

Shifts the cost of a stored attestation entry from the contract's balance to an operator prepayment, keeping the node self-submitting with its deposit-less function-call key. prepay_attestation_storage(account_id, grants) buys grants; one is consumed when a new entry is inserted and returned when the sweep reclaims it. Re-attestation under a TLS key the caller already owns stays free, so entries that predate the upgrade need no action.

Changes:

  • attestation_grants: LookupMap<AccountId, u32> (available grants, row dropped at zero), payable permissionless prepay_attestation_storage, available_attestation_grants view.
  • Read-only precondition before any verification in submit_participant_info, re-checked in resolve_verification (later receipt); consumption only on ParticipantInsertion::NewlyInsertedParticipant.
  • TeeState::clean_invalid_attestations returns removed-entry owners so the caller can credit a grant each; the public method still returns a count.
  • Votable Config.attestation_storage_fee_millinear (default 20 mNEAR) threaded through DTOs, migration, snapshots, fixtures; operator guide gains a prepayment step.

Reviewed changes

Per-file summary
File Description
crates/contract/src/lib.rs Grant state, prepay entrypoint + view, precondition, callback re-check, consume/return helpers, sweep crediting.
crates/contract/src/config.rs New attestation_storage_fee_millinear, default 20 mNEAR.
crates/contract/src/dto_mapping.rs Maps the fee field both ways plus the InitConfig override.
crates/contract/src/errors.rs UnexpectedDeposit, NoAttestationStorageGrant.
crates/contract/src/storage_keys.rs Appends AttestationGrantsV1 (existing prefixes stable).
crates/contract/src/tee/tee_state.rs Sweep returns owners; new read-only attestation_owner.
crates/contract/src/v3_13_0_state.rs Migration initialises the empty grants map.
crates/contract/src/snapshots/…borsh_schema….snap, tests/snapshots/abi…snap Record the new field, map, and two methods.
crates/contract/tests/inprocess/attestation_submission.rs Rejection without a grant, consume-on-insert, two grants → two entries, foreign TLS key, exact-deposit rstest, zero grants, cross-account funding, sweep return + reuse.
crates/contract/tests/sandbox/utils/mpc_contract.rs Adds prepay_attestation_grants + submit_participant_info_raw; existing helper prepays when needed.
crates/contract/tests/sandbox/tee_verifier.rs Separate payer so balance assertions still isolate the failed submission.
crates/contract/tests/sandbox/{contract_configuration,upgrade_from_current_contract}.rs Fill in the new config field.
crates/near-mpc-contract-interface/src/types/config.rs Field added to InitConfig/Config + round-trip tests.
crates/test-utils/src/contract_types.rs Extends dummy_config.
crates/contract/README.md Documents the two new endpoints.
docs/running-an-mpc-node-in-tdx-external-guide.md Prepayment step; drops the stale "TBD, XXX NEAR" note.

Findings

Blocking (must fix before merge):

  • crates/e2e-tests/src/cluster.rs:1311The e2e harness was not updated and the MPC E2E tests job is red on this PR. init mocks attestations only for initial_participant_indices (build_participants, cluster.rs:1462), so any node outside that set has no entry and no grant, and its self-submission now hits NoAttestationStorageGrant. Breaks at least: tests/submit_participant_info.rs:12 (4 nodes, 2 initial participants; waits for all 4 in get_tee_accounts, will stall at 2) and tests/key_resharing.rs:19 / cancellation_of_resharing.rs:18 (nodes 2..4 join by resharing, and vote_new_parameters requires a valid stored attestation for each proposed participant — lib.rs:913InvalidTeeRemoteAttestation). Fix by mirroring the operator step in the harness: after init, call prepay_attestation_storage for every node account, not just the participants, with the deposit read from config().

  • crates/contract/src/lib.rs:841 — Doc drift on the funding model: the claim this PR inverts is still asserted in six places, which CLAUDE.md §Documentation alignment treats as review-blocking.

    • lib.rs:841 (submit_participant_info): "Storage is funded by the contract's own balance, so a node submits with no deposit … for its first attestation and re-attestations alike."
    • lib.rs:2448 (resolve_verification), lib.rs:2497 (verify_post_dcap_and_store): "storage funded by the contract's balance".
    • crates/contract/src/tee/tee_state.rs:445: "Returns the number of entries removed" — it now returns the owners.
    • docs/securing-mpc-with-tee-design-doc.md:389, docs/design/attestation-verifier-contract.md:144: both state storage is contract-funded and no deposit exists in the flow.

    The first two strings are also baked into tests/snapshots/abi__abi_has_not_changed.snap:1320 and :2344, so the ABI snapshot needs re-accepting. crates/contract/README.md:313 still describes submit_participant_info without the grant precondition.

  • crates/contract/tests/sandbox/tee.rs:1039submit_participant_info__should_store_new_entry_with_zero_deposit and its doc ("the contract's own balance funds the storage") now claim something false; the test passes only because the shared helper silently prepays. The in-process twin was renamed to …should_store_a_new_entry_and_consume_one_grant — do the same here (prepay explicitly, assert the grant hits 0), or split into with-grant / without-grant cases.

  • crates/contract/src/config.rs:27 — Dangling reference: the comment points at docs/design/operator-prepaid-attestation-storage.md, which does not exist and is not added here. The 20 mNEAR figure is exactly the number a reviewer and an operator will want justified, and the PR body also defers to that doc's "Existing nodes" section. Add the design doc in this PR, or inline the derivation (worst-case entry + grant row ≈ 640 B against the ~2 kB that 20 mNEAR buys) and drop the reference.

  • crates/contract/src/lib.rs:1999 — The per-removal grant write pushes the reshare-time sweep below the current fleet size. RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN is 100 (lib.rs:122) against DEFAULT_CLEAN_INVALID_ATTESTATIONS_TERA_GAS = 10 (config.rs:29), and per your measurement the marginal cost goes 0.347 → 0.504 TGas per removal, taking capacity from ~14 removals to ~10 while mainnet holds 14 entries. A whitelist change that invalidates the whole set — the normal trigger for a mass sweep — now runs out of gas; the promise is detached (lib.rs:1208), so the receipt reverts every removal and every grant return with nothing observable, and each later resharing repeats it identically. clean_invalid_attestations: gas budget cannot cover max_scan #4035 can still own reconciling the constants and making the failure visible, but this PR is what crosses the 14-entry line and the mitigation is one constant: raise DEFAULT_CLEAN_INVALID_ATTESTATIONS_TERA_GAS, or lower the reshare max_scan to what 10 TGas actually buys.

Non-blocking (nits, follow-ups, suggestions):

  • crates/contract/tests/inprocess/attestation_submission.rs (…should_reject_a_tls_key_owned_by_another_account) — assert_matches!(&result, Err(_)) doesn't pin what the test claims. The store rejects a foreign TLS key too, and its Err short-circuits before consume_…, so both the grant assertion and the loose Err(_) hold even if the precondition is removed. Assert the concrete TlsKeyOwnedByOtherAccount variant.
  • crates/contract/src/config.rs:28 — the fee is votable and 0 is accepted; at 0 the required deposit is 0, so prepay_attestation_storage becomes callable by function-call keys and anyone can mint u32::MAX grants, restoring free contract-funded storage. Reject 0 in update_config, or document 0 as an intentional kill-switch.
  • crates/contract/src/lib.rs:1997 — "Rule 3:" (and the same prefix on the sweep test's doc) numbers a rule that exists only in the PR description and the absent design doc (engineering-standards.md §Write helpful code comments, items 3 and 5). State the behaviour instead.
  • crates/contract/src/lib.rs:774 — free grants are minted for any swept entry that never consumed one; that includes the sentinels init/init_running seed via with_mocked_participant_attestations (lib.rs:1969, lib.rs:2051), not just pre-upgrade entries as the PR body implies. Still bounded by participant count — worth stating accurately wherever the exemption is documented.
  • crates/contract/src/lib.rs:777available_attestation_grants takes an owned AccountId because it is a view, forcing account_id.clone() at all five internal call sites; a private fn grants(&self, &AccountId) -> u32 that the view delegates to removes them.
  • New doc comments name items in plain backticks (get_tee_accounts, config(), prepay_attestation_storage, submit_participant_info in tee_state::attestation_owner); engineering-standards.md asks for intra-doc links so cargo doc catches rot.
  • docs/running-an-mpc-node-in-tdx-external-guide.md — with no refund and no withdrawal, a typo in account_id burns the deposit permanently; worth saying outright that the id cannot be corrected afterwards.

⚠️ Issues found

@barakeinav1
barakeinav1 force-pushed the 4015-operator-prepaid-attestation-storage branch 4 times, most recently from 3c6e9d1 to 770a269 Compare August 4, 2026 07:02
barakeinav1 added a commit that referenced this pull request Aug 4, 2026
Addresses the review on #4016.

Doc drift (CLAUDE.md treats this as review-blocking): five places still said
attestation storage is funded by the contract's balance, which this PR inverts --
submit_participant_info, resolve_verification and verify_post_dcap_and_store
docstrings, plus securing-mpc-with-tee-design-doc.md and
attestation-verifier-contract.md. Each now says a new entry consumes a prepaid grant
and a re-attestation consumes none. The first two are embedded in the ABI, so the
snapshot is re-accepted.

The sandbox test called should_store_new_entry_with_zero_deposit claimed "the
contract's own balance funds the storage" and only passed because the shared helper
prepaid for it. Renamed to ...should_store_a_new_entry_against_a_prepaid_grant, with
a separate account prepaying explicitly and an assertion that the grant reaches zero.
"Zero deposit" is about the submitting node, whose function-call key cannot attach
one -- not about the storage being free.

Also: assert the concrete TlsKeyOwnedByOtherAccount rather than Err(_), which held
even with the precondition removed; reject a configured fee of zero, since a zero
required deposit is one a function-call key can attach, which would let anyone mint
grants; and drop a duplicated comment in the sandbox helper.
barakeinav1 added a commit that referenced this pull request Aug 4, 2026
Review follow-ups on #4016.

- return_attestation_storage_grant now uses checked_add, matching the credit in
  prepay_attestation_storage; a saturating add at u32::MAX would silently drop a
  grant that was genuinely returned.
- A private grants_for(&AccountId) does the lookup and the public view delegates to
  it, removing the clone the owned parameter forced at every internal call site. The
  view keeps an owned parameter because NEAR deserialises view arguments by value.
- A configured fee of zero stays permitted: the value is for governance to choose.
- Drop the "Rule 3:" prefix, which numbered a rule that exists only in the design
  doc rather than in the code.
- Operator guide: say outright that a grant prepaid to a mistyped account cannot be
  recovered or redirected, since nothing is refunded and there is no withdrawal.
@barakeinav1
barakeinav1 force-pushed the 4015-operator-prepaid-attestation-storage branch 2 times, most recently from 11011fb to 5cab95b Compare August 4, 2026 09:30
Implements the design in docs/design/operator-prepaid-attestation-storage.md
(#4015). An operator prepays for attestation-entry storage in a separate
transaction; the node keeps self-submitting with its deposit-less function-call key.
Payment and submission have to be separate: a function-call key cannot attach a
deposit, and report_data binds the quote to env::signer_account_pk(), so neither
party can do both halves.

One prepayment buys one grant -- permission to hold one stored attestation entry --
and the grant returns when that entry is reclaimed, so it is a slot the operator
keeps rather than a per-attestation charge.

Contract:
- prepay_attestation_storage(account_id, grants), payable and permissionless,
  requiring exactly fee x grants so there is no remainder to keep or refund.
- available_attestation_grants(account_id) view. The fee is read from config();
  there is deliberately no dedicated view for it.
- available_attestation_grants: LookupMap<AccountId, u32> holds available grants;
  the row is removed at zero so the map does not accumulate rows for accounts
  holding none.
- Config.attestation_storage_fee_millinear defaults to 20 (0.02 NEAR) and is votable
  through ConfigExt. Zero stays permitted: the value is governance's to choose.

Charging rules: a re-attestation under a key the caller already owns consumes
nothing; a new entry consumes one grant; clean_invalid_attestations returns one grant
to the owner of each entry it removes. The precondition is read-only and runs before
any verification, and compares the owning account rather than just testing key
presence -- otherwise a submission for somebody else's key would be classified as
needing no grant and would still reach verify_quote. It is re-checked inside
resolve_verification, since that callback runs in a later receipt where the grant may
since have been consumed.

Entries that predate the fee need no handling: they already hold a slot no grant was
bought for, and re-attestation is free, so those operators need no grant and no
action. Migration just initialises the map.

TeeState::clean_invalid_attestations now returns the owners of the entries it removed
rather than a count, so the caller can credit them; MpcContract still returns the
count, leaving the external interface unchanged.

Docs: operator guide gains the prepayment step after Create a NEAR Account for Your
Node, where the operator still holds that account's full-access key and the node has
not started yet; it reads the fee from config() rather than hard-coding it, and warns
that a grant prepaid to a mistyped account cannot be recovered. Drops the stale "will
incur a cost (TBD, XXX NEAR)" note citing the closed #903. Both new methods are
documented in the contract README's User API. Five docstrings that still claimed
storage is contract-funded now describe the grant instead.

Tests cover the guards as well as the happy path: exact-deposit rejection either side
by one yocto, zero grants, one account funding another, rejection without a grant, a
key owned by another account rejected before verification by its concrete error, and
a swept entry returning a grant that is then spendable without paying again. Both
sandbox and in-process harnesses prepay only when a submission would actually consume
a grant, and the e2e harness prepays for every node in the cluster -- a node joining
by resharing attests from its own process with a key that cannot attach a deposit, so
its grant must exist beforehand.

Sweep gas is out of scope and tracked in #4035: this adds a per-removal write that
takes the marginal cost from 0.347 to 0.504 TGas, but the budget was already short of
RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN on main.
@barakeinav1

Copy link
Copy Markdown
Contributor Author

Went through all of these. Two were already stale, three fixed, one deferred with reasoning, and the non-blocking list mostly done. Everything is in the single squashed commit.

The e2e harness was not updated and the MPC E2E tests job is red on this PR. … Fix by mirroring the operator step in the harness: after init, call prepay_attestation_storage for every node account, not just the participants

Fixed, and your diagnosis was exactly right including the "every node, not just participants" part — a node joining by resharing attests from its own process with a function-call key, so its grant has to exist beforehand. Took several rounds: the fee could not be read from config() there (the prepayment runs while the cluster is still starting, before the contract has state, so the view panics with "Calling default not allowed"), and the upgrade-compatibility tests run a production binary with no prepay_attestation_storage, so a method-not-found response now skips the prepayment instead of failing the cluster.

Doc drift on the funding model: the claim this PR inverts is still asserted in six places

Fixed, all six. submit_participant_info, resolve_verification and verify_post_dcap_and_store now describe the grant; tee_state::clean_invalid_attestations says it returns the owners; securing-mpc-with-tee-design-doc.md and attestation-verifier-contract.md both updated. The ABI snapshot is re-accepted, since the first two strings are embedded in it. The README's User API also gained rows for both new methods.

submit_participant_info__should_store_new_entry_with_zero_deposit and its doc … now claim something false; the test passes only because the shared helper silently prepays

Fixed. Renamed to …should_store_a_new_entry_against_a_prepaid_grant, with a separate account prepaying explicitly and an assertion that the grant reaches zero. Worth noting the "zero deposit" half was never wrong — it refers to what the node attaches, which is the point of the design; what was wrong was the doc claiming the storage was free.

You also caught a real flaw underneath this: both harnesses prepaid whenever an account had no grant, including for re-attestations that need none. Since participants already hold sentinel entries from init, every setup submission left a stray unused grant — which is why the function-call-key sandbox test was passing without ever prepaying. Both now prepay only when a submission would actually consume a grant.

Dangling reference: the comment points at docs/design/operator-prepaid-attestation-storage.md, which does not exist

Stale — #4011 merged and the doc is on main, pulled in here by a merge from main.

The per-removal grant write pushes the reshare-time sweep below the current fleet size.

Deferred to #4035 deliberately, and the severity is lower than we both had it. clean_invalid_attestations is permissionless and takes max_scan as a parameter, so an external caller is bound by neither RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN nor clean_invalid_attestations_tera_gas — those govern only the promise vote_reshared schedules. One manual call at ~300 TGas clears roughly 550 entries, so a backlog is never stuck and the worst this PR contributes is a manual invocation. Details and a correction to my own framing are on #4035.

Non-blocking

  • Loose Err(_) assertion — fixed, pins TlsKeyOwnedByOtherAccount. You were right that it proved nothing: the store rejects a foreign key too, so the test held even with the precondition removed.
  • Fee of 0 — left permitted by decision: the value is governance's to choose, and zero is a legitimate (if unwise) choice.
  • "Rule 3:" naming — fixed, states the behaviour instead.
  • Sentinels also mint grants — correct, and sharper than the PR body was. Acknowledged, no change; still bounded by participant count.
  • Owned AccountId forcing clones — fixed with a private grants_for(&AccountId) that the view delegates to; the view keeps an owned parameter because NEAR deserialises view arguments by value.
  • Intra-doc links — converted the three references that name items. But note the rot detection does not actually apply here: near-sdk refuses to build this crate outside wasm32, so cargo doc skips it and check-docs never sees these links. Verified by inserting a link to a nonexistent method and watching cargo doc --workspace exit 0. The paths were checked by hand.
  • Typo in account_id burns the deposit — said outright in the guide now, along with the current fee in NEAR (which also closes out the substance of [Docs-Missing] Document cost in NEAR tokens for calling submit_participant_info #903).

@barakeinav1

Copy link
Copy Markdown
Contributor Author

@claude review

@barakeinav1

Copy link
Copy Markdown
Contributor Author

@copilot lite review

`prepay_attestation_storage`'s exact-multiple and zero-grants cases are pure
input validation: they need a contract and a deposit context, nothing about
participants or protocol state, so they belong in `src` rather than the
in-process harness.
gilcu3
gilcu3 previously approved these changes Aug 7, 2026

@gilcu3 gilcu3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the fixes!

Left some nits (and a tiny blocker about a contract method name constant but I am not blocking for that one)

Comment thread crates/contract/tests/sandbox/utils/mpc_contract.rs Outdated
Comment thread crates/contract/tests/sandbox/tee.rs Outdated
Comment thread crates/contract/tests/sandbox/tee.rs Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I still believe some of these tests could still be unit tests, but we can leave that as a follow up, to not overcomplicate this one

Comment on lines +158 to +172
/// Like [`Self::call`], but waits for the block to be final, so a `view` issued
/// afterwards sees the state this call wrote.
pub async fn call_final(
&self,
method: &str,
args: serde_json::Value,
) -> anyhow::Result<FinalExecutionOutcome> {
self.client
.call(&self.contract_id, method)
.args(args)
.gas(MAX_GAS)
.wait_until::<Final>()
.await
.map_err(|e| anyhow::anyhow!("contract call `{method}` failed: {e}"))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

thought we could just reuse the call function above this one, but that one is not Final. It feels we could organize this a bit better to avoid so much duplicate code and to ensure the caller knows exactly what they are calling, but for now this is fine.

Comment thread crates/e2e-tests/src/cluster.rs
Comment thread crates/contract/src/lib.rs Outdated
Comment thread crates/contract/src/lib.rs Outdated
Comment thread crates/contract/src/lib.rs Outdated
Comment on lines +960 to +983
/// call site costs a failed transaction rather than an entry nobody paid for.
fn consume_attestation_storage_grant(&mut self, account_id: &AccountId) {
let remaining = self
.grants_for(account_id)
.checked_sub(1)
.expect("caller must establish an available grant before consuming one");
if remaining == 0 {
self.available_attestation_grants.remove(account_id);
} else {
self.available_attestation_grants
.insert(account_id.clone(), remaining);
}
}

fn return_attestation_storage_grant(&mut self, account_id: &AccountId) {
let available = self.grants_for(account_id);
// Checked, not saturating: at `u32::MAX` a returned grant would be dropped silently.
let Some(returned) = available.checked_add(1) else {
log!("grant counter for {account_id} is saturated; not returning a grant");
return;
};
self.available_attestation_grants
.insert(account_id.clone(), returned);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I see a small foot gun here related to the fact that at 0 the entry is deleted, then in the function below apparently that would cause that the grant is not returned. This is not the case because of the unwrap_or(0) inside grants_for but I wonder if there is a better way. I have no explicit suggestion though 😿

netrome
netrome previously approved these changes Aug 7, 2026

@netrome netrome left a comment

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.

Quick sweep, looks alright but I'm questioning if we want to use a LookupMap vs an iterable map for the attestation grants. Don't need to block merging this PR, but could consider changing this as a follow-up.

Comment thread crates/contract/src/lib.rs Outdated
Comment on lines +190 to +192
tee_verifier_votes: TeeVerifierVotes,
/// A row is removed at zero, so the map holds no entry for an account with none.
available_attestation_grants: LookupMap<AccountId, u32>,

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.

Yeah, it feels like we should spend more time thinking how to organize the contract code. I guess you're about to do this soon @gilcu3 so probably could move this in the same go.

Comment thread crates/contract/src/lib.rs Outdated
@gilcu3
gilcu3 dismissed stale reviews from netrome and themself via 7392953 August 7, 2026 09:22
gilcu3
gilcu3 previously approved these changes Aug 7, 2026
@gilcu3
gilcu3 added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 9ae5881 Aug 7, 2026
15 checks passed
@gilcu3
gilcu3 deleted the 4015-operator-prepaid-attestation-storage branch August 7, 2026 10:27
barakeinav1 added a commit that referenced this pull request Aug 11, 2026
Scanning an entry costs gas whether or not it is removed, so
RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN and
clean_invalid_attestations_tera_gas have to be sized against each other.
They were not: max_scan 100 needs ~28 TGas to walk the list with zero
removals, against a 10 TGas budget. The promise is detached, so exceeding
it rolls back every removal silently, which a live sweep on
v1.signer-prod.testnet confirmed by burning 13.1 TGas.

Lower max_scan 100 -> 30 and raise the budget 10 -> 15 TGas. A 30-entry
scan costs ~9.9 TGas before any removal; 15 TGas covers ~7 removals on top,
measured with #4016's storage-grant return in place (which took the
per-removal cost from 0.362 to 0.588 TGas).

Reset that field during migration rather than carrying the deployed value
forward, so it applies on upgrade without a governance config vote.

Every scanned entry being removable stays unfunded (~22 TGas); the
permissionless entry point is the recovery path.

Closes #4035
barakeinav1 added a commit that referenced this pull request Aug 11, 2026
Scanning an entry costs gas whether or not it is removed, so
RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN and
clean_invalid_attestations_tera_gas have to be sized against each other.
They were not: max_scan 100 needs ~28 TGas to walk the list with zero
removals, against a 10 TGas budget. The promise is detached, so exceeding
it rolls back every removal silently, which a live sweep on
v1.signer-prod.testnet confirmed by burning 13.1 TGas.

Lower max_scan 100 -> 30 and raise the budget 10 -> 15 TGas. A 30-entry
scan costs ~9.9 TGas before any removal; 15 TGas covers ~7 removals on top,
measured with #4016's storage-grant return in place (which took the
per-removal cost from 0.362 to 0.588 TGas).

Reset that field during migration rather than carrying the deployed value
forward, so it applies on upgrade without a governance config vote.

Every scanned entry being removable stays unfunded (~22 TGas); the
permissionless entry point is the recovery path.

Closes #4035
barakeinav1 added a commit that referenced this pull request Aug 11, 2026
Scanning an entry costs gas whether or not it is removed, so
RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN and
clean_invalid_attestations_tera_gas have to be sized against each other.
They were not: max_scan 100 needs ~28 TGas to walk the list with zero
removals, against a 10 TGas budget. The promise is detached, so exceeding
it rolls back every removal silently, which a live sweep on
v1.signer-prod.testnet confirmed by burning 13.1 TGas.

Lower max_scan 100 -> 30 and raise the budget 10 -> 15 TGas. A 30-entry
scan costs ~9.9 TGas before any removal; 15 TGas covers ~7 removals on top,
measured with #4016's storage-grant return in place (which took the
per-removal cost from 0.362 to 0.588 TGas).

Reset that field during migration rather than carrying the deployed value
forward, so it applies on upgrade without a governance config vote.

Every scanned entry being removable stays unfunded (~22 TGas); the
permissionless entry point is the recovery path.

Closes #4035
barakeinav1 added a commit that referenced this pull request Aug 11, 2026
Scanning an entry costs gas whether or not it is removed, so
RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN and
clean_invalid_attestations_tera_gas have to be sized against each other.
They were not: max_scan 100 needs ~28 TGas to walk the list with zero
removals, against a 10 TGas budget. The promise is detached, so exceeding
it rolls back every removal silently, which a live sweep on
v1.signer-prod.testnet confirmed by burning 13.1 TGas.

Lower max_scan 100 -> 30 and raise the budget 10 -> 15 TGas. A 30-entry
scan costs ~9.9 TGas before any removal; 15 TGas covers ~7 removals on top,
measured with #4016's storage-grant return in place (which took the
per-removal cost from 0.362 to 0.588 TGas).

Reset that field during migration rather than carrying the deployed value
forward, so it applies on upgrade without a governance config vote.

Every scanned entry being removable stays unfunded (~22 TGas); the
permissionless entry point is the recovery path.

Closes #4035
barakeinav1 added a commit that referenced this pull request Aug 11, 2026
Scanning an entry costs gas whether or not it is removed, so
RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN and
clean_invalid_attestations_tera_gas have to be sized against each other.
They were not: max_scan 100 needs ~28 TGas to walk the list with zero
removals, against a 10 TGas budget. The promise is detached, so exceeding
it rolls back every removal silently, which a live sweep on
v1.signer-prod.testnet confirmed by burning 13.1 TGas.

Lower max_scan 100 -> 30 and raise the budget 10 -> 15 TGas. A 30-entry
scan costs ~9.9 TGas before any removal; 15 TGas covers ~7 removals on top,
measured with #4016's storage-grant return in place (which took the
per-removal cost from 0.362 to 0.588 TGas).

Reset that field during migration rather than carrying the deployed value
forward, so it applies on upgrade without a governance config vote.

Every scanned entry being removable stays unfunded (~22 TGas); the
permissionless entry point is the recovery path.

Closes #4035
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(contract): operator-prepaid attestation storage (grant counter)

4 participants