Building an On-Chain Rate Limiter & Rolling Window Circuit Breaker for Bridge Vaults

In March 2022, attackers compromised the private keys of five Ronin bridge validators and took 173,600 ETH and 25.5 million USDC through two unrestricted withdrawals within minutes, causing $620 million in losses. Similar incidents followed at Nomad ($190 million), Harmony Horizon ($100 million), and Heco Bridge ($86 million). Security specialists reviewing these major Web3 thefts consistently reach a difficult conclusion:

In real-world cross-chain bridges and large DeFi vaults, compromising a relayer or multisig private key is a matter of time and cost.

If a vault contract bases its entire withdrawal and release logic on whether a signature is valid, a compromised relayer can empty every last unit of value in a single operation without resistance. Defense in depth must assume that the worst has already happened: even when attackers fully control the signers, the vault contract needs an on-chain fuse they cannot bypass.

This article examines one of the core defenses in Passage Bridge: building a gas-efficient rolling-window rate limiter and automatic circuit breaker in Solidity that resists double spending at window boundaries.


1. Why Fixed Windows Fail to Stop Attacks

Building an On-Chain Rate Limiter & Rolling Window Circuit Breaker for Bridge Vaults

Teams new to rate-limiting contracts often start with a fixed epoch bucket based on calendar days or hours:

// Unsafe basic fixed-window implementation: vulnerable to 2x spikes at window boundaries
uint256 currentHourBucket = block.timestamp / 1 hours;
require(hourlyWithdrawn[currentHourBucket] + amount <= MAX_HOURLY_LIMIT, "LIMIT_EXCEEDED");
hourlyWithdrawn[currentHourBucket] += amount;

This simple algorithm has a critical boundary spike attack vulnerability that allows the limit to be spent twice:

+----------------------------------------------------------------------------------------------------+
|                               Fixed-Bucket Boundary Bypass Vulnerability                            |
+----------------------------------------------------------------------------------------------------+

   MAX_HOURLY_LIMIT = 100,000 USDC
   
         Hour T (e.g. 13:00 - 13:59)                    Hour T+1 (e.g. 14:00 - 14:59)
   [─────────────────────────────────────]       [─────────────────────────────────────]
                                  ▲               ▲
                                  │ 13:59:50      │ 14:00:05
                                  │               │
                           Withdraw 100,000 USDC  Withdraw 100,000 USDC
                           
   Critical failure:
   In just 15 seconds spanning the hour boundary, 200,000 USDC leaves the vault!
   The instantaneous outflow is exactly 2x the intended rate, enough to drain liquidity before operations receives an alert.
+----------------------------------------------------------------------------------------------------+

An attacker can submit concurrent, high-frequency transactions in the seconds around an hour boundary to inflict twice the damage allowed by the limit. Eliminating this gap requires replacing discrete integer buckets with a continuous rolling window with linear decay.


2. Continuous Rolling Windows and the Linear-Decay Model

To enforce a precise on-chain rolling window without storing hundreds or thousands of withdrawal records, which would incur substantial storage gas and iteration costs, we can use the time-domain dual of the leaky-bucket algorithm:

Define the vault’s global rate-limit parameters:

  • W: rolling-window duration (for example, 3600 seconds, or 1 hour);
  • L: maximum permitted net outflow within the window (for example, 100,000 USDC);
  • r: capacity recovery rate (r = L/W).

When a new withdrawal arrives at timestamp tnow and the last update was at tlast:

  1. Calculate elapsed time since the previous withdrawal, Δt = tnow − tlast;
  2. Linearly release previously consumed outflow capacity: decayedAmount=min⁡(currentInFlight,  currentInFlight⋅ΔtW)\text{decayedAmount} = \min\left(\text{currentInFlight}, \; \frac{\text{currentInFlight} \cdot \Delta t}{W}\right)
  3. Subtract the decayed amount from outstanding usage to calculate current net outflow usage: currentInFlight′ = currentInFlight − decayedAmount
  4. Assert the safety invariant: currentInFlight′ + newAmount ≤ L
  5. If the assertion passes, update the state: currentInFlightnew = currentInFlight′ + newAmount,  tlast = tnow

