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.

Contents
- The security model, as four assumptions
- Message identity and replay protection
- Rate limits: token buckets and windows
- Sizing a limit: a worked example
- Withdrawal delays and challenge windows
- Emergency pause: scope, roles and the key
- Four failures, walked through
- Triggers that should page someone
- Invariant tests and a review checklist
- Conclusions
- Recommendations
- 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.
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:
// 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.
// 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.
| Binding | Prevents | Leaves open |
|---|---|---|
| Nonce from the source bridge | Two identical transfers sharing one identity | Useless outside the attested digest |
| Source domain and bridge | Messages from a look-alike source | Forged messages that pass verification |
| Destination domain and bridge | Delivery on another chain or deployment | — |
| Domain tag | Another protocol, message type or version read as a transfer | — |
| Executed bitmap | A second execution on this contract | Replay into a fresh contract |
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
Wper calendar window. An attack that straddles the reset takesWon each side, so the bound is2W. - Sliding window: at most
Win any interval of the window’s length. Wormhole’s Governor uses 24 hours, per chain, in US dollars. The bound isW. - Token bucket: capacity
C, refilled at rater. CCIP token pools keep one per token, remote chain and direction, and reject transfers aboveC. The bound over timetisC + 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.
// 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:
| Parameter | Value |
|---|---|
| Escrow | $200M |
| Daily outflow over 90 days | Median $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_p | 30 min (automated watcher); 2 h (human triage); 8 h (overnight) |
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.
| Design | Max in 24 h | Loss at 30 min | Loss at 2 h | Loss at 8 h | Queued, busiest day | Queued, 95th pct. day |
|---|---|---|---|---|---|---|
| No limit | 200 | 200 | 200 | 200 | None | None |
| Fixed window, W = 30 | 60 | 60 | 60 | 60 | None | None |
| Sliding window, W = 30 | 30 | 30 | 30 | 30 | None | None |
| Bucket, C = 6, r = 1/h | 30 | 6.5 | 8 | 14 | None | None |
| Bucket, C = 10, r = 0.5/h | 22 | 10.25 | 11 | 14 | 2; waits 4 h | None |
| Bucket, C = 5, r = 0.25/h | 11 | 5.125 | 5.5 | 7 | 13; never clears | 1; never clears |
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
| Mechanism | Who waits | Length | Example |
|---|---|---|---|
| Challenge window | Every withdrawal | About a week | OP Stack (7 days on mainnet); Arbitrum |
| Large-transfer delay | Transfers above a threshold | 24 hours | Wormhole Governor |
| Manual review | Transfers above a higher threshold | Until approved | Ronin Bridge, Tier 3 |
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.
| Role | Can | Holder and delay | If compromised |
|---|---|---|---|
| Watcher | Pause one route or asset | Isolated automation; no delay | Nuisance pause, expiring after 72 h |
| Guardians | Pause any scope; cancel or release queued transfers; cancel governance actions | Nine organisations; 2 to pause, 6 to release | Pause abuse only; release needs 6 |
| Governance | Unpause; raise limits; change signers and roots; upgrade | Multisig; 48 h timelock | Visible for 48 h; guardians can cancel |
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.
| Failure | What stops or caps it | Bound |
|---|---|---|
| Signing quorum stolen | Bucket, and the pause if its keys were not stolen; a pre-signing limit cannot help, since the attacker signs | $8M |
| Verification bug | Bucket; the watcher sees executions with no source event and pauses | $8M; $6.5M if paused at 30 min |
| Wrong root or threshold after an upgrade | Bucket, shared by all senders, so copycats add nothing; a configuration-change alert | $8M in total |
| Replay of genuine messages | Destination binding and executed bitmap; otherwise the bucket | $0; $8M without the bindings |
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.
| Trigger | Response |
|---|---|
| Execution with no finalised source event, per the watcher’s own nodes | Page; pause the route |
| Wrapped supply above escrow for any asset | Page; pause the asset |
| Attested source event no longer canonical | Page; pause the route |
Bucket below half capacity, a RateLimited revert or a queued transfer | Page the on-call engineer |
| Change to signers, thresholds, roots, limits, roles or code | Page; match an approved change |
Repeated AlreadyExecuted or WrongDestination reverts | Page: replay probing |
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.
// 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_pis 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
- Bind attested messages to a domain tag, both chains and both bridges; record executions before any transfer.
- Put per-asset and global buckets in the contract that releases or mints, sized from observed outflows.
- Make pausing fast and narrow, unpausing slow, and every pause expire.
Operators
- Run an independent watcher that matches executions to finalised source events and can pause a route.
- Measure detection-to-pause time in drills and recompute
C + r·t_pwhen it changes.
Reviewers
- Test containment invariants with a handler that holds a stolen attester quorum.
Bibliography
Primary sources: standards, official documentation, incident reports and papers.
- 1Governor (whitepaper 0007)(opens in a new tab)Wormhole Foundation, wormhole repository on GitHub
- 2Rate Limits - Overview(opens in a new tab)Chainlink CCIP documentation
- 3Withdrawal flow(opens in a new tab)Optimism documentation
- 4Privileged Roles in OP Stack Chains(opens in a new tab)Optimism documentation
- 5Child to parent chain messaging(opens in a new tab)Arbitrum documentation
- 6Withdrawal limits(opens in a new tab)Ronin documentation
- 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.





