Skip to content
Quantix Security Labs
Technical analysis

Containing bridge failures: rate limits, delays and emergency pauses

Assume a bridge’s verification will fail one day. Replay protection, rate limits sized from observed outflows, withdrawal delays and a well-scoped pause decide how much that failure can take, and who pays for it.

Written by
Quantix Research, Cross-Chain Security
Published
Reading time
11 minutes
Category
Cross-Chain Security

Technical analysis. Built from the public sources in the bibliography. Incident details are as reported there; no new discovery is claimed.

Illustration: two chains joined by a bridge span; withdrawals pass a token bucket that meters them and stop at a lowered red pause gate, under an arc marking the delay.
Contents
  1. The security model, as four assumptions
  2. Message identity and replay protection
  3. Rate limits: token buckets and windows
  4. Sizing a limit: a worked example
  5. Withdrawal delays and challenge windows
  6. Emergency pause: scope, roles and the key
  7. Four failures, walked through
  8. Triggers that should page someone
  9. Invariant tests and a review checklist
  10. Conclusions
  11. Recommendations
  12. Bibliography

Our analysis of cross-chain bridge exploits grouped public incidents by the part of the verification path that failed. This follow-up assumes that verification will fail and asks how much can leave before someone stops it, and what that bound costs legitimate users. It sizes the controls that contain a failure (message identity, rate limits, delays and pauses) for a model bridge, using mechanisms documented by Wormhole, Chainlink CCIP, the OP Stack, Arbitrum and Ronin.

The security model, as four assumptions

A lock-and-mint bridge escrows an asset on a source chain and mints a wrapped version on a destination chain; burn-and-release reverses the flow. For each asset, the bridge is solvent while

wrapped supply on all destinations ≤ escrow balance on the source

The difference is the value in flight. Four assumptions keep the invariant true:

  • Verification: every accepted message matches a source event. Stolen keys, verifier bugs and wrong trusted roots break it.
  • Finality: the source event cannot be reverted after the destination acts. Early attestation and deep reorganisations break it.
  • Ordering: configuration messages, such as a signer rotation, apply in order relative to the transfers they govern.
  • Uniqueness: each source event is acted on once, on one destination, and is executed or refunded, never both.

No control below makes these assumptions true; each bounds how fast the invariant breaks once one fails.

Release path of the model bridge and what each control boundsA transfer message and its attestation arrive from the source chain, where the asset was locked or burned, a nonce was assigned and the event was finalised. On the destination it passes six stages in order: verify the signatures, proof or light client; bind and deduplicate the message using the domain tag, source and destination identifiers and an executed-message bitmap; check that the route, the asset and the bridge as a whole are not paused; check its size, sending transfers at or above the threshold to a large-transfer queue that releases after 24 hours or after approval; draw on the per-asset and global rate-limit buckets, reverting or queueing when they are empty; and finally release or mint the asset. An independent watcher re-derives every message from source-chain events, checks that wrapped supply never exceeds the escrow, and can page people and pause the affected scope. Legend: verification stops forged messages while it is correct; binding and deduplication stop replays of genuine messages; the pause stops all outflow once someone acts; the size check makes large transfers wait for time or approval; the rate limit bounds outflow to capacity plus refill rate times elapsed time, whatever passed the earlier stages.Source chainasset locked or burned; nonce assigned; finalisedmessage and attestationVerifysignatures, proofor light client1Bind and dedupedomain, chains,executed bitmap2Pause checkroute, assetor global scope3Size checkat or above thethreshold: delay4Rate limitper-asset andglobal buckets5Release or mintfunds leavethe bridgeLarge-transfer queue24 h, or approvalover limit: revert or queueIndependent watcherre-derives each message from source events; checks wrapped supply ≤ escrowpages and pauses1Verify: stops forged messages while it is correct2Bind and dedupe: stops replays of genuine messages3Pause: stops all outflow once someone acts4Size check: large transfers wait for time or approval5Rate limit: outflow ≤ C + r·t, whatever passed above
Release path of the model bridge; each control assumes that earlier stages may have failed.

Message identity and replay protection

Uniqueness is the one assumption a destination contract can enforce alone. It takes an identity that differs for every transfer and context, and a record of identities already executed. This release function gets both wrong:

