Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/rate-limiter-library.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---

`RateLimiter`: Add a library that provides primitives for limiting the rate at which an action can be performed, with two complementary strategies: a refilling token bucket and a sliding window counter.
1 change: 1 addition & 0 deletions contracts/mocks/Stateless.sol
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {NoncesKeyed} from "../utils/NoncesKeyed.sol";
import {P256} from "../utils/cryptography/P256.sol";
import {Packing} from "../utils/Packing.sol";
import {Panic} from "../utils/Panic.sol";
import {RateLimiter} from "../utils/RateLimiter.sol";
import {RelayedCall} from "../utils/RelayedCall.sol";
import {RLP} from "../utils/RLP.sol";
import {RSA} from "../utils/cryptography/RSA.sol";
Expand Down
3 changes: 3 additions & 0 deletions contracts/utils/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Miscellaneous contracts and libraries containing utility functions you can use t
* {Multicall}: Abstract contract with a utility to allow batching together multiple calls in a single transaction. Useful for allowing EOAs to perform multiple operations at once.
* {Packing}: A library for packing and unpacking multiple values into bytes32.
* {Panic}: A library to revert with https://docs.soliditylang.org/en/v0.8.20/control-structures.html#panic-via-assert-and-error-via-require[Solidity panic codes].
* {RateLimiter}: A library that provides primitives for limiting the rate at which an action can be performed, using a refilling token bucket or a sliding window counter.
* {RelayedCall}: A library for performing calls that use minimal and predictable relayers to hide the sender.
* {RLP}: Library for encoding and decoding data in Ethereum's Recursive Length Prefix format.
* {ShortStrings}: Library to encode (and decode) short strings into (or from) a single bytes32 slot for optimizing costs. Short strings are limited to 31 characters.
Expand Down Expand Up @@ -147,6 +148,8 @@ Ethereum contracts have no native concept of an interface, so applications must

{{Panic}}

{{RateLimiter}}

{{RelayedCall}}

{{RLP}}
Expand Down
264 changes: 264 additions & 0 deletions contracts/utils/RateLimiter.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;

import {Math} from "./math/Math.sol";
import {SafeCast} from "./math/SafeCast.sol";
import {Checkpoints} from "./structs/Checkpoints.sol";
import {Time} from "./types/Time.sol";

