From 60657c99a6852a551a51aeba0a0ea90e57bf2848 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 1 May 2026 23:51:51 +0200 Subject: [PATCH 1/7] Add RateLimiter library 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) --- .changeset/rate-limiter-library.md | 5 + contracts/mocks/Stateless.sol | 1 + contracts/utils/README.adoc | 3 + contracts/utils/RateLimiter.sol | 248 +++++++++++++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 .changeset/rate-limiter-library.md create mode 100644 contracts/utils/RateLimiter.sol diff --git a/.changeset/rate-limiter-library.md b/.changeset/rate-limiter-library.md new file mode 100644 index 00000000000..164e63cbbe3 --- /dev/null +++ b/.changeset/rate-limiter-library.md @@ -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. diff --git a/contracts/mocks/Stateless.sol b/contracts/mocks/Stateless.sol index 0669c675248..da281280b3d 100644 --- a/contracts/mocks/Stateless.sol +++ b/contracts/mocks/Stateless.sol @@ -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"; diff --git a/contracts/utils/README.adoc b/contracts/utils/README.adoc index 10cc2c85fd4..ff2e1a04200 100644 --- a/contracts/utils/README.adoc +++ b/contracts/utils/README.adoc @@ -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. @@ -147,6 +148,8 @@ Ethereum contracts have no native concept of an interface, so applications must {{Panic}} +{{RateLimiter}} + {{RelayedCall}} {{RLP}} diff --git a/contracts/utils/RateLimiter.sol b/contracts/utils/RateLimiter.sol new file mode 100644 index 00000000000..3effca94aa5 --- /dev/null +++ b/contracts/utils/RateLimiter.sol @@ -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. + * + * - {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 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; + } +} From 6f86b94b6b2b3af41373480d924f43aa13ee4f4b Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 4 May 2026 10:39:48 +0200 Subject: [PATCH 2/7] tests --- test/utils/RateLimiter.test.js | 387 +++++++++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 test/utils/RateLimiter.test.js diff --git a/test/utils/RateLimiter.test.js b/test/utils/RateLimiter.test.js new file mode 100644 index 00000000000..ef5e3d14895 --- /dev/null +++ b/test/utils/RateLimiter.test.js @@ -0,0 +1,387 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { MAX_UINT48 } = require('../helpers/constants'); +const time = require('../helpers/time'); + +const WINDOW = 100n; +const CAPACITY = 1000n; +const refillPerSecond = CAPACITY / WINDOW; + +async function fixture() { + const mock = await ethers.deployContract('$RateLimiter'); + return { mock }; +} + +const wrap = (mock, type) => ({ + state: () => mock.getFunction(`$state_RateLimiter_${type}`)(0n), + used: () => mock.getFunction(`$used_RateLimiter_${type}`)(0n), + available: () => mock.getFunction(`$available_RateLimiter_${type}`)(0n), + tryConsume: q => mock.getFunction(`$tryConsume_RateLimiter_${type}`)(0n, q), + tryConsumeStatic: q => mock.getFunction(`$tryConsume_RateLimiter_${type}`).staticCall(0n, q), + consume: q => mock.getFunction(`$consume_RateLimiter_${type}`)(0n, q), + reset: () => mock.getFunction(`$reset_RateLimiter_${type}`)(0n), + updateSettings: (window, capacity) => mock.getFunction(`$updateSettings_RateLimiter_${type}`)(0n, window, capacity), +}); + +describe('RateLimiter', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('RefillingBucket', function () { + beforeEach(async function () { + Object.assign(this.mock, wrap(this.mock, 'RefillingBucket')); + }); + + it('starts empty', async function () { + await expect(this.mock.state()).to.eventually.deep.equal([0n, 0n]); + await expect(this.mock.used()).to.eventually.equal(0n); + await expect(this.mock.available()).to.eventually.equal(0n); + }); + + describe('with some capacity', function () { + beforeEach(async function () { + await this.mock.updateSettings(WINDOW, CAPACITY); + }); + + it('reports full capacity available', async function () { + await expect(this.mock.state()).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used()).to.eventually.equal(0n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY); + }); + + describe('consume', function () { + it('consume reduces available and increases used', async function () { + await this.mock.consume(17n); + + await expect(this.mock.state()).to.eventually.deep.equal([17n, CAPACITY - 17n]); + await expect(this.mock.used()).to.eventually.equal(17n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 17n); + }); + + it('consume reverts when over capacity', async function () { + await expect(this.mock.consume(CAPACITY + 1n)).to.be.revertedWithCustomError(this.mock, 'RateLimitExceeded'); + }); + + it('consume(0) is a no-op that returns true', async function () { + await this.mock.consume(42n); + + await expect(this.mock.state()).to.eventually.deep.equal([42n, CAPACITY - 42n]); + await expect(this.mock.used()).to.eventually.equal(42n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n); + + await this.mock.consume(0n); + + // one second passed so we have to account for the auto refill + await expect(this.mock.state()).to.eventually.deep.equal([ + 42n - refillPerSecond, + CAPACITY - 42n + refillPerSecond, + ]); + await expect(this.mock.used()).to.eventually.equal(42n - refillPerSecond); + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n + refillPerSecond); + }); + }); + + describe('tryConsume', function () { + it('tryConsume reduces available and increases used', async function () { + // Static + await expect(this.mock.tryConsumeStatic(42n)).to.eventually.be.true; + + // execute (and get event) + await expect(this.mock.tryConsume(42n)) + .to.emit(this.mock, 'return$tryConsume_RateLimiter_RefillingBucket_uint256') + .withArgs(true); + + await expect(this.mock.state()).to.eventually.deep.equal([42n, CAPACITY - 42n]); + await expect(this.mock.used()).to.eventually.equal(42n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n); + }); + + it('tryConsume returns false and does not update state when over capacity', async function () { + // Static + await expect(this.mock.tryConsumeStatic(CAPACITY + 1n)).to.eventually.be.false; + + // execute (and get event) + await expect(this.mock.tryConsume(CAPACITY + 1n)) + .to.emit(this.mock, 'return$tryConsume_RateLimiter_RefillingBucket_uint256') + .withArgs(false); + + await expect(this.mock.state()).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used()).to.eventually.equal(0n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY); + }); + + it('consume(0) is a no-op that returns true', async function () { + await this.mock.consume(42n); + + await expect(this.mock.state()).to.eventually.deep.equal([42n, CAPACITY - 42n]); + await expect(this.mock.used()).to.eventually.equal(42n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n); + + await expect(this.mock.tryConsume(0n)) + .to.emit(this.mock, 'return$tryConsume_RateLimiter_RefillingBucket_uint256') + .withArgs(true); + + await expect(this.mock.state()).to.eventually.deep.equal([ + 42n - refillPerSecond, + CAPACITY - 42n + refillPerSecond, + ]); + await expect(this.mock.used()).to.eventually.equal(42n - refillPerSecond); + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n + refillPerSecond); + }); + }); + + it('refills linearly over time', async function () { + const wait = 17n; + const refilled = (wait * CAPACITY) / WINDOW; + + await this.mock.consume(CAPACITY); + + // bucket is empty; advance time by half a window + await time.increaseBy.timestamp(wait); + + await expect(this.mock.state()).to.eventually.deep.equal([CAPACITY - refilled, refilled]); + await expect(this.mock.used()).to.eventually.equal(CAPACITY - refilled); + await expect(this.mock.available()).to.eventually.equal(refilled); + }); + + it('fully refills after a window has elapsed', async function () { + await this.mock.consume(CAPACITY); + await time.increaseBy.timestamp(WINDOW); + + await expect(this.mock.state()).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used()).to.eventually.equal(0n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY); + }); + + it('used does not go below zero', async function () { + await this.mock.consume(10n); + await time.increaseBy.timestamp(WINDOW + 1n); + + await expect(this.mock.state()).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used()).to.eventually.equal(0n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY); + }); + + it('updateSettings freezes accrued usage', async function () { + // consume the whole bucket + await this.mock.consume(CAPACITY); + + // wait half a window so half should have refilled + await time.increaseBy.timestamp(10n); + + await expect(this.mock.state()).to.eventually.deep.equal([ + CAPACITY - 10n * refillPerSecond, + 10n * refillPerSecond, + ]); + await expect(this.mock.used()).to.eventually.equal(CAPACITY - 10n * refillPerSecond); + await expect(this.mock.available()).to.eventually.equal(10n * refillPerSecond); + + // update settings (changes capacity but should keep the current accrued used quantity) + await this.mock.updateSettings(WINDOW * 2n, CAPACITY * 2n); + + // one more second passed (because of the updateSettings call) + await expect(this.mock.state()).to.eventually.deep.equal([ + CAPACITY - 11n * refillPerSecond, + CAPACITY + 11n * refillPerSecond, + ]); + await expect(this.mock.used()).to.eventually.equal(CAPACITY - 11n * refillPerSecond); + await expect(this.mock.available()).to.eventually.equal(CAPACITY + 11n * refillPerSecond); + }); + + it('reset clears used', async function () { + await this.mock.consume(CAPACITY / 2n); + + await this.mock.reset(); + + await expect(this.mock.state()).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used()).to.eventually.equal(0n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY); + }); + + it('window saturation prevents underflow when block.timestamp < window', async function () { + // computing state with a window larger than block.timestamp must not revert + await this.mock.updateSettings(MAX_UINT48, CAPACITY); + await expect(this.mock.state()).to.not.be.reverted; + }); + }); + }); + + describe('SlidingWindow', function () { + beforeEach(async function () { + Object.assign(this.mock, wrap(this.mock, 'SlidingWindow')); + }); + + it('starts empty', async function () { + await expect(this.mock.state()).to.eventually.deep.equal([0n, 0n]); + await expect(this.mock.used()).to.eventually.equal(0n); + await expect(this.mock.available()).to.eventually.equal(0n); + }); + + describe('with some capacity', function () { + beforeEach(async function () { + await this.mock.updateSettings(WINDOW, CAPACITY); + }); + + it('reports full limit available', async function () { + await expect(this.mock.state()).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used()).to.eventually.equal(0n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY); + }); + + describe('consume', function () { + it('consume reduces available and increases used', async function () { + await this.mock.consume(17n); + + await expect(this.mock.state()).to.eventually.deep.equal([17n, CAPACITY - 17n]); + await expect(this.mock.used()).to.eventually.equal(17n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 17n); + }); + + it('multiple consume accumulate within the window', async function () { + await this.mock.consume(100n); + await this.mock.consume(200n); + await this.mock.consume(50n); + + await expect(this.mock.state()).to.eventually.deep.equal([350n, CAPACITY - 350n]); + await expect(this.mock.used()).to.eventually.equal(350n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 350n); + }); + + it('consume reverts when over limit', async function () { + await expect(this.mock.consume(CAPACITY + 1n)).to.be.revertedWithCustomError(this.mock, 'RateLimitExceeded'); + }); + + it('consume(0) is a no-op that returns true', async function () { + await this.mock.consume(42n); + + await expect(this.mock.state()).to.eventually.deep.equal([42n, CAPACITY - 42n]); + await expect(this.mock.used()).to.eventually.equal(42n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n); + + await this.mock.consume(0n); + + // one second passed so we have to account for the auto refill + await expect(this.mock.state()).to.eventually.deep.equal([42n, CAPACITY - 42n]); + await expect(this.mock.used()).to.eventually.equal(42n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n); + }); + }); + + describe('tryConsume', function () { + it('tryConsume reduces available and increases used', async function () { + // Static + await expect(this.mock.tryConsumeStatic(42n)).to.eventually.be.true; + + // execute (and get event) + await expect(this.mock.tryConsume(42n)) + .to.emit(this.mock, 'return$tryConsume_RateLimiter_SlidingWindow_uint256') + .withArgs(true); + + await expect(this.mock.state()).to.eventually.deep.equal([42n, CAPACITY - 42n]); + await expect(this.mock.used()).to.eventually.equal(42n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n); + }); + + it('multiple tryConsume accumulate within the window', async function () { + await this.mock.tryConsume(100n); + await this.mock.tryConsume(200n); + await this.mock.tryConsume(50n); + + await expect(this.mock.state()).to.eventually.deep.equal([350n, CAPACITY - 350n]); + await expect(this.mock.used()).to.eventually.equal(350n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 350n); + }); + + it('tryConsume returns false and does not update state when over capacity', async function () { + // Static + await expect(this.mock.tryConsumeStatic(CAPACITY + 1n)).to.eventually.be.false; + + // execute (and get event) + await expect(this.mock.tryConsume(CAPACITY + 1n)) + .to.emit(this.mock, 'return$tryConsume_RateLimiter_SlidingWindow_uint256') + .withArgs(false); + + await expect(this.mock.state()).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used()).to.eventually.equal(0n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY); + }); + + it('consume(0) is a no-op that returns true', async function () { + await this.mock.consume(42n); + + await expect(this.mock.state()).to.eventually.deep.equal([42n, CAPACITY - 42n]); + await expect(this.mock.used()).to.eventually.equal(42n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n); + + await expect(this.mock.tryConsume(0n)) + .to.emit(this.mock, 'return$tryConsume_RateLimiter_SlidingWindow_uint256') + .withArgs(true); + + await expect(this.mock.state()).to.eventually.deep.equal([42n, CAPACITY - 42n]); + await expect(this.mock.used()).to.eventually.equal(42n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n); + }); + }); + + it('past consumptions drop out after the window elapses', async function () { + await this.mock.consume(CAPACITY / 2n); + + // step past the window + await time.increaseBy.timestamp(WINDOW + 1n); + + await expect(this.mock.state()).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used()).to.eventually.equal(0n); + await expect(this.mock.available()).to.eventually.equal(CAPACITY); + }); + + it('only consumptions outside the window drop out', async function () { + // first batch + await this.mock.consume(17n); + + // second batch, half-a-window later + await time.increaseBy.timestamp(WINDOW / 2n); + await this.mock.consume(42n); + + // both still within the window + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 59n); + await expect(this.mock.used()).to.eventually.equal(59n); // 17n + 42n + + // step forward so the first consume falls out but the second is still in-window + await time.increaseBy.timestamp(WINDOW / 2n + 5n); + + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n); + await expect(this.mock.used()).to.eventually.equal(42n); + }); + + it('updateSettings does not modify recorded history', async function () { + await this.mock.consume(42n); + + await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n); + await expect(this.mock.used()).to.eventually.equal(42n); + + await this.mock.updateSettings(WINDOW * 2n, CAPACITY * 2n); + + // used reflects existing checkpoints, only the new limit/window applies going forward + await expect(this.mock.available()).to.eventually.equal(2n * CAPACITY - 42n); + await expect(this.mock.used()).to.eventually.equal(42n); + }); + + it('reset clears the history', async function () { + await this.mock.consume(CAPACITY / 2n); + await this.mock.reset(); + + expect(await this.mock.used()).to.equal(0n); + expect(await this.mock.available()).to.equal(CAPACITY); + }); + + it('window saturation prevents underflow when block.timestamp < window', async function () { + // computing state with a window larger than block.timestamp must not revert + await this.mock.updateSettings(MAX_UINT48, CAPACITY); + await expect(this.mock.state()).to.not.be.reverted; + }); + }); + }); +}); From 79a8cfe41087638d21ef6012d26e53b8fca5d9c5 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 4 May 2026 18:21:15 +0200 Subject: [PATCH 3/7] update --- contracts/utils/RateLimiter.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/utils/RateLimiter.sol b/contracts/utils/RateLimiter.sol index 3effca94aa5..54b1dfb0ba9 100644 --- a/contracts/utils/RateLimiter.sol +++ b/contracts/utils/RateLimiter.sol @@ -172,7 +172,7 @@ library RateLimiter { used_ = Math.saturatingSub( self.history.upperLookupRecent(Time.timestamp()), - self.history.upperLookupRecent(uint48(Math.saturatingSub(Time.timestamp(), cacheWindow))) + self.history.upperLookupRecent(uint48(Math.saturatingSub(Time.timestamp(), Math.max(cacheWindow, 1)))) ); available_ = Math.saturatingSub(cacheLimit, used_); } @@ -220,7 +220,7 @@ library RateLimiter { /** * @dev Resets the rolling window to a fully-available state. * - * The `capacity` and `window` settings are preserved; only the consumed quantity is cleared. + * The `limit` 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. From c78be902d92c15dae0406c817c8af409f51b0341 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 5 May 2026 14:44:44 +0200 Subject: [PATCH 4/7] fix --- contracts/utils/RateLimiter.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/utils/RateLimiter.sol b/contracts/utils/RateLimiter.sol index 54b1dfb0ba9..0a27a254689 100644 --- a/contracts/utils/RateLimiter.sol +++ b/contracts/utils/RateLimiter.sol @@ -171,7 +171,7 @@ library RateLimiter { uint48 cacheWindow = self.window; used_ = Math.saturatingSub( - self.history.upperLookupRecent(Time.timestamp()), + self.history.latest(), self.history.upperLookupRecent(uint48(Math.saturatingSub(Time.timestamp(), Math.max(cacheWindow, 1)))) ); available_ = Math.saturatingSub(cacheLimit, used_); From 83d639e942386aae80d7233964b2ba1c92ef553a Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 20 May 2026 18:15:58 +0200 Subject: [PATCH 5/7] add keys --- contracts/utils/RateLimiter.sol | 77 ++++++++------- test/utils/RateLimiter.test.js | 161 +++++++++++++++++++++++++++++--- 2 files changed, 186 insertions(+), 52 deletions(-) diff --git a/contracts/utils/RateLimiter.sol b/contracts/utils/RateLimiter.sol index 0a27a254689..b73e5c4236a 100644 --- a/contracts/utils/RateLimiter.sol +++ b/contracts/utils/RateLimiter.sol @@ -52,22 +52,28 @@ library RateLimiter { * 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; - uint208 lastUsed; - uint48 lastTimepoint; + mapping(bytes32 key => RefillingBucketItem) items; } /** * @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_) { + 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.lastUsed; - uint48 cacheLastTimepoint = self.lastTimepoint; + uint208 cacheLastUsed = self.items[key].lastUsed; + uint48 cacheLastTimepoint = self.items[key].lastTimepoint; used_ = Math.saturatingSub( cacheLastUsed, @@ -79,15 +85,15 @@ library RateLimiter { /** * @dev Returns the currently used quantity. See {state}. */ - function used(RefillingBucket storage self) internal view returns (uint256 used_) { - (used_, ) = state(self); + 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) internal view returns (uint256 available_) { - (, available_) = state(self); + function available(RefillingBucket storage self, bytes32 key) internal view returns (uint256 available_) { + (, available_) = state(self, key); } /** @@ -96,13 +102,13 @@ library RateLimiter { * * 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); + 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.lastTimepoint = Time.timestamp(); - self.lastUsed = SafeCast.toUint208(used_ + quantity); + self.items[key].lastTimepoint = Time.timestamp(); + self.items[key].lastUsed = SafeCast.toUint208(used_ + quantity); return true; } else { return false; @@ -113,8 +119,8 @@ library RateLimiter { * @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); + function consume(RefillingBucket storage self, bytes32 key, uint256 quantity) internal { + bool success = tryConsume(self, key, quantity); require(success, RateLimitExceeded()); } @@ -123,21 +129,14 @@ library RateLimiter { * * The `capacity` and `window` settings are preserved; only the consumed quantity is cleared. */ - function reset(RefillingBucket storage self) internal { - self.lastUsed = 0; + function reset(RefillingBucket storage self, bytes32 key) internal { + self.items[key].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; } @@ -159,20 +158,20 @@ library RateLimiter { struct SlidingWindow { uint208 limit; uint48 window; - Checkpoints.Trace208 history; + 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) internal view returns (uint256 used_, uint256 available_) { + function state(SlidingWindow storage self, bytes32 key) internal view returns (uint256 used_, uint256 available_) { uint208 cacheLimit = self.limit; uint48 cacheWindow = self.window; used_ = Math.saturatingSub( - self.history.latest(), - self.history.upperLookupRecent(uint48(Math.saturatingSub(Time.timestamp(), Math.max(cacheWindow, 1)))) + self.items[key].latest(), + self.items[key].upperLookupRecent(uint48(Math.saturatingSub(Time.timestamp(), Math.max(cacheWindow, 1)))) ); available_ = Math.saturatingSub(cacheLimit, used_); } @@ -180,15 +179,15 @@ library RateLimiter { /** * @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); + 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) internal view returns (uint256 available_) { - (, available_) = state(self); + function available(SlidingWindow storage self, bytes32 key) internal view returns (uint256 available_) { + (, available_) = state(self, key); } /** @@ -197,11 +196,11 @@ library RateLimiter { * * A `quantity` of 0 is always accepted and does not modify storage. */ - function tryConsume(SlidingWindow storage self, uint256 quantity) internal returns (bool) { + function tryConsume(SlidingWindow storage self, bytes32 key, 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)); + } else if (quantity <= available(self, key)) { + self.items[key].push(Time.timestamp(), SafeCast.toUint208(self.items[key].latest() + quantity)); return true; } else { return false; @@ -212,8 +211,8 @@ library RateLimiter { * @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); + function consume(SlidingWindow storage self, bytes32 key, uint256 quantity) internal { + bool success = tryConsume(self, key, quantity); require(success, RateLimitExceeded()); } @@ -227,8 +226,8 @@ library RateLimiter { * 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; + function reset(SlidingWindow storage self, bytes32 key) internal { + Checkpoints.Checkpoint208[] storage trace = self.items[key]._checkpoints; assembly ("memory-safe") { sstore(trace.slot, 0) } diff --git a/test/utils/RateLimiter.test.js b/test/utils/RateLimiter.test.js index ef5e3d14895..430724c73ff 100644 --- a/test/utils/RateLimiter.test.js +++ b/test/utils/RateLimiter.test.js @@ -15,13 +15,14 @@ async function fixture() { } const wrap = (mock, type) => ({ - state: () => mock.getFunction(`$state_RateLimiter_${type}`)(0n), - used: () => mock.getFunction(`$used_RateLimiter_${type}`)(0n), - available: () => mock.getFunction(`$available_RateLimiter_${type}`)(0n), - tryConsume: q => mock.getFunction(`$tryConsume_RateLimiter_${type}`)(0n, q), - tryConsumeStatic: q => mock.getFunction(`$tryConsume_RateLimiter_${type}`).staticCall(0n, q), - consume: q => mock.getFunction(`$consume_RateLimiter_${type}`)(0n, q), - reset: () => mock.getFunction(`$reset_RateLimiter_${type}`)(0n), + state: (key = ethers.ZeroHash) => mock.getFunction(`$state_RateLimiter_${type}`)(0n, key), + used: (key = ethers.ZeroHash) => mock.getFunction(`$used_RateLimiter_${type}`)(0n, key), + available: (key = ethers.ZeroHash) => mock.getFunction(`$available_RateLimiter_${type}`)(0n, key), + tryConsume: (amount, key = ethers.ZeroHash) => mock.getFunction(`$tryConsume_RateLimiter_${type}`)(0n, key, amount), + tryConsumeStatic: (amount, key = ethers.ZeroHash) => + mock.getFunction(`$tryConsume_RateLimiter_${type}`).staticCall(0n, key, amount), + consume: (amount, key = ethers.ZeroHash) => mock.getFunction(`$consume_RateLimiter_${type}`)(0n, key, amount), + reset: (key = ethers.ZeroHash) => mock.getFunction(`$reset_RateLimiter_${type}`)(0n, key), updateSettings: (window, capacity) => mock.getFunction(`$updateSettings_RateLimiter_${type}`)(0n, window, capacity), }); @@ -61,6 +62,39 @@ describe('RateLimiter', function () { await expect(this.mock.available()).to.eventually.equal(CAPACITY - 17n); }); + it('consume one key does not affect another key', async function () { + const key1 = ethers.id('key1'); + const key2 = ethers.id('key2'); + + await expect(this.mock.state(key1)).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used(key1)).to.eventually.equal(0n); + await expect(this.mock.available(key1)).to.eventually.equal(CAPACITY); + await expect(this.mock.state(key2)).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used(key2)).to.eventually.equal(0n); + await expect(this.mock.available(key2)).to.eventually.equal(CAPACITY); + + await this.mock.consume(17n, key1); + + await expect(this.mock.state(key1)).to.eventually.deep.equal([17n, CAPACITY - 17n]); + await expect(this.mock.used(key1)).to.eventually.equal(17n); + await expect(this.mock.available(key1)).to.eventually.equal(CAPACITY - 17n); + await expect(this.mock.state(key2)).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used(key2)).to.eventually.equal(0n); + await expect(this.mock.available(key2)).to.eventually.equal(CAPACITY); + + await this.mock.consume(42n, key2); + + await expect(this.mock.state(key1)).to.eventually.deep.equal([ + 17n - refillPerSecond, + CAPACITY - 17n + refillPerSecond, + ]); + await expect(this.mock.used(key1)).to.eventually.equal(17n - refillPerSecond); + await expect(this.mock.available(key1)).to.eventually.equal(CAPACITY - 17n + refillPerSecond); + await expect(this.mock.state(key2)).to.eventually.deep.equal([42n, CAPACITY - 42n]); + await expect(this.mock.used(key2)).to.eventually.equal(42n); + await expect(this.mock.available(key2)).to.eventually.equal(CAPACITY - 42n); + }); + it('consume reverts when over capacity', async function () { await expect(this.mock.consume(CAPACITY + 1n)).to.be.revertedWithCustomError(this.mock, 'RateLimitExceeded'); }); @@ -91,7 +125,7 @@ describe('RateLimiter', function () { // execute (and get event) await expect(this.mock.tryConsume(42n)) - .to.emit(this.mock, 'return$tryConsume_RateLimiter_RefillingBucket_uint256') + .to.emit(this.mock, 'return$tryConsume_RateLimiter_RefillingBucket_bytes32_uint256') .withArgs(true); await expect(this.mock.state()).to.eventually.deep.equal([42n, CAPACITY - 42n]); @@ -99,13 +133,50 @@ describe('RateLimiter', function () { await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n); }); + it('tryConsume one key does not affect another key', async function () { + const key1 = ethers.id('key1'); + const key2 = ethers.id('key2'); + + await expect(this.mock.state(key1)).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used(key1)).to.eventually.equal(0n); + await expect(this.mock.available(key1)).to.eventually.equal(CAPACITY); + await expect(this.mock.state(key2)).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used(key2)).to.eventually.equal(0n); + await expect(this.mock.available(key2)).to.eventually.equal(CAPACITY); + + await expect(this.mock.tryConsume(17n, key1)) + .to.emit(this.mock, 'return$tryConsume_RateLimiter_RefillingBucket_bytes32_uint256') + .withArgs(true); + + await expect(this.mock.state(key1)).to.eventually.deep.equal([17n, CAPACITY - 17n]); + await expect(this.mock.used(key1)).to.eventually.equal(17n); + await expect(this.mock.available(key1)).to.eventually.equal(CAPACITY - 17n); + await expect(this.mock.state(key2)).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used(key2)).to.eventually.equal(0n); + await expect(this.mock.available(key2)).to.eventually.equal(CAPACITY); + + await expect(this.mock.tryConsume(42n, key2)) + .to.emit(this.mock, 'return$tryConsume_RateLimiter_RefillingBucket_bytes32_uint256') + .withArgs(true); + + await expect(this.mock.state(key1)).to.eventually.deep.equal([ + 17n - refillPerSecond, + CAPACITY - 17n + refillPerSecond, + ]); + await expect(this.mock.used(key1)).to.eventually.equal(17n - refillPerSecond); + await expect(this.mock.available(key1)).to.eventually.equal(CAPACITY - 17n + refillPerSecond); + await expect(this.mock.state(key2)).to.eventually.deep.equal([42n, CAPACITY - 42n]); + await expect(this.mock.used(key2)).to.eventually.equal(42n); + await expect(this.mock.available(key2)).to.eventually.equal(CAPACITY - 42n); + }); + it('tryConsume returns false and does not update state when over capacity', async function () { // Static await expect(this.mock.tryConsumeStatic(CAPACITY + 1n)).to.eventually.be.false; // execute (and get event) await expect(this.mock.tryConsume(CAPACITY + 1n)) - .to.emit(this.mock, 'return$tryConsume_RateLimiter_RefillingBucket_uint256') + .to.emit(this.mock, 'return$tryConsume_RateLimiter_RefillingBucket_bytes32_uint256') .withArgs(false); await expect(this.mock.state()).to.eventually.deep.equal([0n, CAPACITY]); @@ -121,7 +192,7 @@ describe('RateLimiter', function () { await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n); await expect(this.mock.tryConsume(0n)) - .to.emit(this.mock, 'return$tryConsume_RateLimiter_RefillingBucket_uint256') + .to.emit(this.mock, 'return$tryConsume_RateLimiter_RefillingBucket_bytes32_uint256') .withArgs(true); await expect(this.mock.state()).to.eventually.deep.equal([ @@ -240,6 +311,36 @@ describe('RateLimiter', function () { await expect(this.mock.available()).to.eventually.equal(CAPACITY - 17n); }); + it('consume one key does not affect another key', async function () { + const key1 = ethers.id('key1'); + const key2 = ethers.id('key2'); + + await expect(this.mock.state(key1)).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used(key1)).to.eventually.equal(0n); + await expect(this.mock.available(key1)).to.eventually.equal(CAPACITY); + await expect(this.mock.state(key2)).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used(key2)).to.eventually.equal(0n); + await expect(this.mock.available(key2)).to.eventually.equal(CAPACITY); + + await this.mock.consume(17n, key1); + + await expect(this.mock.state(key1)).to.eventually.deep.equal([17n, CAPACITY - 17n]); + await expect(this.mock.used(key1)).to.eventually.equal(17n); + await expect(this.mock.available(key1)).to.eventually.equal(CAPACITY - 17n); + await expect(this.mock.state(key2)).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used(key2)).to.eventually.equal(0n); + await expect(this.mock.available(key2)).to.eventually.equal(CAPACITY); + + await this.mock.consume(42n, key2); + + await expect(this.mock.state(key1)).to.eventually.deep.equal([17n, CAPACITY - 17n]); + await expect(this.mock.used(key1)).to.eventually.equal(17n); + await expect(this.mock.available(key1)).to.eventually.equal(CAPACITY - 17n); + await expect(this.mock.state(key2)).to.eventually.deep.equal([42n, CAPACITY - 42n]); + await expect(this.mock.used(key2)).to.eventually.equal(42n); + await expect(this.mock.available(key2)).to.eventually.equal(CAPACITY - 42n); + }); + it('multiple consume accumulate within the window', async function () { await this.mock.consume(100n); await this.mock.consume(200n); @@ -277,7 +378,7 @@ describe('RateLimiter', function () { // execute (and get event) await expect(this.mock.tryConsume(42n)) - .to.emit(this.mock, 'return$tryConsume_RateLimiter_SlidingWindow_uint256') + .to.emit(this.mock, 'return$tryConsume_RateLimiter_SlidingWindow_bytes32_uint256') .withArgs(true); await expect(this.mock.state()).to.eventually.deep.equal([42n, CAPACITY - 42n]); @@ -285,6 +386,40 @@ describe('RateLimiter', function () { await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n); }); + it('tryConsume one key does not affect another key', async function () { + const key1 = ethers.id('key1'); + const key2 = ethers.id('key2'); + + await expect(this.mock.state(key1)).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used(key1)).to.eventually.equal(0n); + await expect(this.mock.available(key1)).to.eventually.equal(CAPACITY); + await expect(this.mock.state(key2)).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used(key2)).to.eventually.equal(0n); + await expect(this.mock.available(key2)).to.eventually.equal(CAPACITY); + + await expect(this.mock.tryConsume(17n, key1)) + .to.emit(this.mock, 'return$tryConsume_RateLimiter_SlidingWindow_bytes32_uint256') + .withArgs(true); + + await expect(this.mock.state(key1)).to.eventually.deep.equal([17n, CAPACITY - 17n]); + await expect(this.mock.used(key1)).to.eventually.equal(17n); + await expect(this.mock.available(key1)).to.eventually.equal(CAPACITY - 17n); + await expect(this.mock.state(key2)).to.eventually.deep.equal([0n, CAPACITY]); + await expect(this.mock.used(key2)).to.eventually.equal(0n); + await expect(this.mock.available(key2)).to.eventually.equal(CAPACITY); + + await expect(this.mock.tryConsume(42n, key2)) + .to.emit(this.mock, 'return$tryConsume_RateLimiter_SlidingWindow_bytes32_uint256') + .withArgs(true); + + await expect(this.mock.state(key1)).to.eventually.deep.equal([17n, CAPACITY - 17n]); + await expect(this.mock.used(key1)).to.eventually.equal(17n); + await expect(this.mock.available(key1)).to.eventually.equal(CAPACITY - 17n); + await expect(this.mock.state(key2)).to.eventually.deep.equal([42n, CAPACITY - 42n]); + await expect(this.mock.used(key2)).to.eventually.equal(42n); + await expect(this.mock.available(key2)).to.eventually.equal(CAPACITY - 42n); + }); + it('multiple tryConsume accumulate within the window', async function () { await this.mock.tryConsume(100n); await this.mock.tryConsume(200n); @@ -301,7 +436,7 @@ describe('RateLimiter', function () { // execute (and get event) await expect(this.mock.tryConsume(CAPACITY + 1n)) - .to.emit(this.mock, 'return$tryConsume_RateLimiter_SlidingWindow_uint256') + .to.emit(this.mock, 'return$tryConsume_RateLimiter_SlidingWindow_bytes32_uint256') .withArgs(false); await expect(this.mock.state()).to.eventually.deep.equal([0n, CAPACITY]); @@ -317,7 +452,7 @@ describe('RateLimiter', function () { await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n); await expect(this.mock.tryConsume(0n)) - .to.emit(this.mock, 'return$tryConsume_RateLimiter_SlidingWindow_uint256') + .to.emit(this.mock, 'return$tryConsume_RateLimiter_SlidingWindow_bytes32_uint256') .withArgs(true); await expect(this.mock.state()).to.eventually.deep.equal([42n, CAPACITY - 42n]); From 88c8736bd1b813a516edcdf628b2e199db337fdf Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 20 May 2026 18:18:00 +0200 Subject: [PATCH 6/7] up --- test/utils/RateLimiter.test.js | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/test/utils/RateLimiter.test.js b/test/utils/RateLimiter.test.js index 430724c73ff..95af237a98b 100644 --- a/test/utils/RateLimiter.test.js +++ b/test/utils/RateLimiter.test.js @@ -9,20 +9,23 @@ const WINDOW = 100n; const CAPACITY = 1000n; const refillPerSecond = CAPACITY / WINDOW; +const defaultKey = ethers.ZeroHash; +const key1 = ethers.id('key1'); +const key2 = ethers.id('key2'); + async function fixture() { const mock = await ethers.deployContract('$RateLimiter'); return { mock }; } const wrap = (mock, type) => ({ - state: (key = ethers.ZeroHash) => mock.getFunction(`$state_RateLimiter_${type}`)(0n, key), - used: (key = ethers.ZeroHash) => mock.getFunction(`$used_RateLimiter_${type}`)(0n, key), - available: (key = ethers.ZeroHash) => mock.getFunction(`$available_RateLimiter_${type}`)(0n, key), - tryConsume: (amount, key = ethers.ZeroHash) => mock.getFunction(`$tryConsume_RateLimiter_${type}`)(0n, key, amount), - tryConsumeStatic: (amount, key = ethers.ZeroHash) => - mock.getFunction(`$tryConsume_RateLimiter_${type}`).staticCall(0n, key, amount), - consume: (amount, key = ethers.ZeroHash) => mock.getFunction(`$consume_RateLimiter_${type}`)(0n, key, amount), - reset: (key = ethers.ZeroHash) => mock.getFunction(`$reset_RateLimiter_${type}`)(0n, key), + state: (k = defaultKey) => mock.getFunction(`$state_RateLimiter_${type}`)(0n, k), + used: (k = defaultKey) => mock.getFunction(`$used_RateLimiter_${type}`)(0n, k), + available: (k = defaultKey) => mock.getFunction(`$available_RateLimiter_${type}`)(0n, k), + tryConsume: (q, k = defaultKey) => mock.getFunction(`$tryConsume_RateLimiter_${type}`)(0n, k, q), + tryConsumeStatic: (q, k = defaultKey) => mock.getFunction(`$tryConsume_RateLimiter_${type}`).staticCall(0n, k, q), + consume: (q, k = defaultKey) => mock.getFunction(`$consume_RateLimiter_${type}`)(0n, k, q), + reset: (k = defaultKey) => mock.getFunction(`$reset_RateLimiter_${type}`)(0n, k), updateSettings: (window, capacity) => mock.getFunction(`$updateSettings_RateLimiter_${type}`)(0n, window, capacity), }); @@ -63,9 +66,6 @@ describe('RateLimiter', function () { }); it('consume one key does not affect another key', async function () { - const key1 = ethers.id('key1'); - const key2 = ethers.id('key2'); - await expect(this.mock.state(key1)).to.eventually.deep.equal([0n, CAPACITY]); await expect(this.mock.used(key1)).to.eventually.equal(0n); await expect(this.mock.available(key1)).to.eventually.equal(CAPACITY); @@ -134,9 +134,6 @@ describe('RateLimiter', function () { }); it('tryConsume one key does not affect another key', async function () { - const key1 = ethers.id('key1'); - const key2 = ethers.id('key2'); - await expect(this.mock.state(key1)).to.eventually.deep.equal([0n, CAPACITY]); await expect(this.mock.used(key1)).to.eventually.equal(0n); await expect(this.mock.available(key1)).to.eventually.equal(CAPACITY); From 17a103eca61768d0cb037145940cdba11bd9f672 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 20 May 2026 21:11:35 +0200 Subject: [PATCH 7/7] document side effects of RefillingBucket.updateSettings --- contracts/utils/RateLimiter.sol | 17 ++++++++++++ test/utils/RateLimiter.test.js | 48 ++++++++++++++++++++++----------- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/contracts/utils/RateLimiter.sol b/contracts/utils/RateLimiter.sol index b73e5c4236a..4fb08b4cef2 100644 --- a/contracts/utils/RateLimiter.sol +++ b/contracts/utils/RateLimiter.sol @@ -135,12 +135,29 @@ library RateLimiter { /** * @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. diff --git a/test/utils/RateLimiter.test.js b/test/utils/RateLimiter.test.js index 95af237a98b..0ff944e442f 100644 --- a/test/utils/RateLimiter.test.js +++ b/test/utils/RateLimiter.test.js @@ -3,6 +3,7 @@ const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const { MAX_UINT48 } = require('../helpers/constants'); +const { batchInBlock } = require('../helpers/txpool'); const time = require('../helpers/time'); const WINDOW = 100n; @@ -27,6 +28,7 @@ const wrap = (mock, type) => ({ consume: (q, k = defaultKey) => mock.getFunction(`$consume_RateLimiter_${type}`)(0n, k, q), reset: (k = defaultKey) => mock.getFunction(`$reset_RateLimiter_${type}`)(0n, k), updateSettings: (window, capacity) => mock.getFunction(`$updateSettings_RateLimiter_${type}`)(0n, window, capacity), + refresh: type == 'RefillingBucket' ? (k = defaultKey) => mock.$refresh(0n, k) : undefined, }); describe('RateLimiter', function () { @@ -233,30 +235,46 @@ describe('RateLimiter', function () { await expect(this.mock.available()).to.eventually.equal(CAPACITY); }); - it('updateSettings freezes accrued usage', async function () { + it('updateSettings side effect on usage', async function () { + const d1 = 3n; + const d2 = 4n; + const newRefillPerSecond = (2n * CAPACITY) / WINDOW; + // consume the whole bucket await this.mock.consume(CAPACITY); - // wait half a window so half should have refilled - await time.increaseBy.timestamp(10n); + await time.increaseBy.timestamp(d1, false); + await this.mock.updateSettings(WINDOW, CAPACITY * 2n); + await time.increaseBy.timestamp(d2); await expect(this.mock.state()).to.eventually.deep.equal([ - CAPACITY - 10n * refillPerSecond, - 10n * refillPerSecond, + CAPACITY - (d1 + d2) * newRefillPerSecond, + CAPACITY + (d1 + d2) * newRefillPerSecond, ]); - await expect(this.mock.used()).to.eventually.equal(CAPACITY - 10n * refillPerSecond); - await expect(this.mock.available()).to.eventually.equal(10n * refillPerSecond); + await expect(this.mock.used()).to.eventually.equal(CAPACITY - (d1 + d2) * newRefillPerSecond); + await expect(this.mock.available()).to.eventually.equal(CAPACITY + (d1 + d2) * newRefillPerSecond); + }); + + it('using refresh to mitigate updateSettings side effect', async function () { + const d1 = 3n; + const d2 = 4n; + const newRefillPerSecond = (2n * CAPACITY) / WINDOW; + + // consume the whole bucket + await this.mock.consume(CAPACITY); - // update settings (changes capacity but should keep the current accrued used quantity) - await this.mock.updateSettings(WINDOW * 2n, CAPACITY * 2n); + await time.increaseBy.timestamp(d1, false); + await batchInBlock([() => this.mock.refresh(), () => this.mock.updateSettings(WINDOW, CAPACITY * 2n)]); + await time.increaseBy.timestamp(d2); - // one more second passed (because of the updateSettings call) await expect(this.mock.state()).to.eventually.deep.equal([ - CAPACITY - 11n * refillPerSecond, - CAPACITY + 11n * refillPerSecond, + CAPACITY - d1 * refillPerSecond - d2 * newRefillPerSecond, + CAPACITY + d1 * refillPerSecond + d2 * newRefillPerSecond, ]); - await expect(this.mock.used()).to.eventually.equal(CAPACITY - 11n * refillPerSecond); - await expect(this.mock.available()).to.eventually.equal(CAPACITY + 11n * refillPerSecond); + await expect(this.mock.used()).to.eventually.equal(CAPACITY - d1 * refillPerSecond - d2 * newRefillPerSecond); + await expect(this.mock.available()).to.eventually.equal( + CAPACITY + d1 * refillPerSecond + d2 * newRefillPerSecond, + ); }); it('reset clears used', async function () { @@ -494,7 +512,7 @@ describe('RateLimiter', function () { await expect(this.mock.available()).to.eventually.equal(CAPACITY - 42n); await expect(this.mock.used()).to.eventually.equal(42n); - await this.mock.updateSettings(WINDOW * 2n, CAPACITY * 2n); + await this.mock.updateSettings(WINDOW, CAPACITY * 2n); // used reflects existing checkpoints, only the new limit/window applies going forward await expect(this.mock.available()).to.eventually.equal(2n * CAPACITY - 42n);