ReleaseV1.sol (illustrative, vulnerable)solidity
// Simplified, illustrative. Not production code.contract ReleaseV1 {    IAttesterSet public immutable attesters;   // k-of-n signature check    IERC20 public immutable token;    mapping(bytes32 => bool) public used;     function release(address to, uint256 amount, uint64 nonce, bytes[] calldata sigs) external {        bytes32 digest = keccak256(abi.encodePacked(to, amount, nonce));  // no chain, bridge or domain        require(attesters.verify(digest, sigs), "bad attestation");        bytes32 key = keccak256(abi.encode(sigs));                         // replay key: the signatures        require(!used[key], "used");        used[key] = true;        token.transfer(to, amount);    }}

The digest names no chain and no bridge, so the attestation is valid on every deployment that trusts the same attesters. The replay key is the signature bundle, not the message: any other k of the n signatures over the same digest form a new key for the same transfer.

ReleaseV2.sol (illustrative)solidity
// Simplified, illustrative. Not production code.struct Transfer {    uint32 srcDomain; address srcBridge; uint64 nonce;   // nonce: unique per route    uint32 dstDomain; address dstBridge;    address token; address to; uint256 amount;} contract ReleaseV2 {    bytes32 constant TAG = keccak256("ModelBridge/Transfer/v2");     // protocol, type, version    mapping(uint32 => mapping(uint256 => uint256)) private executed;  // srcDomain => 256-bit words    // localDomain, remoteBridge, verifier, pauses, limiter, largeThreshold, _enqueue: omitted     function release(Transfer calldata t, bytes calldata attestation) external {        bytes32 id = keccak256(abi.encode(TAG, t));        if (!verifier.verify(id, attestation)) revert BadAttestation();        if (t.dstDomain != localDomain || t.dstBridge != address(this)) revert WrongDestination();        if (t.srcBridge != remoteBridge[t.srcDomain]) revert UnknownSource();        _markExecuted(t.srcDomain, t.nonce);                           // before any transfer        pauses.requireOpen(t.srcDomain, t.token);                      // route, asset, global        if (t.amount >= largeThreshold[t.token]) { _enqueue(id, t); return; }        limiter.consume(t.token, t.amount);                            // reverts when empty        IERC20(t.token).safeTransfer(t.to, t.amount);    }     function _markExecuted(uint32 src, uint64 nonce) internal {        uint256 bit = uint256(1) << (nonce & 0xff);        uint256 word = executed[src][nonce >> 8];        if (word & bit != 0) revert AlreadyExecuted();        executed[src][nonce >> 8] = word | bit;    }}

The id hashes the tag with every field, so changing anything the attesters approved changes the id.

BindingPreventsLeaves open
Nonce from the source bridgeTwo identical transfers sharing one identityUseless outside the attested digest
Source domain and bridgeMessages from a look-alike sourceForged messages that pass verification
Destination domain and bridgeDelivery on another chain or deployment—
Domain tagAnother protocol, message type or version read as a transfer—
Executed bitmapA second execution on this contractReplay into a fresh contract
What each part of a message’s identity prevents

A bitmap holds 256 flags per storage slot, which suits dense per-route nonces. It is security state: a migration must carry it over or change the domain tag. The OP Stack likewise checks that a withdrawal was not already finalised. Replay from the signing side is covered in signature replay across EVM chains; the cross-chain message replay case study shows a replay on a second destination that an alert-only limiter failed to stop.

Rate limits: token buckets and windows

A rate limit bounds outflow over time, whatever failed upstream.

  • Fixed window: at most W per calendar window. An attack that straddles the reset takes W on each side, so the bound is 2W.
  • Sliding window: at most W in any interval of the window’s length. Wormhole’s Governor uses 24 hours, per chain, in US dollars. The bound is W.
  • Token bucket: capacity C, refilled at rate r. CCIP token pools keep one per token, remote chain and direction, and reject transfers above C. The bound over time t is C + r·t.

Placement decides what a limit caps. The Governor holds messages in guardian node software before they are signed, so a destination contract that accepts unsigned messages, or a stolen signing quorum, escapes it. CCIP enforces limits on-chain in both pools, inbound about 5–10% above outbound because finalised transfers can become executable together. Only a limiter in the contract that releases or mints applies whatever failed before it.

Per-asset limits add up: three assets at C = $6M, r = $1M/h can lose 3 × (6 + 1 × 2) = $24M in two hours; a global bucket of $10M plus $2M/h caps that at 10 + 2 × 2 = $14M. Global limits need prices, and a low price widens them; the Governor values transfers at the higher of a floor price and a market price.