This mathematical model ensures that cumulative vault outflow strictly converges to L within any continuous 3600-second interval, eliminates spikes at hour boundaries, and updates only 2 storage slots per withdrawal, with strictly O(1) gas complexity.


3. Passage Bridge’s Vault Rate Limiter and Circuit Breaker: Core Code

The Passage Bridge asset vault tightly integrates the rolling-window limiter with an automated circuit breaker. When withdrawal velocity approaches the critical threshold abnormally, the contract supports more than a revert: it can automatically trigger a global circuit-breaker lock and route excess transactions to an off-chain audit queue.

// passage-bridge/contracts/src/security/RollingWindowRateLimiter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/access/Ownable2Step.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";

error RateLimitExceeded(address token, uint256 requested, uint256 availableCapacity);
error CircuitBreakerTripped(address token, uint256 velocityExceeded);
error TokenRateLimitNotConfigured(address token);

contract RollingWindowRateLimiter is Ownable2Step, Pausable {
    struct TokenLimitConfig {
        uint256 windowDuration;       // Rolling-window duration in seconds, e.g. 3600
        uint256 maxCapacity;          // Maximum net outflow per window, in base units
        uint256 circuitBreakerThreshold; // Instantaneous automatic-trip threshold, e.g. 90% of capacity
        bool isConfigured;
    }

    struct TokenLimiterState {
        uint256 currentInFlight;      // Total outflow currently counted within the rolling window
        uint64 lastUpdateTime;        // Timestamp of the last outflow update
    }

    // tokenAddress => rate-limit configuration
    mapping(address => TokenLimitConfig) public tokenConfigs;
    // tokenAddress => runtime rate-limit state
    mapping(address => TokenLimiterState) public tokenStates;

    event RateLimitUpdated(address indexed token, uint256 currentInFlight, uint256 capacityRemaining);
    event CircuitBreakerActivated(address indexed token, uint256 currentInFlight, string reason);
    event LimitConfigSet(address indexed token, uint256 windowDuration, uint256 maxCapacity);

    constructor(address initialOwner) Ownable(initialOwner) {}

    function setTokenLimitConfig(
        address token,
        uint256 windowDuration,
        uint256 maxCapacity,
        uint256 circuitBreakerThreshold
    ) external onlyOwner {
        require(windowDuration > 0, "INVALID_WINDOW");
        require(maxCapacity > 0, "INVALID_CAPACITY");
        require(circuitBreakerThreshold <= maxCapacity, "INVALID_THRESHOLD");

        tokenConfigs[token] = TokenLimitConfig({
            windowDuration: windowDuration,
            maxCapacity: maxCapacity,
            circuitBreakerThreshold: circuitBreakerThreshold,
            isConfigured: true
        });

        emit LimitConfigSet(token, windowDuration, maxCapacity);
    }

    /**
     * @notice Consume capacity before release; enforce rejection on excess or a circuit-breaker trip
     * @param token Address of the asset token to be released
     * @param amount Outflow amount as an integer in base units
     */
    function consumeOutflow(address token, uint256 amount) external whenNotPaused {
        TokenLimitConfig memory config = tokenConfigs[token];
        if (!config.isConfigured) {
            revert TokenRateLimitNotConfigured(token);
        }

        TokenLimiterState storage state = tokenStates[token];
        uint256 inFlight = state.currentInFlight;
        uint64 lastTime = state.lastUpdateTime;
        uint64 currentTime = uint64(block.timestamp);

        // 1. Calculate linear time decay
        if (currentTime > lastTime && inFlight > 0) {
            uint256 timePassed = currentTime - lastTime;
            if (timePassed >= config.windowDuration) {
                inFlight = 0; // A full rolling window has elapsed; restore all capacity
            } else {
                uint256 decay = (inFlight * timePassed) / config.windowDuration;
                inFlight = inFlight > decay ? inFlight - decay : 0;
            }
        }

        // 2. Check available capacity
        uint256 projectedInFlight = inFlight + amount;
        if (projectedInFlight > config.maxCapacity) {
            uint256 availableCapacity = config.maxCapacity > inFlight ? config.maxCapacity - inFlight : 0;
            revert RateLimitExceeded(token, amount, availableCapacity);
        }

        // 3. Automatic circuit breaker: trigger a system-wide emergency stop near the critical threshold
        if (projectedInFlight >= config.circuitBreakerThreshold) {
            _pause(); // Activate Pausable state to block all subsequent automatic releases
            emit CircuitBreakerActivated(token, projectedInFlight, "Outflow velocity breached circuit breaker threshold");
        }

        // 4. Update stored state
        state.currentInFlight = projectedInFlight;
        state.lastUpdateTime = currentTime;

        emit RateLimitUpdated(token, projectedInFlight, config.maxCapacity - projectedInFlight);
    }

    /**
     * @notice Estimate the token's available capacity at the current time
     */
    function getAvailableCapacity(address token) external view returns (uint256) {
        TokenLimitConfig memory config = tokenConfigs[token];
        if (!config.isConfigured) return 0;

        TokenLimiterState memory state = tokenStates[token];
        uint256 inFlight = state.currentInFlight;
        uint256 currentTime = block.timestamp;

        if (currentTime > state.lastUpdateTime && inFlight > 0) {
            uint256 timePassed = currentTime - state.lastUpdateTime;
            if (timePassed >= config.windowDuration) {
                inFlight = 0;
            } else {
                uint256 decay = (inFlight * timePassed) / config.windowDuration;
                inFlight = inFlight > decay ? inFlight - decay : 0;
            }
        }

        return config.maxCapacity > inFlight ? config.maxCapacity - inFlight : 0;
    }
}

