Skip to content

Add RateLimiter library#6490

Open
Amxx wants to merge 5 commits intoOpenZeppelin:masterfrom
Amxx:feature/RateLimiter
Open

Add RateLimiter library#6490
Amxx wants to merge 5 commits intoOpenZeppelin:masterfrom
Amxx:feature/RateLimiter

Conversation

@Amxx
Copy link
Copy Markdown
Collaborator

@Amxx Amxx commented May 1, 2026

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.

PR Checklist

  • Tests
  • Documentation
  • Changeset entry (run npx changeset add)

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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Amxx Amxx requested a review from a team as a code owner May 1, 2026 21:52
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 1, 2026

🦋 Changeset detected

Latest commit: c78be90

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
openzeppelin-solidity Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@Amxx Amxx added this to the 5.7 milestone May 1, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 1, 2026

Walkthrough

The PR adds a new RateLimiter library to the openzeppelin-solidity package. The library provides two rate-limiting implementations: a refilling token bucket mechanism and a sliding window counter mechanism. Both implementations use similar internal interfaces with methods for checking and consuming rate limit capacity, resetting state, and updating settings. The library is integrated into the codebase through a new utility file, documentation updates, and an import in a mock contract. A changeset metadata file records the minor version bump for this addition.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add RateLimiter library' directly and concisely summarizes the main change: the introduction of a new RateLimiter library to the codebase.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description clearly describes the changeset as introducing a RateLimiter library with two rate-limiting strategies.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@contracts/utils/RateLimiter.sol`:
- Around line 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.
- Around line 169-177: The SlidingWindow limiter silently disables when
window==0 because state() does two identical lookups; fix by (1) validating in
updateSettings(SlidingWindow storage, ...) that the provided window > 0 and
revert (or set to a sane minimum) if zero, and (2) harden state(SlidingWindow
storage) by adding a guard: if cacheWindow == 0 then set used_ = cacheLimit and
available_ = 0 (or treat cacheWindow = 1 as a minimum) before calling
history.upperLookupRecent, referencing the SlidingWindow struct fields (limit,
window), the state() function, updateSettings(), history.upperLookupRecent and
Time.timestamp() to locate code to change.
- Around line 221-224: The doc comment for SlidingWindow.reset uses the wrong
field name; update the wording to reference the struct's `limit` instead of
`capacity`. Locate the SlidingWindow.reset function and change the sentence "The
`capacity` and `window` settings are preserved..." to "The `limit` and `window`
settings are preserved..." (or equivalent) so the docs match the struct field
name `limit`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 69f57a46-42af-4dd3-9abd-a3a88dcf0a93

📥 Commits

Reviewing files that changed from the base of the PR and between 9767839 and 60657c9.

📒 Files selected for processing (4)
  • .changeset/rate-limiter-library.md
  • contracts/mocks/Stateless.sol
  • contracts/utils/README.adoc
  • contracts/utils/RateLimiter.sol

Comment on lines +39 to +248
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 RefillingBucket {
uint208 capacity;
uint48 window;
uint208 lastUsed;
uint48 lastTimepoint;
}

/**
* @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;
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_) {
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.
*/
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;

used_ = Math.saturatingSub(
self.history.upperLookupRecent(Time.timestamp()),
self.history.upperLookupRecent(uint48(Math.saturatingSub(Time.timestamp(), cacheWindow)))
);
available_ = Math.saturatingSub(cacheLimit, used_);
}

/**
* @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.
*
* 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;
}
}
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.

Comment thread contracts/utils/RateLimiter.sol
Comment thread contracts/utils/RateLimiter.sol
Comment on lines +48 to +60
/**
* @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 RefillingBucket {
uint208 capacity;
uint48 window;
uint208 lastUsed;
uint48 lastTimepoint;
}
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.

@Amxx
Copy link
Copy Markdown
Collaborator Author

Amxx commented May 4, 2026

Missing tests:

  • multiple consume in same block
  • zero length window

Comment on lines +170 to +171
uint208 cacheLimit = self.limit;
uint48 cacheWindow = self.window;
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.

Comment thread contracts/utils/RateLimiter.sol Outdated
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.

2 participants