Smart Contract Circuit Breakers
Prerequisite: Blockchain Fundamentals, Security Model
An attacker finds a reentrancy vector in your stablecoin’s redeem function. They drain $2.3 million in the first 90 seconds. Your security team gets the alert at second 40, confirms the exploit at second 75, and needs to stop the bleeding. The contract has no pause function. The only option is deploying a new implementation through the proxy’s upgrade mechanism — a multi-sig transaction that requires 3-of-5 signers across three time zones. By the time the upgrade executes, $11 million is gone.
A circuit breaker turns a 20-minute scramble into a 15-second response. The difference is whether your contract was built to stop.
The Pausable Pattern
OpenZeppelin’s Pausable contract is the baseline. It exposes two internal functions — _pause() and _unpause() — and a modifier whenNotPaused that reverts any function call when the contract is paused.
import "@openzeppelin/contracts/utils/Pausable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
contract StablecoinToken is Pausable, AccessControl {
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
function transfer(address to, uint256 amount)
public
whenNotPaused
returns (bool)
{
_transfer(msg.sender, to, amount);
return true;
}
function pause() external onlyRole(PAUSER_ROLE) {
_pause();
}
function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) {
_unpause();
}
}
The whenNotPaused modifier checks a single boolean storage slot. Gas overhead is approximately 2,600 gas per guarded call — a cold SLOAD (2,100 gas) plus comparison and conditional revert. On a function that already costs 50,000+ gas for a token transfer, this is a 5% overhead.
The design separates pause authority from unpause authority. Anyone with PAUSER_ROLE can stop the contract. Only DEFAULT_ADMIN_ROLE can restart it. This asymmetry is intentional — pausing is an emergency action that must be fast. Unpausing is a deliberate decision that should require higher authority.
A contract with whenNotPaused on transfer but not on approve allows an attacker to set up unlimited approvals while paused. When you unpause, they drain in a single block. Guard every function that moves or authorizes movement of value.
Granular Pause
A global pause is a blunt instrument. Pausing all functions when only mint is compromised blocks redemptions — users cannot exit their positions while you investigate. For a stablecoin, blocking redemptions during a crisis is how a bank run starts.
Granular pause uses multiple pause states instead of one boolean.
mapping(bytes32 => bool) private _functionPaused;
bytes32 public constant MINT_FUNCTION = keccak256("MINT");
bytes32 public constant BURN_FUNCTION = keccak256("BURN");
bytes32 public constant TRANSFER_FUNCTION = keccak256("TRANSFER");
modifier whenFunctionNotPaused(bytes32 functionId) {
require(!_functionPaused[functionId], "Function paused");
_;
}
function pauseFunction(bytes32 functionId)
external
onlyRole(PAUSER_ROLE)
{
_functionPaused[functionId] = true;
emit FunctionPaused(functionId, msg.sender);
}
With granular pause, you can stop minting while redemptions continue normally. The operational surface is smaller — fewer users affected, less market disruption, lower regulatory scrutiny.
| Pause scope | What stops | What continues | When to use |
|---|---|---|---|
| Global | Everything | Nothing | Active exploit across multiple functions |
| Mint only | New issuance | Burns, transfers, balance queries | Mint authority compromise, reserve discrepancy |
| Transfer only | All movement | Minting, burning, balance queries | Suspected theft, compliance freeze |
| Burn only | Redemptions | Minting, transfers | Reserve liquidity crisis, orderly wind-down |
USDC’s FiatTokenV2 uses a global pause. Circle’s operational model accepts the tradeoff: when something is wrong enough to pause, it is wrong enough to pause everything. Simpler contracts with fewer attack surfaces can afford the blunt approach. Contracts with complex multi-function interactions — a lending protocol with deposits, borrows, repayments, and liquidations — need granular control to avoid cascading failures during a partial pause.
In-Flight State Under Pause
Pausing a contract does not unwind operations already in progress. Cross-chain messages, queued withdrawals, and multi-step operations create state that must be handled explicitly.
Cross-chain messages. A LayerZero or Wormhole message sent from Chain A arrives on Chain B after the destination contract is paused. The message either reverts on delivery (losing the message unless the bridge retries) or queues in the bridge’s retry buffer. Your contract must handle both cases — implement a nonblockingLzReceive pattern that stores failed messages for manual replay after unpause, rather than reverting and relying on bridge-level retries.
Queued withdrawals. A user initiated a two-step withdrawal: step 1 locked their tokens and created a withdrawal record, step 2 (after a timelock) releases the underlying asset. If you pause between steps, the tokens are locked but unredeemable. The contract must either allow step 2 to complete despite the pause (exempting finalization from the pause scope) or provide a cancellation path that returns locked tokens.
Pending approvals. approve calls made before the pause remain active. If the pause was triggered by a compromised operator, existing approvals may be the attack vector for post-unpause exploitation. The unpause checklist must include reviewing and revoking suspicious approvals.
| In-flight state | Risk if ignored | Mitigation |
|---|---|---|
| Cross-chain messages | Lost funds in bridge limbo | Store-and-retry pattern |
| Queued withdrawals | Locked user funds | Exempt finalization or add cancel path |
| Active approvals | Post-unpause drain | Approval review before unpause |
| Pending oracle updates | Stale prices after unpause | Force oracle refresh on unpause |
Emergency Shutdown vs Pause
A pause is temporary. You stop the contract, investigate, fix the issue, and restart. An emergency shutdown is permanent — the contract enters a terminal state and begins an orderly wind-down.
MakerDAO’s Emergency Shutdown is the canonical example. When triggered, the system:
- Freezes all Vaults — no new debt can be issued.
- Calculates the target price for all collateral types at the moment of shutdown.
- Allows Vault owners to withdraw excess collateral above their debt.
- Allows DAI holders to claim a proportional share of system collateral.
- Never restarts. A new system deploys if governance decides to continue.
The shutdown process took approximately 6 hours in MakerDAO’s test exercises. During that window, all DAI holders and Vault owners must interact with the contract to claim their assets. Any unclaimed collateral remains locked until claimed — there is no expiration.
The decision between pause and shutdown depends on whether the system’s invariants can be restored.
| Condition | Action |
|---|---|
| Exploit found but no funds lost yet | Pause, patch, unpause |
| Funds drained but root cause identified and fixable | Pause, upgrade via proxy, compensate, unpause |
| Root cause unknown or unfixable without redesign | Pause, assess, likely shutdown |
| Fundamental invariant broken (e.g., more tokens than backing) | Shutdown, wind down, relaunch |
Automated Circuit Breakers
Manual pause depends on a human noticing the exploit, confirming it, and submitting a transaction. Automated circuit breakers encode detection logic on-chain.
Rate-based triggers. Track the number of mints per block or per time window. If the mint count exceeds a threshold — say, more than 10 mints in a single block when the historical average is 2 — the contract auto-pauses.
uint256 private _mintCountThisBlock;
uint256 private _lastMintBlock;
uint256 public constant MAX_MINTS_PER_BLOCK = 10;
function _beforeMint(address to, uint256 amount) internal {
if (block.number != _lastMintBlock) {
_mintCountThisBlock = 0;
_lastMintBlock = block.number;
}
_mintCountThisBlock++;
if (_mintCountThisBlock > MAX_MINTS_PER_BLOCK) {
_pause();
emit AutoPaused("RATE_LIMIT", block.number);
}
}
Value-based triggers. A single transfer exceeding $5 million or cumulative transfers exceeding $50 million in a 24-hour window trigger a pause. The thresholds reflect normal operational ranges — legitimate mints of $100 million happen, but they go through a different code path with additional authorization.
Oracle-deviation triggers. If the price feed that backs a collateralized stablecoin deviates by more than 5% from the previous update, freeze all operations that depend on price. A 5% move in a stablecoin’s underlying is either a real market event that requires human judgment or an oracle manipulation attack.
| Trigger type | Threshold example | False positive rate | Response time |
|---|---|---|---|
| Rate per block | >10 mints/block | Low (spikes are rare in payment systems) | Same block |
| Single value | >$5M single transfer | Medium (legitimate large transfers exist) | Same transaction |
| Cumulative value | >$50M in 24h window | Low with calibrated threshold | Same transaction |
| Oracle deviation | >5% price move | Medium (real volatility triggers it) | Same transaction |
Automated triggers must be tunable. A threshold set too low pauses the contract during normal operations. A threshold set too high misses the exploit. The initial calibration comes from 30-60 days of production data — measure the distribution of normal mints per block, normal transfer sizes, and normal oracle movement, then set thresholds at the 99.9th percentile.
Re-Enabling Safely
Unpausing is not the reverse of pausing. Pausing is a single action under time pressure. Unpausing is a deliberate process with a checklist.
Before calling unpause():
- Root cause identified and fixed. If the exploit was in contract logic, the fix is deployed through the upgrade mechanism. If the exploit was a compromised key, the key is rotated.
- State integrity verified. Total supply matches expected value. All account balances reconcile against known positions. No unauthorized minter or pauser roles exist.
- Pending state resolved. Cross-chain messages replayed or acknowledged as lost. Queued withdrawals either finalized or cancelled. Suspicious approvals revoked.
- Oracle feeds current. Stale prices from the pause window are refreshed. Oracle heartbeat confirmed active.
- Monitoring armed. Alerting thresholds re-calibrated. Team on standby for the first hour after unpause.
Gradual re-enablement reduces risk. Unpause read-only functions first (balance queries, position checks). Then unpause burns/redemptions — let users exit if they choose. Then unpause transfers. Unpause minting last. Each step runs for a minimum observation window — 30 minutes to an hour — before the next function resumes.
The highest-risk moment in a contract’s lifecycle is the first hour after unpausing. Attackers monitor pause/unpause events. If the fix was incomplete, the second exploit attempt arrives within blocks of the unpause transaction.
Testing Pause Scenarios
A pause mechanism you have never tested is a pause mechanism that does not work. Foundry’s cheatcodes make pause testing straightforward.
function test_pauseBlocksTransfer() public {
vm.prank(pauser);
token.pause();
vm.expectRevert("Pausable: paused");
token.transfer(alice, 100);
}
function test_pauseDoesNotBlockBalanceQuery() public {
vm.prank(pauser);
token.pause();
uint256 bal = token.balanceOf(alice);
assertEq(bal, 1000);
}
function test_inFlightWithdrawalDuringPause() public {
token.initiateWithdrawal(500);
vm.prank(pauser);
token.pause();
// Finalization must either succeed or offer cancellation
token.finalizeWithdrawal();
assertEq(token.balanceOf(address(this)), 500);
}
Test every function with the contract paused — not just the ones you expect to block. A function missing whenNotPaused is a function that remains open during an emergency. The test suite should exhaustively verify that every state-changing function reverts when paused and that every read-only function still returns correct data.
Simulate the full incident timeline: normal operations → exploit detected → pause → state inspection → fix deployed → unpause → normal operations resume. The simulation should run in a fork of mainnet state to catch interactions with external contracts (DEX routers, bridges, oracles) that behave differently when your contract is paused.