/**
* @dev This library provides primitives for limiting the rate at which an action can be performed.
*
* Two complementary strategies are available, each represented by a storage struct that the consumer keeps in its
* own storage:
*
* - {RefillingBucket}: a token bucket that refills linearly over time. Suitable when the protected resource is
* expected to regenerate continuously and short bursts up to the bucket capacity are acceptable. Storage cost is
* constant regardless of consumption history.
*
* - {SlidingWindow}: a moving-window counter that caps the cumulative consumption over any `window`-second
* interval. Suitable when a strict cap on usage within a rolling window is required. Each successful consumption
* appends a checkpoint, making it a most expensive option with a larger storage footprint.
*
* Both strategies expose the same set of operations ({state}, {used}, {available}, {tryConsume}, {consume} and
* {updateSettings}), distinguished by the storage struct passed as the first argument.
*
* Example usage:
*
* ```solidity
* using RateLimiter for RateLimiter.RefillingBucket;
*
* mapping(address user => RateLimiter.RefillingBucket) private _withdrawLimits;
*
* function withdraw(uint256 amount) external {
* _withdrawLimits[msg.sender].consume(amount);
* // ...
* }
* ```
*/
library RateLimiter {
using Checkpoints for Checkpoints.Trace208;

/**
* @dev The requested quantity exceeds the currently available capacity.
*/
error RateLimitExceeded();

// ================================================ RefillingBucket ================================================
/**
* @dev A token bucket that refills linearly over time.
*
* The bucket has a maximum `capacity` and refills at a rate of `capacity / window` per second, so that an empty
* bucket fully refills in `window` seconds. The current state is reconstructed lazily from `lastUsed` and
* `lastTimepoint` on read, keeping storage cost constant (2 packed slots).
*/
struct RefillingBucketItem {
uint208 lastUsed;
uint48 lastTimepoint;
}
struct RefillingBucket {
uint208 capacity;
uint48 window;
mapping(bytes32 key => RefillingBucketItem) items;
}
Comment on lines +48 to +63
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we have a create bucket function? I feel that its not obvious what lastUsed and lastTimepoint should be. Maybe there should be a way to specify if a bucket is full or empty at creation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The updateSettings is what you should use to setup intial config. Maybe the name is not very clear.


/**
* @dev Returns the current `used` and `available` quantities for a {RefillingBucket}, accounting for the
* time-based refill that has accrued since the last update.
*/
function state(
RefillingBucket storage self,
bytes32 key
) internal view returns (uint256 used_, uint256 available_) {
uint208 cacheCapacity = self.capacity;
uint48 cacheWindow = self.window;
uint208 cacheLastUsed = self.items[key].lastUsed;
uint48 cacheLastTimepoint = self.items[key].lastTimepoint;

used_ = Math.saturatingSub(
cacheLastUsed,
Math.mulDiv(Time.timestamp() - cacheLastTimepoint, cacheCapacity, Math.max(cacheWindow, 1))
);
available_ = Math.saturatingSub(cacheCapacity, used_);
}

/**
* @dev Returns the currently used quantity. See {state}.
*/
function used(RefillingBucket storage self, bytes32 key) internal view returns (uint256 used_) {
(used_, ) = state(self, key);
}

/**
* @dev Returns the currently available quantity. See {state}.
*/
function available(RefillingBucket storage self, bytes32 key) internal view returns (uint256 available_) {
(, available_) = state(self, key);
}

/**
* @dev Attempts to consume `quantity` from the bucket. Returns `true` on success, `false` if the available
* quantity is insufficient.
*
* A `quantity` of 0 is always accepted and does not modify storage.
*/
function tryConsume(RefillingBucket storage self, bytes32 key, uint256 quantity) internal returns (bool) {
(uint256 used_, uint256 available_) = state(self, key);
if (quantity == 0) {
return true;
} else if (quantity <= available_) {
self.items[key].lastTimepoint = Time.timestamp();
self.items[key].lastUsed = SafeCast.toUint208(used_ + quantity);
return true;
} else {
return false;
}
}

/**
* @dev Consumes `quantity` from the bucket. Reverts with {RateLimitExceeded} if the available quantity is
* insufficient. See {tryConsume}.
*/
function consume(RefillingBucket storage self, bytes32 key, uint256 quantity) internal {
bool success = tryConsume(self, key, quantity);
require(success, RateLimitExceeded());
}

/**
* @dev Resets the bucket to a fully-available state.
*
* The `capacity` and `window` settings are preserved; only the consumed quantity is cleared.
*/
function reset(RefillingBucket storage self, bytes32 key) internal {
self.items[key].lastUsed = 0;
}

/**
* @dev Updates the `capacity` and `window` of the bucket.
*
* NOTE: The new settings will retroactively affect all the keys. The new replenishing rate (capacity / window) is
* applied from the last update timepoint of each key. Therefore, if the new settings correspond to a faster
* replenishing rate, some quantity may become available immediately. Conversely, if the new settings correspond
* to a slower replenishing rate, some quantity that would otherwise be available immediately may become
* unavailable. This side effect can be mitigated by calling {refresh} on the relevant keys before updating the
* settings. There is no mechanism to automatically refresh all the keys in a single operation.
*/
function updateSettings(RefillingBucket storage self, uint48 newWindow, uint208 newCapacity) internal {
self.capacity = newCapacity;
self.window = newWindow;
}

/**
* @dev Refreshes the bucket by applying the accrued refill since the last update timepoint to `lastUsed` and
* `lastTimepoint`, effectively moving the timepoint forward to now. This can be used to mitigate the side effect
* of {updateSettings} when the refreshing rate is modified.
*/
function refresh(RefillingBucket storage self, bytes32 key) internal {
self.items[key].lastUsed = uint208(used(self, key));
self.items[key].lastTimepoint = Time.timestamp();
}

// ================================================= SlidingWindow =================================================
/**
* @dev A moving-window counter that caps cumulative consumption within any `window`-second interval.
*
* Each successful consumption appends a checkpoint to `history` recording the running cumulative total. The
* current `used` quantity is the difference between the cumulative total at `block.timestamp` and the cumulative
* total at `block.timestamp - window`.
*
* NOTE: The cumulative total is stored as a `uint208`. Once it reaches `2²⁰⁸ - 1`, further consumption will
* revert in {SafeCast}. This bound is unreachable for any realistic `limit`, but consumers should be aware of it.
*
* NOTE: Old checkpoints are never pruned. The storage footprint grows with the number of {tryConsume} calls
* that succeed with a non-zero `quantity`.
*/
struct SlidingWindow {
uint208 limit;
uint48 window;
mapping(bytes32 key => Checkpoints.Trace208) items;
}

/**
* @dev Returns the current `used` and `available` quantities for a {SlidingWindow}, computed as the cumulative
* consumption over the last `window` seconds.
*/
function state(SlidingWindow storage self, bytes32 key) internal view returns (uint256 used_, uint256 available_) {
uint208 cacheLimit = self.limit;
uint48 cacheWindow = self.window;
Comment on lines +186 to +187
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is necessary to ensure just one sload is used?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

that is the goal.


used_ = Math.saturatingSub(
self.items[key].latest(),
self.items[key].upperLookupRecent(uint48(Math.saturatingSub(Time.timestamp(), Math.max(cacheWindow, 1))))
);
available_ = Math.saturatingSub(cacheLimit, used_);
}

/**
* @dev Returns the currently used quantity within the rolling window. See {state}.
*/
function used(SlidingWindow storage self, bytes32 key) internal view returns (uint256 used_) {
(used_, ) = state(self, key);
}

/**
* @dev Returns the currently available quantity within the rolling window. See {state}.
*/
function available(SlidingWindow storage self, bytes32 key) internal view returns (uint256 available_) {
(, available_) = state(self, key);
}

/**
* @dev Attempts to record a consumption of `quantity`. Returns `true` on success, `false` if the available
* quantity within the current window is insufficient.
*
* A `quantity` of 0 is always accepted and does not modify storage.
*/
function tryConsume(SlidingWindow storage self, bytes32 key, uint256 quantity) internal returns (bool) {
if (quantity == 0) {
return true;
} else if (quantity <= available(self, key)) {
self.items[key].push(Time.timestamp(), SafeCast.toUint208(self.items[key].latest() + quantity));
return true;
} else {
return false;
}
}

/**
* @dev Records a consumption of `quantity`. Reverts with {RateLimitExceeded} if the available quantity within
* the current window is insufficient. See {tryConsume}.
*/
function consume(SlidingWindow storage self, bytes32 key, uint256 quantity) internal {
bool success = tryConsume(self, key, quantity);
require(success, RateLimitExceeded());
}

/**
* @dev Resets the rolling window to a fully-available state.
*
* The `limit` and `window` settings are preserved; only the consumed quantity is cleared.
*
Comment thread
Amxx marked this conversation as resolved.
* NOTE: This will reset the entire history, meaning it can also be used to recover from the cumulative total
* approaching the `uint208` ceiling. The underlying storage slots holding past checkpoints are not zeroed out.
* As a consequence, there is no gas refunded, but future {consume}/{tryConsume} operations are cheaper from
* reusing "dirty" slots.
*/
function reset(SlidingWindow storage self, bytes32 key) internal {
Checkpoints.Checkpoint208[] storage trace = self.items[key]._checkpoints;
assembly ("memory-safe") {
sstore(trace.slot, 0)
}
}

/**
* @dev Updates the `limit` and `window` of the rate limiter.
*
* NOTE: The history of past consumptions is not modified. Increasing `window` retroactively brings older
* consumptions back into the rolling window until they age out under the new duration; decreasing `window`
* conversely causes older consumptions to drop out sooner.
*/
function updateSettings(SlidingWindow storage self, uint48 newWindow, uint208 newLimit) internal {
self.limit = newLimit;
self.window = newWindow;
}
}
Comment on lines +39 to +264
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.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Add targeted tests for boundary semantics before merge.

Given the amount of new stateful logic, please add tests for: zero-window behavior, boundary-at-now-window, repeated same-timestamp consumes, updateSettings transitions, and reset history clearing behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/utils/RateLimiter.sol` around lines 39 - 248, Add targeted unit
tests covering boundary semantics for both RefillingBucket and SlidingWindow:
test RefillingBucket.state/used/available and tryConsume/consume when window ==
0 (zero-window behavior), at timestamp == now - window (boundary-at-now-window),
repeated consumes at the same timestamp to ensure multiple pushes/updates
behave, updateSettings transitions for RefillingBucket.updateSettings and
SlidingWindow.updateSettings (freeze used, change refill/limit rates and
validate subsequent state/availability), and reset() semantics
(RefillingBucket.reset clears lastUsed; SlidingWindow.reset clears
history._checkpoints) including that history is logically cleared although
storage slots remain dirty; use the functions/structs RefillingBucket,
SlidingWindow, tryConsume, consume, state, updateSettings, and reset to locate
logic under test and assert expected used/available values and revert behavior
(RateLimitExceeded) where appropriate.

Loading