OutflowLimiter.sol (illustrative)solidity
// Simplified, illustrative. Not production code.contract OutflowLimiter {    struct Bucket { uint128 capacity; uint128 level; uint64 ratePerSec; uint64 last; }    mapping(address => Bucket) public perAsset;   // token units    Bucket public global;                         // USD, 18 decimals     function consume(address token, uint256 amount) external onlyBridge {        _take(perAsset[token], amount);        _take(global, prices.valueAtLeastFloor(token, amount));  // max(floor, feed)    }     function _take(Bucket storage b, uint256 amount) internal {        uint256 level = b.level + (block.timestamp - b.last) * b.ratePerSec;        if (level > b.capacity) level = b.capacity;        if (amount > level) revert RateLimited(amount, level);        (b.level, b.last) = (uint128(level - amount), uint64(block.timestamp));    }     // Lowering is immediate; raising is timelocked and leaves b.level unchanged.    function lower(address token, uint128 capacity, uint64 ratePerSec) external onlyGuardian {        Bucket storage b = perAsset[token];        require(capacity <= b.capacity && ratePerSec <= b.ratePerSec, "not a decrease");        _take(b, 0);                                   // settle refill at the old rate        (b.capacity, b.ratePerSec) = (capacity, ratePerSec);        if (b.level > capacity) b.level = capacity;    }}

CCIP applies limit changes immediately; v2.0 pools jump to the new capacity, v1.x pools keep refilling at the normal rate. For increases, the second behaviour bounds what a hasty or stolen admin change releases.

Sizing a limit: a worked example

Consider a model bridge with one dollar-pegged asset and these illustrative parameters:

ParameterValue
Escrow$200M
Daily outflow over 90 daysMedian $6M; 95th percentile $12M; busiest day $24M
Busiest hour; transfer sizes$4M; largest transfer $2.5M, $1M or more about twice a day
Detection-to-pause time t_p30 min (automated watcher); 2 h (human triage); 8 h (overnight)
Parameters of the model bridge

If the limit is full when the attack starts and the attacker takes all it allows, the bound is C + r·t_p for a bucket, W for a sliding window and 2W for a fixed one (t_p under 24 hours), capped by the escrow. A bucket releases at most C + 24r in any 24 hours, so a day with outflow D queues at least D − C − 24r; with even demand, the last queued transfer waits queue / r.

Example: C = $10M, r = $0.5M/h. At two hours the bound is 10 + 0.5 × 2 = $11M. On the busiest day, demand runs at 24 / 24 = $1M/h, so 24 − 10 − 0.5 × 24 = $2M is queued and the last transfer waits 2 / 0.5 = 4 hours; at the median rate of 6 / 24 = $0.25M/h, the queue clears in 2 / (0.5 − 0.25) = 8 hours.

DesignMax in 24 hLoss at 30 minLoss at 2 hLoss at 8 hQueued, busiest dayQueued, 95th pct. day
No limit200200200200NoneNone
Fixed window, W = 3060606060NoneNone
Sliding window, W = 3030303030NoneNone
Bucket, C = 6, r = 1/h306.5814NoneNone
Bucket, C = 10, r = 0.5/h2210.2511142; waits 4 hNone
Bucket, C = 5, r = 0.25/h115.1255.5713; never clears1; never clears
Loss bound and legitimate queueing on the model bridge ($ millions, exact; queueing assumes even demand after an ordinary day)
Maximum loss against detection-to-pause time for four limit designs on the model bridgeLine chart. The horizontal axis is the time from the first malicious outflow to an effective pause, from 0 to 16 hours. The vertical axis is the maximum loss in millions of dollars, from 0 to 70. A fixed 24-hour window of 30 million is flat at 60 million, because an attack that straddles the reset gets two windows. A sliding 24-hour window of 30 million is flat at 30 million. A token bucket with 6 million capacity refilling at 1 million per hour rises from 6 million at time zero to 22 million at 16 hours, passing 6.5 million at 30 minutes, 8 million at 2 hours and 14 million at 8 hours. A token bucket with 10 million capacity refilling at 0.5 million per hour rises from 10 million to 18 million at 16 hours, passing 10.25 million at 30 minutes, 11 million at 2 hours and 14 million at 8 hours. The two buckets are equal at 8 hours; below that the smaller capacity gives the lower bound, above it the slower refill. Without a limit the loss is the whole 200 million escrow at every detection time, off the scale.Maximum loss ($ millions)0102030405060700 h2 h4 h6 h8 h10 h12 h14 h16 hDetection-to-pause time (hours)30 min2 h8 hFixed 24 h window, $30MSliding 24 h window, $30MBucket $6M + $1M/hBucket $10M + $0.5M/hequal at 8 h: $14MNo limit: $200M, off scaleFixed 24 h window, $30MSliding 24 h window, $30MBucket $6M + $1M/hBucket $10M + $0.5M/h
Loss bound against detection-to-pause time for four designs in the table.