4. Simulating Extreme Attacks with Foundry

To verify the limiter under extreme concurrency and malicious attacks, we wrote dedicated Foundry assertions covering normal capacity recovery, rejection of an oversized individual withdrawal, and circuit-breaker locking:

// passage-bridge/contracts/test/RollingWindowRateLimiter.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/security/RollingWindowRateLimiter.sol";

contract RollingWindowRateLimiterTest is Test {
    RollingWindowRateLimiter public limiter;
    address public dummyUsdc = address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
    address public admin = address(0x1);

    function setUp() public {
        vm.prank(admin);
        limiter = new RollingWindowRateLimiter(admin);

        // Configure USDC: 100,000 USDC per hour (3600s); emergency circuit breaker at 90,000 USDC
        vm.prank(admin);
        limiter.setTokenLimitConfig(
            dummyUsdc,
            3600,
            100_000 * 1e6,
            90_000 * 1e6
        );
    }

    function test_NormalConsumptionAndLinearDecay() public {
        // 1. Withdraw 40,000 USDC at second 0
        limiter.consumeOutflow(dummyUsdc, 40_000 * 1e6);
        assertEq(limiter.getAvailableCapacity(dummyUsdc), 60_000 * 1e6);

        // 2. Advance 1800 seconds (half an hour); linear decay should restore 50% (20,000 USDC)
        vm.warp(block.timestamp + 1800);
        assertEq(limiter.getAvailableCapacity(dummyUsdc), 80_000 * 1e6);

        // 3. Advance another 1800 seconds (3600 total); capacity should fully recover to 100,000 USDC
        vm.warp(block.timestamp + 1800);
        assertEq(limiter.getAvailableCapacity(dummyUsdc), 100_000 * 1e6);
    }

    function test_RevertWhenRateLimitExceeded() public {
        // An attempted single withdrawal of 100,001 USDC must revert with the exact error
        vm.expectRevert(abi.encodeWithSelector(
            RateLimitExceeded.selector,
            dummyUsdc,
            100_001 * 1e6,
            100_000 * 1e6
        ));
        limiter.consumeOutflow(dummyUsdc, 100_001 * 1e6);
    }

    function test_CircuitBreakerActivation() public {
        // Withdraw 92,000 USDC, exceeding the 90,000 threshold; the breaker must automatically activate Paused state
        limiter.consumeOutflow(dummyUsdc, 92_000 * 1e6);
        assertTrue(limiter.paused());

        // The circuit breaker has tripped; EnforcedPause must reject every subsequent withdrawal, regardless of amount
        vm.expectRevert(Pausable.EnforcedPause.selector);
        limiter.consumeOutflow(dummyUsdc, 1 * 1e6);
    }
}

