Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
60657c9
Add RateLimiter library
Amxx May 1, 2026
6f86b94
tests
Amxx May 4, 2026
1ea03ca
Merge branch 'master' into feature/RateLimiter
Amxx May 4, 2026
79a8cfe
update
Amxx May 4, 2026
c78be90
fix
Amxx May 5, 2026
83d639e
add keys
Amxx May 20, 2026
88c8736
up
Amxx May 20, 2026
17a103e
document side effects of RefillingBucket.updateSettings
Amxx May 20, 2026
8bc5d8f
review feedback
arr00 Jun 3, 2026
3a84264
rename refresh -> sync
Amxx Jun 9, 2026
efbe5b9
apply suggestion
Amxx Jun 9, 2026
17e0e89
Fix references in `RateLimiter` docs
arr00 Jun 10, 2026
506f8ea
Merge pull request #25 from arr00/chore/fix-ratelimiter-links
Amxx Jun 10, 2026
92be27b
erase history when consuming and past window is empty
Amxx Jun 10, 2026
7302e61
update comment
arr00 Jun 11, 2026
9c41412
Apply suggestions from code review
Amxx Jun 24, 2026
484b6ec
fix lint
arr00 Jun 26, 2026
958d2bb
Update contracts/utils/RateLimiter.sol
Amxx Jul 6, 2026
f0e1b6c
Update contracts/utils/RateLimiter.sol
Amxx Jul 6, 2026
c4cd9ab
Update contracts/utils/RateLimiter.sol
Amxx Jul 6, 2026
eda8570
Apply suggestions from code review
Amxx Jul 6, 2026
fcf11f1
Clarify limiter vs. entries in RateLimiter documentation
Amxx Jul 6, 2026
c398808
Add tests for consuming full capacity in RateLimiter
Amxx Jul 6, 2026
e87f3eb
Apply prettier formatting to RateLimiter tests
Amxx Jul 6, 2026
36226f4
Moar tests
ernestognw Jul 8, 2026
61fc069
Merge branch 'master' into feature/RateLimiter
ernestognw Jul 8, 2026
fa53164
Review
ernestognw Jul 8, 2026
fe145c5
Update contracts/utils/RateLimiter.sol
Amxx Jul 9, 2026
cb27f76
Warn against manual modification of RateLimiter structs
Amxx Jul 9, 2026
2eb6dc0
Prefix RateLimiter struct fields with underscore to discourage direct…
Amxx Jul 9, 2026
bd4995d
Cache SlidingWindow item storage pointer
Amxx Jul 9, 2026
7c1320b
Update contracts/utils/RateLimiter.sol
Amxx Jul 9, 2026
1960d10
Update contracts/utils/RateLimiter.sol
Amxx Jul 9, 2026
41c889b
Update contracts/utils/RateLimiter.sol
gonzaotc Jul 9, 2026
1f2dbd3
Update test/utils/RateLimiter.test.js
Amxx Jul 13, 2026
1bbdc43
Update contracts/utils/RateLimiter.sol
Amxx Jul 13, 2026
d8acf7d
Note state behavior with 0 window
ernestognw Jul 13, 2026
15107d8
Update contracts/utils/RateLimiter.sol
ernestognw Jul 13, 2026
4638015
Update contracts/utils/RateLimiter.sol
ernestognw Jul 13, 2026
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
248 changes: 248 additions & 0 deletions contracts/utils/RateLimiter.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
// 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.
Comment thread
arr00 marked this conversation as resolved.
Outdated
Comment thread
Amxx marked this conversation as resolved.
Outdated
*
* - {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.
Comment thread
Amxx marked this conversation as resolved.
Outdated
*
* Both strategies expose the same set of operations ({state}, {used}, {available}, {tryConsume}, {consume} and
Comment thread
Amxx marked this conversation as resolved.
Outdated
* {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);
* // ...
* }
Comment thread
Amxx marked this conversation as resolved.
* ```
*/
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).
Comment thread
Amxx marked this conversation as resolved.
Outdated
*/
struct RefillingBucket {
uint208 capacity;
uint48 window;
uint208 lastUsed;
uint48 lastTimepoint;
}
Comment thread
arr00 marked this conversation as resolved.

/**
* @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) internal view returns (uint256 used_, uint256 available_) {
uint208 cacheCapacity = self.capacity;
Comment thread
arr00 marked this conversation as resolved.
Outdated
uint48 cacheWindow = self.window;
uint208 cacheLastUsed = self.lastUsed;
uint48 cacheLastTimepoint = self.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) internal view returns (uint256 used_) {
(used_, ) = state(self);
}

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

/**
* @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, uint256 quantity) internal returns (bool) {
(uint256 used_, uint256 available_) = state(self);
if (quantity == 0) {
return true;
} else if (quantity <= available_) {
Comment thread
arr00 marked this conversation as resolved.
Outdated
self.lastTimepoint = Time.timestamp();
self.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, uint256 quantity) internal {
bool success = tryConsume(self, 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.
Comment thread
Amxx marked this conversation as resolved.
Outdated
*/
function reset(RefillingBucket storage self) internal {
self.lastUsed = 0;
}

/**
* @dev Updates the `capacity` and `window` of the bucket.
*
* The current usage is frozen before the new parameters take effect, so the refill that has accrued up to this
* point is preserved and future refill happens at the new rate. If `newCapacity` is smaller than the currently
* used quantity, the bucket starts with zero available quantity until the new rate refills it.
*/
function updateSettings(RefillingBucket storage self, uint48 newWindow, uint208 newCapacity) internal {
// Important: compute used before updating anything else in the structure
self.lastUsed = uint208(used(self));
self.lastTimepoint = Time.timestamp();
self.capacity = newCapacity;
self.window = newWindow;
}

// ================================================= 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;
Checkpoints.Trace208 history;
}

/**
* @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) internal view returns (uint256 used_, uint256 available_) {
uint208 cacheLimit = self.limit;
uint48 cacheWindow = self.window;
Comment thread
arr00 marked this conversation as resolved.
Outdated

used_ = Math.saturatingSub(
self.history.upperLookupRecent(Time.timestamp()),
Comment thread
Amxx marked this conversation as resolved.
Outdated
self.history.upperLookupRecent(uint48(Math.saturatingSub(Time.timestamp(), cacheWindow)))
);
available_ = Math.saturatingSub(cacheLimit, used_);
Comment thread
Amxx marked this conversation as resolved.
Outdated
}
Comment thread
Amxx marked this conversation as resolved.

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

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

/**
* @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, uint256 quantity) internal returns (bool) {
if (quantity == 0) {
return true;
} else if (quantity <= available(self)) {
self.history.push(Time.timestamp(), SafeCast.toUint208(self.history.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, uint256 quantity) internal {
bool success = tryConsume(self, quantity);
require(success, RateLimitExceeded());
}

/**
* @dev Resets the rolling window to a fully-available state.
*
* The `capacity` and `window` settings are preserved; only the consumed quantity is cleared.
*
Comment thread
Amxx marked this conversation as resolved.
Outdated
* 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) internal {
Checkpoints.Checkpoint208[] storage trace = self.history._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 thread
Amxx marked this conversation as resolved.

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.

Why not having 2 separate files since very few in common?

utils/rate-limiter/ (or closer to Time.sol? Or elsewhere):

  • RefillingBucketLimiter.sol
  • SlidingWindowLimiter.sol

Would makes client usage more straightforward.

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.

I'm not sure its worth it. The features and interfaces of both structure are really similar. consider we have both Pointer and Slices in the same Memory library, I think it make sens to have a single file here.

@ernestognw @arr00 ?