Capacity dominates when detection is fast and refill when it is slow: the $6M + $1M/h and $10M + $0.5M/h buckets meet at $14M at eight hours. Users pay for the refill rate: a refill below the busiest day’s $1M/h queues transfers that day, and one at the median rate never clears its queue, so the lowest bound in the table is unusable. And a bucket can match a window’s daily ceiling yet bound short incidents more tightly: $6M + $1M/h allows the sliding window’s $30M a day, but only $8M in two hours, 4% of the escrow.

So set r at or above the busiest sustained rate and C so that C + r covers the busiest hour, then compute C + r·t_p. If that is too large, the lever that costs users nothing is a shorter t_p.

Withdrawal delays and challenge windows

MechanismWho waitsLengthExample
Challenge windowEvery withdrawalAbout a weekOP Stack (7 days on mainnet); Arbitrum
Large-transfer delayTransfers above a threshold24 hoursWormhole Governor
Manual reviewTransfers above a higher thresholdUntil approvedRonin Bridge, Tier 3
Delay mechanisms in documented systems

A delay turns detection into a deadline, and every transfer that waits pays for it. In optimistic designs the delay is the verification: an OP Stack withdrawal waits a seven-day fault challenge period on mainnet, and an Arbitrum message to the parent chain waits for its assertion to be confirmed, about a week. The window must cover detection, dispute and inclusion under adverse conditions.

Application bridges delay only what is large. The Governor holds transfers above a per-chain threshold for 24 hours before guardians sign them; Ronin asks for more operator approvals as amounts grow and sends its top tier to manual review. Neither counts those transfers toward its daily limit.

On the model bridge, a $1M threshold with a 24-hour delay holds back about two transfers a day and outlasts every detection time in the table, so a forged large transfer is stopped. An attacker splits below the threshold instead, which the bucket bounds. A timed release fails open if nobody acts, and the Governor’s design requires guardians to respond within its 24 hours; manual approval trades that risk for liveness.

Emergency pause: scope, roles and the key

A pause turns the bound into a stop. Four properties matter.

  • Scope: per route, per asset and global, stopping releases, mints and new deposits, so one faulty chain does not halt everything and nobody locks funds against an unbacked token.
  • Asymmetry: a missed pause costs funds, a needless one hours. Pausing should be fast and open to any of several independent parties, including a watcher whose key can do nothing else; unpausing needs more signers and a delay.
  • Independence: the pause must not run through the verifier or a contract the compromised key can upgrade.
  • Bounded power: a pause key is a denial-of-service key. A compromised OP Stack Guardian could pause withdrawals for at most three months before they unpause automatically, and the L1 Proxy Admin can replace it. In Wormhole, 7 of 19 guardians can block a message and 13 can release one early.
RoleCanHolder and delayIf compromised
WatcherPause one route or assetIsolated automation; no delayNuisance pause, expiring after 72 h
GuardiansPause any scope; cancel or release queued transfers; cancel governance actionsNine organisations; 2 to pause, 6 to releasePause abuse only; release needs 6
GovernanceUnpause; raise limits; change signers and roots; upgradeMultisig; 48 h timelockVisible for 48 h; guardians can cancel
Privileged roles on the model bridge

Four failures, walked through

With the $6M + $1M/h bucket, a $1M threshold and two hours to pause, the controls cap four failure classes as follows.

FailureWhat stops or caps itBound
Signing quorum stolenBucket, and the pause if its keys were not stolen; a pre-signing limit cannot help, since the attacker signs$8M
Verification bugBucket; the watcher sees executions with no source event and pauses$8M; $6.5M if paused at 30 min
Wrong root or threshold after an upgradeBucket, shared by all senders, so copycats add nothing; a configuration-change alert$8M in total
Replay of genuine messagesDestination binding and executed bitmap; otherwise the bucket$0; $8M without the bindings
What the controls cap on the model bridge

The third row has a public counterpart. In August 2024, according to Ronin, an upgrade made its bridge misinterpret the operator vote threshold for withdrawals. White-hat actors withdrew about 4,000 ETH and 2 million USDC, later returned: the most of each asset one transaction could withdraw, Ronin said. The bridge was paused about 40 minutes after the first on-chain action was spotted, and Ronin said the limit “effectively prevented further damage in this exploit”.