5. Comparing Fixed Windows, Transaction History Lists, and Continuous Linear Decay

In Solidity’s constrained execution environment, storage writes (SSTORE) are the most expensive operation. The following table compares security, gas costs, and implementation complexity across three rate-limiting approaches:

Dimension A: Fixed time buckets (block.timestamp / 1h) B: On-chain linked list of transaction history C: Continuous linear-decay rolling window (this approach)
Boundary-spike resistance Very poor (instantaneous 2x outflow at hour boundaries) Excellent (tracks each historical transaction precisely) Excellent (monotonic linear decay with no boundary-jump vulnerability)
Gas per withdrawal Low (~22,000 gas, incrementing one slot) Very high (iterate over N past transactions and remove expired records; may exceed the gas limit) Very low (~26,000 gas, updating only 2 consecutive storage slots)
Memory and storage overhead Historical buckets permanently occupy slots and require periodic cleanup Grows linearly with transaction volume, creating hazardous unbounded storage growth Constant O(1), requiring only one TokenLimiterState slot
Automatic circuit-breaker integration Difficult to calculate instantaneous acceleration accurately during window resets Bloated logic, prone to deadlocks during reentrancy checks Native support: a one-line condition can trigger a global atomic circuit-breaker suspension
Emergency operational recovery Cannot be tuned smoothly; requires a global contract pause Overly complex structure, prone to dangling pointers Administrators can adjust maxCapacity through multisig at any time for gradual recovery

6. Coordinating with Independent Multi-Relayer Review Queues

An internal contract rate limiter is the minimum defense against major theft. A complete cross-chain asset protection system needs a closed loop of “off-chain alerts → on-chain throttling → human review”:

+----------------------------------------------------------------------------------------------------+
|                         Passage Bridge: Multilevel Cross-Chain Withdrawal Risk Controls             |
+----------------------------------------------------------------------------------------------------+

   User initiates a cross-chain withdrawal
         │
         ▼
   [ Independent Message Review Queue ] ──(amount <= 5,000 USDC and normal rate)──> Fast automatic relayer path
         │                                                                             │
         └──(large withdrawal or approaching 80% of the rolling-window limit)          │
                   │                                                                   ▼
                   ▼                                                    [ On-Chain Vault RateLimiter ]
   [ Route to Multisig Manual Approval (Operational Desk) ]                           │
                   │                                                                   ▼
                   └──(multisig release after verifying actual source-chain deposit invariant)──> [ Release to destination-chain user ]
+----------------------------------------------------------------------------------------------------+
  1. Small withdrawals released in seconds: for routine withdrawals below the safety threshold, the limiter silently deducts capacity and enables fast release;
  2. Large withdrawals and bursts queued in batches: oversized individual transactions are automatically suspended, preventing a one-shot drain and giving off-chain monitoring a valuable “15-minute alert and multisig intervention window”;
  3. Independent asset-conservation reconciliation: combine this with Independent Message Review and Circuit Breakers for Multi-Relayer Bridges and Preventing Cross-Chain Replay Attacks to eliminate the risk of cross-chain asset theft at its root.

Consulting and Source-Code Delivery

If you are planning a cross-chain asset bridge, decentralized lending vault, or high-throughput Web3 payment and clearing channel, and want a vault defense foundation backed by formal verification and intensive load testing: