Skip to content

Commit a9970fe

Browse files
committed
feat(contract): operator-prepaid attestation storage (grant counter)
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.
1 parent f13945f commit a9970fe

22 files changed

Lines changed: 879 additions & 62 deletions

crates/contract/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,8 @@ stateDiagram-v2
264264
| `verify_foreign_transaction(request: VerifyForeignTransactionRequestArgs)` | Submits a foreign-chain transaction verification request to the contract. Requires a deposit of 1 yoctonear and that the requested foreign chain is in the contract's supported set. Duplicate submissions of the same request (same caller, domain, chain, and payload) while an earlier one is still pending are queued and all receive the same response when the MPC nodes reply; the queue is bounded — concurrent duplicates beyond that bound are rejected with `PendingRequestQueueFull`. | deferred to promise | `10 Tgas` | `~7 Tgas` |
265265
| `public_key(domain: Option<DomainId>)` | Read-only function; returns the public key used for the given domain (defaulting to first). | `Result<PublicKey, Error>` | | |
266266
| `derived_public_key(path: String, predecessor: Option<AccountId>, domain: Option<DomainId>)` | Generates a derived public key for a given path and account, for the given domain (defaulting to first). | `Result<PublicKey, Error>` | | |
267+
| `prepay_attestation_storage(account_id: AccountId, grants: u32)` | Prepays attestation-entry storage for `account_id`. One grant permits one stored attestation. Payable and permissionless — anyone may prepay for any account, which is how an operator funds a node whose function-call access key cannot attach a deposit. Requires an attached deposit of exactly `attestation_storage_fee_millinear × grants` (see `config`) and rejects anything else. Nothing is refunded and there is no withdrawal. | `Result<(), Error>` | 30Tgas | ~4Tgas |
268+
| `available_attestation_grants(account_id: AccountId)` | Read-only function; returns the grants `account_id` has available — bought, minus those currently backing a stored attestation. `0` therefore means either "never prepaid" or "prepaid, and the grant is backing an entry"; cross-reference `get_tee_accounts` to distinguish them. | `u32` | | |
267269

268270
#### SignRequestArgs (Latest version)
269271

crates/contract/src/config.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ const DEFAULT_RETURN_CK_AND_CLEAN_STATE_ON_SUCCESS_CALL_TERA_GAS: u64 = 7;
2323
const DEFAULT_FAIL_ON_TIMEOUT_TERA_GAS: u64 = 2;
2424
/// Prepaid gas for a `fail_attestation_submission` call
2525
const DEFAULT_FAIL_ATTESTATION_SUBMISSION_TERA_GAS: u64 = 2;
26+
/// Fee, in milliNEAR, for one attestation-storage grant. Covers the worst-case
27+
/// stored entry plus the grant-counter row it creates, with headroom for layout
28+
/// growth; see `docs/design/operator-prepaid-attestation-storage.md`.
29+
const DEFAULT_ATTESTATION_STORAGE_FEE_MILLINEAR: u64 = 20;
2630
/// Prepaid gas for a `clean_tee_status` call
2731
const DEFAULT_CLEAN_TEE_STATUS_TERA_GAS: u64 = 10;
2832
/// Prepaid gas for the reshare-time `clean_invalid_attestations` promise.
@@ -81,6 +85,8 @@ pub(crate) struct Config {
8185
pub(crate) verifier_tera_gas: u64,
8286
/// Prepaid gas for the `resolve_verification` callback.
8387
pub(crate) resolve_verification_tera_gas: u64,
88+
/// Fee, in milliNEAR, charged for one attestation-storage grant.
89+
pub(crate) attestation_storage_fee_millinear: u64,
8490
}
8591

8692
impl Default for Config {
@@ -110,6 +116,7 @@ impl Default for Config {
110116
DEFAULT_REMOVE_NON_PARTICIPANT_TEE_VERIFIER_VOTES_TERA_GAS,
111117
verifier_tera_gas: DEFAULT_VERIFIER_TERA_GAS,
112118
resolve_verification_tera_gas: DEFAULT_RESOLVE_VERIFICATION_TERA_GAS,
119+
attestation_storage_fee_millinear: DEFAULT_ATTESTATION_STORAGE_FEE_MILLINEAR,
113120
}
114121
}
115122
}

crates/contract/src/dto_mapping.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,9 @@ impl From<near_mpc_contract_interface::types::InitConfig> for Config {
501501
if let Some(v) = config_ext.resolve_verification_tera_gas {
502502
config.resolve_verification_tera_gas = v;
503503
}
504+
if let Some(v) = config_ext.attestation_storage_fee_millinear {
505+
config.attestation_storage_fee_millinear = v;
506+
}
504507

505508
config
506509
}
@@ -533,6 +536,7 @@ impl From<&Config> for near_mpc_contract_interface::types::Config {
533536
.remove_non_participant_tee_verifier_votes_tera_gas,
534537
verifier_tera_gas: value.verifier_tera_gas,
535538
resolve_verification_tera_gas: value.resolve_verification_tera_gas,
539+
attestation_storage_fee_millinear: value.attestation_storage_fee_millinear,
536540
}
537541
}
538542
}
@@ -564,6 +568,7 @@ impl From<near_mpc_contract_interface::types::Config> for Config {
564568
.remove_non_participant_tee_verifier_votes_tera_gas,
565569
verifier_tera_gas: value.verifier_tera_gas,
566570
resolve_verification_tera_gas: value.resolve_verification_tera_gas,
571+
attestation_storage_fee_millinear: value.attestation_storage_fee_millinear,
567572
}
568573
}
569574
}

crates/contract/src/errors.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,14 @@ pub enum InvalidParameters {
127127
MalformedPayload { reason: String },
128128
#[error("Attached deposit is lower than required. Attached: {attached}, required: {required}")]
129129
InsufficientDeposit { attached: u128, required: u128 },
130+
#[error(
131+
"attached deposit {attached} must be exactly the attestation storage fee times the requested grants, {required}"
132+
)]
133+
UnexpectedDeposit { attached: u128, required: u128 },
134+
#[error(
135+
"no attestation storage grant available for {account_id}; prepay one with prepay_attestation_storage"
136+
)]
137+
NoAttestationStorageGrant { account_id: String },
130138
#[error("Provided gas is lower than required. Provided: {provided}, required: {required}")]
131139
InsufficientGas { provided: u64, required: u64 },
132140
#[error("This sign request has timed out, was completed, or never existed.")]

0 commit comments

Comments
 (0)