Triggers that should page someone

Each trigger below justifies waking someone, because t_p is set by monitoring and response. On-chain monitoring and an incident response plan that settles who may pause keep it short.

TriggerResponse
Execution with no finalised source event, per the watcher’s own nodesPage; pause the route
Wrapped supply above escrow for any assetPage; pause the asset
Attested source event no longer canonicalPage; pause the route
Bucket below half capacity, a RateLimited revert or a queued transferPage the on-call engineer
Change to signers, thresholds, roots, limits, roles or codePage; match an approved change
Repeated AlreadyExecuted or WrongDestination revertsPage: replay probing
Monitoring triggers for the model bridge

Invariant tests and a review checklist

Safety invariants hold while the four assumptions hold; test them with a handler that delivers, duplicates, misroutes and reorders genuine messages. Containment invariants must hold when the assumptions fail; give the handler a stolen attester quorum too. Writing invariants that find bugs covers handlers and ghost variables.

ModelBridgeInvariants.t.sol (illustrative)solidity
// Simplified, illustrative. Not production code.contract ModelBridgeInvariants is Test {    Handler internal h;     function setUp() public {        h = new Handler();        targetContract(address(h));    }     // Safety suite (honest handler): holds while the four assumptions hold.    function invariant_backed() public view {        assertLe(h.wrapped().totalSupply(), h.asset().balanceOf(address(h.escrow())));    }    function invariant_executedOnce() public view {        assertEq(h.ghostDoubleExecutions(), 0);    }     // Containment suite (the handler also holds a stolen attester quorum).    function invariant_lossBounded() public view {        // max over recorded intervals of (outflow - RATE_PER_SEC * duration)        assertLe(h.ghostWorstExcess(), CAPACITY);    }    function invariant_nothingLeavesWhilePaused() public view {        assertEq(h.ghostOutflowWhilePaused(), 0);    }}

A review should confirm that:

  • the attested digest binds the domain tag, both domains and bridges, the nonce and the payload;
  • replay records key on message identity, precede external calls and survive migrations, and no message is both executed and refunded;
  • every release and mint path, including retries and rescue functions, passes the limiter and the pause check;
  • limits exist per asset and globally, valued with a floor price, and C + r·t_p is computed from observed outflows and accepted in writing;
  • raising a limit is delayed, lowering it immediate;
  • pausers cannot unpause, pauses expire, and the watcher shares no code, keys or nodes with the attesters.

Conclusions

Verification makes a bridge correct; containment decides what a failure costs. With a token bucket on the release path, no upstream failure takes more than C + r·t_p.

Bursts set the capacity and sustained demand the refill, each with a floor below which users pay. Of the three terms, only detection time can shrink without charging users, which puts the watcher, the pause and the people behind them inside the security model.

Recommendations

Bridge designers

  1. Bind attested messages to a domain tag, both chains and both bridges; record executions before any transfer.
  2. Put per-asset and global buckets in the contract that releases or mints, sized from observed outflows.
  3. Make pausing fast and narrow, unpausing slow, and every pause expire.

Operators

  1. Run an independent watcher that matches executions to finalised source events and can pause a route.
  2. Measure detection-to-pause time in drills and recompute C + r·t_p when it changes.

Reviewers

  1. Test containment invariants with a handler that holds a stolen attester quorum.

Bibliography

Primary sources: standards, official documentation, incident reports and papers.

  1. 1Governor (whitepaper 0007)(opens in a new tab)Wormhole Foundation, wormhole repository on GitHub
  2. 2Rate Limits - Overview(opens in a new tab)Chainlink CCIP documentation
  3. 3Withdrawal flow(opens in a new tab)Optimism documentation
  4. 4Privileged Roles in OP Stack Chains(opens in a new tab)Optimism documentation
  5. 5Child to parent chain messaging(opens in a new tab)Arbitrum documentation
  6. 6Withdrawal limits(opens in a new tab)Ronin documentation
  7. 7Ronin bridge pauses amid $11.8 million outflow to MEV bot white hats(opens in a new tab)The Block, 6 August 2024 (reporting Ronin’s statement)

Applied on this site

Assessments, case studies and tools that apply this publication.

Want this question asked of your code?

Quantix reviewers apply the same reasoning to production systems. Share what you are building and when it ships.