OpenZeppelin / OpenZeppelin/stellar-contracts
Proposal: `frequency_limit` policy, a rolling cap on how often a context rule may authorise a call
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 95
- Forks
- 68
- Avg merge
- 4d 42m
- Merged PRs (30d)
- 20
Description
Summary
Propose a frequency_limit policy for stellar-accounts: a cap on how often a context rule may authorise a call, over a rolling window of ledgers.
spending_limit bounds how much. It does not bound rate, so a key capped at 100 per period can still make a thousand calls of 0.1. There is currently no primitive that bounds call frequency, and the two are not substitutes.
Opening this as an issue first, per CONTRIBUTING. Happy to implement it if the design lands.
Why it is not just spending_limit with a counter
spending_limit::enforce only handles one shape:
if fn_name == &symbol_short!("transfer") {
if let Some(amount_val) = args.get(2) {
Anything else falls through to panic_with_error!(NotAllowed). That is correct for an amount cap, which has to locate an amount, but it means the policy cannot be attached to a rule scoping a swap, a lending submit, or any non-SEP-41 call without denying everything.
Counting calls needs no amount, so a frequency policy can be method-agnostic. It covers the call shapes spending_limit structurally cannot, which is most of its value.
Adding an optional max_calls to SpendingLimitAccountParams would avoid a new contract, but we think it is the wrong shape: it changes the #[contracttype] layout and so breaks already-stored SpendingLimitData for installed accounts, and it would inherit the transfer-only restriction above.
Proposed design
Mirror spending_limit's structure, storing ledger sequences instead of amounts.
pub struct FrequencyLimitAccountParams {
/// Maximum number of authorised calls within the period.
pub max_calls: u32,
/// The period in ledgers over which the limit applies.
pub period_ledgers: u32,
}
pub struct FrequencyLimitData {
pub max_calls: u32,
pub period_ledgers: u32,
/// Ledger sequence of each authorised call in the window.
pub call_history: Vec<u32>,
}
enforce: expire entries older than current_ledger - period_ledgers, deny if call_history.len() >= max_calls, otherwise push current_ledger and write.
The storage bound is structural here
Worth stating explicitly because it differs from spending_limit, and it bears on #847.
spending_limit's history grows with call volume, which is unbounded by the policy itself. That is what makes #847 possible, where the entry reaches the 65,536-byte contractDataEntrySizeBytes limit before MAX_HISTORY_ENTRIES is ever consulted (that measurement is theirs, not ours).
A frequency limit cannot do that. The check that bounds the vector is the policy: once len() reaches max_calls the call is denied, so the vector never exceeds max_calls entries. With u32 entries, even max_calls = 1000 stays far inside the entry size limit. There is no unreachable-cap failure mode to design around.
Alternative, if constant storage is preferred
A tumbling window needs only (window_start: u32, count: u32): 8 bytes, no vector, no expiry loop. The cost is that it permits up to 2 * max_calls across a boundary, max_calls at the end of one window and max_calls at the start of the next.
We prefer the rolling window because it matches spending_limit's semantics and users will reasonably expect the two to behave the same way, but the tumbling variant is cheaper and we would take the maintainers' preference.
Open questions
- Per rule, or per signer?
spending_limitkeys on(smart_account, context_rule.id). Since a rule carrying a policy admits any one of its signers acting alone, a 3-signer rule capped at 10 per day is 10 in total rather than 10 each. We propose per rule, for consistency, but per signer is a defensible alternative and it should be documented either way. - What counts as one call? We propose one
enforceinvocation, which means a multi-operation transaction increments more than once. This needs stating plainly, whichever way it goes. - Method filter? Method-agnostic by default. Should the params carry an optional
fn_nameto narrow it? - Error codes. The next free block after
spending_limitat 3220 would be 3230. Confirm that is where you would want it.
Note on periods
period_ledgers is a ledger count, so "per day" is 17,280 ledgers at roughly five seconds each. That is an approximation of a day rather than a wall-clock day. We surface it to users that way and would document it the same here.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading the mentioned spending_limit::enforce entry point and the related SpendingLimitAccountParams and SpendingLimitData definitions. Before implementation, resolve the four open design questions; done means an agreed frequency-policy shape, storage and error-code choices, and tests covering the selected window semantics.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- backend-api-design, blockchain
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 42/100