3.7.5 Defensive Patterns
The patterns in this section share a different philosophy from the prior four. Control flow patterns (3.7.1), storage patterns (3.7.2), access patterns (3.7.3), and interaction patterns (3.7.4) all aim to prevent vulnerabilities from existing in the first place. Defensive patterns assume that something will eventually go wrong — a bug, an oracle anomaly, a compromised key, an unforeseen interaction with another protocol — and constrain the damage when it does.
The three patterns here form a layered defense:
- Circuit Breakers / Pause halt the contract when something is detected to be wrong, buying time for human response.
- Rate Limiting caps the maximum damage per time window even when no human notices.
- Withdrawal Patterns structure value-leaving operations to be safely interruptible and recoverable.
These patterns interact with each other and with the patterns from prior sections. None is a complete defense on its own. Together they form the safety architecture that turns "exploit drains protocol entirely" into "exploit drains 0.5% of TVL before the bot pauses it." Several major incidents — including some where vulnerabilities had been deployed for months — were limited to small losses purely because defensive patterns activated before the attacker could finish.
The trade-off is universal: every defensive pattern adds centralization, complexity, or both. A perfectly decentralized, perfectly autonomous contract has no pause function and no rate limit. Real protocols make pragmatic compromises. This section presents the patterns; deciding where on the trust-vs-safety spectrum a given protocol should sit is a separate design question.
Circuit Breakers and Pause Mechanisms
A pause mechanism is a contract-level kill switch. A privileged role can disable some or all contract operations, preventing further state changes until the pause is lifted. The pattern is the simplest defensive primitive and the most widely deployed.
Idiomatic Form
OpenZeppelin's Pausable contract provides the standard implementation:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";
contract Protocol is AccessControl, Pausable {
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
constructor(address admin, address pauser) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(PAUSER_ROLE, pauser);
}
function deposit() external payable whenNotPaused {
// ... deposit logic
}
function withdraw(uint256 amount) external whenNotPaused {
// ... withdraw logic
}
function pause() external onlyRole(PAUSER_ROLE) {
_pause();
}
function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) {
_unpause();
}
}
The whenNotPaused modifier blocks function execution when the contract is paused. The _pause() and _unpause() internal functions toggle the state and emit Paused(address) / Unpaused(address) events for off-chain monitoring.
Three deliberate design choices in this implementation are worth examining:
Pause and unpause have different access controls. PAUSER_ROLE can pause but cannot unpause. DEFAULT_ADMIN_ROLE (typically a slower, more secure multi-sig or timelock) can unpause. This asymmetry matters: pausing is a defensive action that should be fast and have low coordination cost; unpausing is a return-to-normal action that should be deliberate and require broader consensus. A monitoring bot with one key can pause; resuming operations requires the full admin process.
Pause does not stop everything. Note that unpause is itself not gated by whenNotPaused — otherwise the contract could be paused into a state with no exit. The same logic applies to recovery functions: a rescueTokens or emergencyWithdraw function should typically remain callable even when the contract is paused.
Reads are not paused. view and pure functions don't have whenNotPaused because pausing is about preventing state changes, not blocking observation. Downstream protocols that depend on reading state from this contract continue to work.
Selective Pausing
Sometimes the right response to an incident is to pause one function while leaving others operational. A lending protocol detecting an oracle issue might want to pause borrowing while still allowing repayments. A token contract under attack might want to pause transfers but not the ability for users to claim airdropped tokens.
contract SelectivelyPausable is AccessControl {
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
mapping(bytes4 => bool) public functionPaused;
error FunctionPaused(bytes4 selector);
modifier whenFunctionNotPaused() {
if (functionPaused[msg.sig]) revert FunctionPaused(msg.sig);
_;
}
function pauseFunction(bytes4 selector) external onlyRole(PAUSER_ROLE) {
functionPaused[selector] = true;
}
function unpauseFunction(bytes4 selector) external onlyRole(DEFAULT_ADMIN_ROLE) {
functionPaused[selector] = false;
}
function deposit() external payable whenFunctionNotPaused {
// ...
}
function withdraw(uint256 amount) external whenFunctionNotPaused {
// ...
}
}
This pattern uses msg.sig (the function selector) as a key into a per-function pause registry. Each function can be paused or unpaused independently. The trade-off is operational complexity — incident response now has to identify which functions to pause rather than flipping one switch — and the risk of asymmetric pauses creating inconsistent state (e.g., pausing deposits while leaving rewards distribution active, which can drain the reward pool with no new deposits).
Pause-Triggering Conditions
In production, pauses are typically triggered by one of three mechanisms:
-
Monitoring bot — an off-chain service watches for anomalies (large outflows, abnormal price movements, oracle deviations) and pauses programmatically. The
PAUSER_ROLEis held by a hot wallet controlled by the bot. The classic example is a TVL-drop trigger: if the contract's balance decreases by more than X% in a Y-block window, pause. -
Human operator — a designated incident responder can manually pause when notified of an issue. Slower than a bot but covers attack patterns the bot doesn't recognize.
-
On-chain trigger — the contract pauses itself when an invariant is violated. The Forta Protocol popularized this with their "Protect" agents, which embed monitoring logic into the contract directly.
contract SelfPausingProtocol is Pausable {
uint256 public lastTvl;
uint256 public constant PAUSE_THRESHOLD = 1000; // 10%
function _checkTvlSanity() internal {
uint256 currentTvl = address(this).balance;
if (currentTvl < lastTvl) {
uint256 drop = ((lastTvl - currentTvl) * 10000) / lastTvl;
if (drop >= PAUSE_THRESHOLD) {
_pause();
}
}
lastTvl = currentTvl;
}
function withdraw(uint256 amount) external whenNotPaused {
// ... withdrawal logic
_checkTvlSanity();
}
}
Self-pausing has the advantage of zero response latency — the pause activates in the same block as the attack. The disadvantage is that the trigger logic itself is part of the attack surface; if the attacker can manipulate lastTvl (by making prior deposits, for instance), they can avoid tripping the threshold.
When Pauses Are Wrong
A pause mechanism is centralizing by definition — someone has the power to halt the protocol. Some protocols deliberately omit pause functions to claim credible neutrality. The trade-off is real and not always one-sided:
- A genuinely immutable, unpausable contract cannot be censored, frozen, or selectively disabled. Users have a guarantee that the contract will continue functioning regardless of legal pressure, business decisions, or operator key compromise.
- The same contract cannot be saved when a critical bug is found. Funds go to the attacker, full stop.
For protocols at any meaningful TVL, the consensus in the security community has shifted firmly toward including a pause mechanism with appropriate access controls. The credible-neutrality argument loses its weight when applied to a protocol that has already been compromised — at that point, "we couldn't stop the attack" is a feature only the attacker appreciates.
Rate Limiting
A rate limit caps the maximum value that can flow through a contract in a defined time window. Unlike a pause (which is binary and requires triggering), a rate limit is always active and limits damage automatically.
The Nomad bridge hack of August 2022 is the canonical case for rate limiting. An initialization bug made every transaction valid; hundreds of users copy-pasted the exploit transaction with their own addresses. The protocol lost ~$190 million before anyone could pause it. With a rate limit of, say, $5 million per hour, the same vulnerability would have lost $5 million instead of $190 million — still bad, but recoverable.
Idiomatic Form
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract RateLimitedBridge {
uint256 public immutable limit; // max outflow per window
uint256 public immutable window; // window length in seconds
uint256 public currentWindowStart;
uint256 public currentWindowOutflow;
error RateLimitExceeded(uint256 attempted, uint256 available);
constructor(uint256 _limit, uint256 _window) {
limit = _limit;
window = _window;
currentWindowStart = block.timestamp;
}
function withdraw(uint256 amount) external {
_consumeRateLimit(amount);
// ... actual withdrawal logic
}
function _consumeRateLimit(uint256 amount) internal {
// Roll the window if elapsed
if (block.timestamp >= currentWindowStart + window) {
currentWindowStart = block.timestamp;
currentWindowOutflow = 0;
}
if (currentWindowOutflow + amount > limit) {
revert RateLimitExceeded(amount, limit - currentWindowOutflow);
}
currentWindowOutflow += amount;
}
}
This is a fixed-window rate limit: each window is a discrete time bucket; once full, no further withdrawals until the window rolls over. Two refinements are common in practice:
Sliding Window
A fixed-window rate limit has a sharp edge: an attacker can drain limit at the very end of one window and another limit at the very start of the next, doubling the effective per-window outflow. A sliding window smooths this:
contract SlidingWindowRateLimit {
uint256 public immutable limit;
uint256 public immutable window;
struct Outflow { uint256 timestamp; uint256 amount; }
Outflow[] private outflows;
function _consumeRateLimit(uint256 amount) internal {
uint256 cutoff = block.timestamp - window;
uint256 windowTotal = 0;
uint256 firstValid = outflows.length;
// Sum outflows still within the window, finding the oldest valid index
for (uint256 i = outflows.length; i > 0; --i) {
if (outflows[i - 1].timestamp < cutoff) {
firstValid = i;
break;
}
windowTotal += outflows[i - 1].amount;
if (i == 1) firstValid = 0;
}
require(windowTotal + amount <= limit, "rate limit");
// Optionally compact the array by truncating expired entries
outflows.push(Outflow({ timestamp: block.timestamp, amount: amount }));
}
}
The trade-off is gas: a sliding window is O(n) over the number of recent outflows. For high-frequency contracts, the gas cost can be substantial. Most production protocols use fixed windows with short durations (5-15 minutes) to approximate sliding behavior, accepting the edge-case doubling as a known limitation.
Per-User Rate Limiting
Rate limits can be applied per user as well as protocol-wide. A protocol might allow each individual user to withdraw at most userLimit per day while also limiting the total protocol outflow to globalLimit per hour. The two layers protect against different attack profiles:
- Global limit caps total damage from any exploit, regardless of how many addresses the attacker controls
- Per-user limit caps damage from any compromised user account or social-engineering victim
mapping(address => uint256) public userWindowOutflow;
mapping(address => uint256) public userWindowStart;
uint256 public immutable userLimit;
uint256 public immutable userWindow;
function _consumeUserRateLimit(address user, uint256 amount) internal {
if (block.timestamp >= userWindowStart[user] + userWindow) {
userWindowStart[user] = block.timestamp;
userWindowOutflow[user] = 0;
}
require(userWindowOutflow[user] + amount <= userLimit, "user rate limit");
userWindowOutflow[user] += amount;
}
Setting Rate Limit Values
A rate limit that's too high doesn't constrain damage; too low and legitimate users hit it constantly. The practical heuristics:
- Look at recent normal traffic patterns. Set the limit to 3-5x the largest legitimate spike observed in the prior six months.
- Consider the time-to-respond. If your team can respond to an incident within an hour, set the window to roughly an hour. The rate limit should hold long enough for human response.
- For protocols with very predictable flows (e.g., a streaming payment contract), the limit can be much tighter. For protocols with bursty flows (e.g., a DEX during volatility), the limit must accommodate the burst.
- Make limits adjustable through governance, not immutable. Markets evolve; rigid limits become wrong over time.
Limitations
Rate limits do not stop slow attackers. An exploit that drains $5 million per hour eventually drains the protocol if no one notices. Rate limits are a time-buying mechanism, paired with monitoring and pause as the human-response loop. The intent is to slow the attack to the point where the pause can engage before catastrophic loss.
Rate limits also create user-experience tension. If a large legitimate user hits the limit, their funds are temporarily inaccessible. Communicating this — and providing a path to expedite legitimate cases through governance — is part of the operational design.
Withdrawal Patterns
The withdrawal pattern structures value-leaving operations to be safely interruptible, recoverable, and resistant to common failure modes. Three sub-patterns matter:
Pull-Based Withdrawals
Already introduced in Section 3.7.1 as a control flow pattern. From the defensive angle: pull-based withdrawals isolate each user's withdrawal into its own transaction, so any failure affects only that user. A push-based protocol that pays out 100 users in a loop has the single-user-revert DoS we covered in 3.7.1; a pull-based protocol allows 99 users to withdraw successfully while one user's withdrawal is broken.
Withdrawal Delays
For protocols holding significant value, an instant withdrawal is sometimes too instant. A withdrawal delay gives the protocol time to detect and respond to an attack before the funds leave. The user requests a withdrawal in one transaction, waits for the delay period to pass, and completes the withdrawal in a second transaction.
contract DelayedWithdrawal {
uint256 public immutable withdrawDelay;
struct PendingWithdrawal {
uint256 amount;
uint256 unlockTime;
}
mapping(address => PendingWithdrawal) public pending;
error WithdrawalNotReady();
error NoPendingWithdrawal();
constructor(uint256 _delay) {
withdrawDelay = _delay;
}
function requestWithdrawal(uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient");
balances[msg.sender] -= amount; // debit immediately
pending[msg.sender] = PendingWithdrawal({
amount: pending[msg.sender].amount + amount,
unlockTime: block.timestamp + withdrawDelay
});
}
function completeWithdrawal() external {
PendingWithdrawal memory p = pending[msg.sender];
if (p.amount == 0) revert NoPendingWithdrawal();
if (block.timestamp < p.unlockTime) revert WithdrawalNotReady();
delete pending[msg.sender];
(bool ok, ) = msg.sender.call{value: p.amount}("");
require(ok);
}
function cancelWithdrawal() external {
PendingWithdrawal memory p = pending[msg.sender];
if (p.amount == 0) revert NoPendingWithdrawal();
balances[msg.sender] += p.amount;
delete pending[msg.sender];
}
}
Several design choices in this implementation:
Balance debited at request, not at completion. This prevents a user from double-spending: requesting a withdrawal of their full balance, then trading or transferring the balance elsewhere before the delay expires. The debit happens upfront; the delay only governs when the funds physically leave.
unlockTime is reset on each request. A user who requests one withdrawal at t=0 and another at t=1000 gets a single pending withdrawal with the later unlock time. Without this, attackers could exploit the pattern by chaining many tiny withdrawals to circumvent the delay.
A cancel function exists. If the user changes their mind during the delay, they can cancel and restore their balance. This is humane and operationally important, but it does mean the protocol cannot be sure pending withdrawals will actually leave — useful for capital planning.
The delay applies to outflows only. Deposits remain instant. Withdrawal delays only solve part of the problem — an attacker who acquires a position via deposit and immediately tries to withdraw must wait, but an attacker who exploits a logic bug to mint themselves a position directly may or may not be affected, depending on where the mint happens.
The lidos and synthetix protocols both use withdrawal delays measured in days (typically 7-21). This is appropriate for protocols where users have strong reasons to plan ahead (staking rewards, debt positions). For DEX or payment-rail protocols where instant withdrawal is core to the UX, delays are inappropriate; rate limits are the right alternative.
Withdrawal Limits Per Position
For protocols where users hold long-lived positions (lending, staking), capping per-position withdrawal velocity catches a different class of issue than global rate limiting:
mapping(address => uint256) public lastWithdrawalTime;
uint256 public constant POSITION_WITHDRAWAL_COOLDOWN = 1 hours;
function withdraw(uint256 amount) external {
require(block.timestamp >= lastWithdrawalTime[msg.sender] + POSITION_WITHDRAWAL_COOLDOWN, "cooldown");
lastWithdrawalTime[msg.sender] = block.timestamp;
// ... rest of withdraw
}
This is a simple cooldown — once a user withdraws, they can't withdraw again for an hour. Useful for protocols where flash-loan-driven exploits depend on repeated withdrawals in the same transaction or block. The pattern is brittle for users with legitimate need to make multiple withdrawals; pair it with thoughtful UX (e.g., a single "withdraw all" function that consolidates) to avoid frustrating users.
Composing Defensive Patterns
The realistic shape of defensive patterns in a production protocol uses all three together:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract DefendedProtocol is AccessControl, Pausable, ReentrancyGuard {
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
bytes32 public constant LIMITER_ROLE = keccak256("LIMITER_ROLE");
uint256 public globalLimit; // configurable
uint256 public immutable globalWindow = 1 hours;
uint256 public currentWindowStart;
uint256 public currentWindowOutflow;
uint256 public immutable withdrawDelay = 1 days;
struct PendingWithdrawal { uint256 amount; uint256 unlockTime; }
mapping(address => PendingWithdrawal) public pending;
mapping(address => uint256) public balances;
error RateLimitExceeded();
constructor(address admin, address pauser, uint256 _initialLimit) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(PAUSER_ROLE, pauser);
_grantRole(LIMITER_ROLE, admin);
globalLimit = _initialLimit;
currentWindowStart = block.timestamp;
}
function deposit() external payable whenNotPaused {
balances[msg.sender] += msg.value;
}
function requestWithdrawal(uint256 amount) external whenNotPaused nonReentrant {
require(balances[msg.sender] >= amount, "insufficient");
_checkAndConsumeRateLimit(amount);
balances[msg.sender] -= amount;
pending[msg.sender] = PendingWithdrawal({
amount: pending[msg.sender].amount + amount,
unlockTime: block.timestamp + withdrawDelay
});
}
function completeWithdrawal() external nonReentrant {
// Note: completeWithdrawal does NOT have whenNotPaused —
// pending withdrawals already passed the rate limit and delay.
// Blocking them on pause would lock user funds indefinitely.
PendingWithdrawal memory p = pending[msg.sender];
require(p.amount > 0 && block.timestamp >= p.unlockTime);
delete pending[msg.sender];
(bool ok, ) = msg.sender.call{value: p.amount}("");
require(ok);
}
function pause() external onlyRole(PAUSER_ROLE) { _pause(); }
function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) { _unpause(); }
function setGlobalLimit(uint256 newLimit) external onlyRole(LIMITER_ROLE) {
globalLimit = newLimit;
}
function _checkAndConsumeRateLimit(uint256 amount) internal {
if (block.timestamp >= currentWindowStart + globalWindow) {
currentWindowStart = block.timestamp;
currentWindowOutflow = 0;
}
if (currentWindowOutflow + amount > globalLimit) revert RateLimitExceeded();
currentWindowOutflow += amount;
}
}
The defensive layering creates the following attack-resistance profile:
| Attack | Defense |
|---|---|
| Bug discovered after deployment | Pauser bot can halt new requests within seconds |
| Slow drain that bot doesn't notice | Rate limit caps hourly outflow |
| Fast drain via flash loan or one-shot exploit | Withdrawal delay holds funds for 24 hours after request |
| Compromised pauser key activates pause unjustly | Pending withdrawals still complete; only new operations halt |
The completeWithdrawal function deliberately omits whenNotPaused. This is a critical design choice. If a malicious pauser could prevent users from completing withdrawals they had already requested (and which had already passed the rate limit and delay), the pause mechanism becomes a censorship tool. Allowing pending withdrawals to complete preserves user safety even against operator misuse.
Foundry Test for Defensive Composition
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/DefendedProtocol.sol";
contract DefendedProtocolTest is Test {
DefendedProtocol protocol;
address admin = makeAddr("admin");
address pauser = makeAddr("pauser");
address alice = makeAddr("alice");
function setUp() public {
protocol = new DefendedProtocol(admin, pauser, 10 ether);
vm.deal(alice, 100 ether);
}
function test_rateLimitBlocksLargeWithdrawal() public {
vm.startPrank(alice);
protocol.deposit{value: 100 ether}();
protocol.requestWithdrawal(10 ether); // fills the window
vm.expectRevert(DefendedProtocol.RateLimitExceeded.selector);
protocol.requestWithdrawal(1 ether);
vm.stopPrank();
}
function test_pauseBlocksNewRequestsButNotPendingCompletions() public {
vm.startPrank(alice);
protocol.deposit{value: 5 ether}();
protocol.requestWithdrawal(5 ether);
vm.stopPrank();
// Admin pauses for an unrelated reason
vm.prank(pauser);
protocol.pause();
// Time passes
vm.warp(block.timestamp + 1 days + 1);
// Pending withdrawal still completes despite pause
vm.prank(alice);
protocol.completeWithdrawal();
assertEq(alice.balance, 100 ether);
// But new requests are blocked
vm.deal(alice, 5 ether);
vm.startPrank(alice);
protocol.deposit{value: 5 ether}(); // also blocked? Let's check.
}
function test_rateLimitRollsOver() public {
vm.startPrank(alice);
protocol.deposit{value: 100 ether}();
protocol.requestWithdrawal(10 ether);
// Advance past the window
vm.warp(block.timestamp + 1 hours + 1);
// Should now succeed
protocol.requestWithdrawal(10 ether);
vm.stopPrank();
}
}
The third test (test_pauseBlocksNewRequestsButNotPendingCompletions) is the most valuable in the suite — it asserts the user-safety property of the pause design. If the design ever changes to block pending completions, this test fails immediately, surfacing a regression that could otherwise turn a defensive feature into a censorship vector.
Quick Reference
| Pattern | Cost | Defends against | Limitation |
|---|---|---|---|
| Pause (whole-contract) | One SLOAD per protected function | Any active exploit, given human response time | Requires trusted role; binary on/off |
| Selective pause | One SLOAD per protected function plus mapping | Same as above with finer granularity | Operational complexity; asymmetric pauses can create bad states |
| Self-pausing | Variable per check | Fast exploits with characteristic signatures | Trigger logic is itself an attack surface |
| Fixed-window rate limit | Two SLOADs + one SSTORE per outflow | Bulk damage from any cause | Window-boundary doubling; tuning is operationally hard |
| Sliding-window rate limit | O(n) over recent outflows | Same, more smoothly | Gas cost can be material |
| Per-user rate limit | One mapping per user | Compromise of individual users; sybil-resistant when paired with global | Doesn't help against attacks across many addresses |
| Pull-based withdrawal | Already in 3.7.1 | DoS via reverting recipient | UX cost of two transactions |
| Withdrawal delay | Two transactions per withdrawal | Fast exploits, key compromise | UX cost of delay; not appropriate for all protocols |
Cross-References
- Pull-over-Push — Section 3.7.1 covers the withdrawal pattern as a control flow primitive
- Access control for pause/unpause — Section 3.7.3 covers the role hierarchy patterns (pauser bot vs admin multi-sig)
- State machines — Section 3.7.2 covers state-based patterns; a paused/unpaused contract is a minimal state machine
- DoS attacks — Section 4.11.7 covers DoS in the auditor's framing; rate limits and circuit breakers are the developer-side defenses
- Incident response — Section 2.9 covers the operational side of pause-and-fix workflows
- Real exploits — Section 3.10.6 (Nomad) shows the cost of missing rate limits; Section 3.10.5 (Ronin) shows the cost of no withdrawal caps; Section 3.10.7 (Wormhole) was substantially mitigated by Jump Crypto's reimbursement rather than by in-protocol pause mechanisms