Applied Patterns
Six security-flavored patterns you'll either build or review. Each is a concrete shape with a failure mode.
The periphery pattern
The most important architectural pattern in modern DeFi security. The idea: keep the audited core minimal and immutable; add functionality through periphery contracts that wrap the core but never modify it.
- Core: small, audited, formally verified, immutable. Holds the actual user funds. Few external functions. Long-lived.
- Periphery: extension contracts. Routers, vault managers, hardened entry points. Replaceable. Carries less risk because if it breaks, the core is untouched.
Examples you should know:
- Uniswap V2/V3 Router as periphery over the immutable Pair/Pool core.
- Lending protocol "bundlers" that compose deposit + borrow + swap in one tx, while the lending core remains untouched.
- Vault managers that allocate funds across multiple markets, while the markets themselves don't trust the manager.
"The right question for a new feature isn't 'how do we add this to the core?' It's 'can we deliver this in a periphery contract that doesn't expand the audited surface?' Most yes."
When it fails
If the periphery is trusted by the core — for example, the core grants the periphery an allowance or special role — the periphery's compromise IS the core's compromise. The pattern only works if the core remains adversarial-by-default toward all callers, including the periphery.
Hardened oracle wrappers
Reading prices directly from a Chainlink feed (or any source) without wrapping is a recurring incident pattern. The pattern: a small wrapper contract that enforces safety properties.
contract HardenedChainlinkOracle {
AggregatorV3Interface public immutable feed;
uint256 public immutable maxStaleness;
uint256 public immutable maxDeviationBps;
uint256 public lastPrice;
error StalePrice();
error InvalidPrice();
error PriceDeviationExceeded();
function getPrice() external returns (uint256) {
(, int256 answer,, uint256 updatedAt, ) = feed.latestRoundData();
if (answer <= 0) revert InvalidPrice();
if (block.timestamp - updatedAt > maxStaleness) revert StalePrice();
uint256 price = uint256(answer);
if (lastPrice != 0) {
uint256 diff = price > lastPrice ? price - lastPrice : lastPrice - price;
if (diff * 10000 / lastPrice > maxDeviationBps) revert PriceDeviationExceeded();
}
lastPrice = price;
return price;
}
}When it fails
- Wrapper too aggressive on deviation → permanent DoS during legitimate volatility.
- Wrapper too permissive → defeats the purpose; an attacker pushes a slow drift.
- Wrapper has its own admin → admin compromise is now the oracle compromise.
- Wrapper writes
lastPricein a way that's race-y between consumers.
Circuit breakers
The idea: if outflow exceeds a configured rate, halt. Borrowed from traditional finance ("trading halts") and adapted to on-chain. Maker, Aave, and others have variants.
contract CircuitBreaker {
uint256 public immutable maxOutflowPerEpoch;
uint256 public immutable epochLength; // e.g. 1 hours
uint256 public currentEpoch;
uint256 public outflowThisEpoch;
bool public tripped;
modifier rateLimit(uint256 amount) {
if (tripped) revert("BREAKER_TRIPPED");
uint256 epoch = block.timestamp / epochLength;
if (epoch != currentEpoch) {
currentEpoch = epoch;
outflowThisEpoch = 0;
}
outflowThisEpoch += amount;
if (outflowThisEpoch > maxOutflowPerEpoch) tripped = true;
_;
}
}When it fails
- Threshold too low → trips on legitimate large redemptions; users hate the protocol.
- Threshold too high → doesn't catch slow drains.
- Reset mechanism vulnerable to griefing or admin compromise.
- Breaker only on one path (withdraw) but attacker drains via another (e.g., liquidation).
Guardian pause patterns
A "guardian" is a privileged address — usually a multi-sig or specialized contract — with a narrow, unilateral power: pause. Unlike full governance, pausing doesn't require a vote.
contract Pausable {
address public guardian;
bool public paused;
modifier whenNotPaused() {
require(!paused, "PAUSED");
_;
}
function pause() external {
require(msg.sender == guardian, "ONLY_GUARDIAN");
paused = true;
emit Paused();
}
// Unpause requires governance (asymmetric)
function unpause() external onlyOwner {
paused = false;
emit Unpaused();
}
}The asymmetry matters. Pausing is unilateral; unpausing requires full governance. This makes guardian compromise survivable — a malicious guardian can grief by pausing, but can't unilaterally make user funds movable.
When it fails
- Pause covers some functions but not others — partial pause lets attackers exit while users can't.
- Guardian key compromise → grief campaign; protocol DoSed until governance unpauses.
- Pause modifier on view function → indexers and frontends break.
Permit2 and signature-replay hardening
EIP-2612 permit() and Uniswap's Permit2 let users sign approvals off-chain instead of submitting an approve transaction. They're standard now. The security pitfalls are well-known and recurring.
- Nonce reuse. Every signed permit must consume a unique nonce. Replay catches.
- Deadline. Signatures must have a deadline; otherwise old sigs can be replayed forever.
- Front-running permit. Anyone can submit your signed permit. If your contract requires that
permit()precedetransferFrom()in the same call, a frontrunner can call your permit out-of-band, leaving your tx to revert. - Cross-chain replay. EIP-712 domain separator must include chain ID; otherwise a sig on chain A is valid on chain B.
// Defensive wrapper: try-permit so frontrunning permit doesn't revert downstream
function depositWithPermit(uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external {
try IERC20Permit(token).permit(msg.sender, address(this), amount, deadline, v, r, s) {} catch {}
// Proceed; allowance might have already been set
token.transferFrom(msg.sender, address(this), amount);
_deposit(amount);
}Multicallable contracts and call-depth attacks
Multicall lets users batch operations: multicall([deposit(...), borrow(...), swap(...)]). Common idiom; subtle bugs.
function multicall(bytes[] calldata data) external returns (bytes[] memory results) {
results = new bytes[](data.length);
for (uint256 i = 0; i < data.length; i++) {
(bool success, bytes memory ret) = address(this).delegatecall(data[i]);
require(success, "CALL_FAIL");
results[i] = ret;
}
}When it fails
- msg.sender persistence. Within delegatecall multicall,
msg.senderis the outer caller — useful, but a footgun if any sub-function trustsaddress(this). - msg.value persistence. Each sub-call sees the same
msg.value. If sub-calls treat msg.value as transferred-this-call, the same ETH appears to be "spent" multiple times. This is a real bug class — many multicall implementations need to ban payable sub-calls. - Reentrancy via batched calls. A reentrancy guard scoped per function gets bypassed when the same lock is touched across batched delegatecalls.
- Storage write conflicts. If sub-calls assume single-tx isolation, batched calls may break that.
Defensive primitives
ReentrancyGuard (classic)
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status = _NOT_ENTERED;
modifier nonReentrant() {
require(_status == _NOT_ENTERED, "REENTRY");
_status = _ENTERED;
_;
_status = _NOT_ENTERED;
}Transient reentrancy lock (EIP-1153)
bytes32 constant LOCK = keccak256("reentrancy.lock");
modifier nonReentrantTransient() {
assembly {
if tload(LOCK) { revert(0, 0) }
tstore(LOCK, 1)
}
_;
assembly { tstore(LOCK, 0) }
}Transient storage is cleared at end-of-tx, so no SLOAD/SSTORE cost. Available post-Cancun.
Checks-Effects-Interactions (CEI)
Order operations:
- Checks. All input validation and state preconditions first.
- Effects. All state changes second.
- Interactions. External calls last.
Following CEI eliminates classic reentrancy without a guard. Doesn't help with cross-function reentrancy if a different function modifies the state you depend on.
Pull-don't-push
Instead of pushing tokens/ETH to a recipient (and risking their fallback), credit them on-chain and let them pull later.
mapping(address => uint256) public pending;
// instead of: payable(to).transfer(amount);
function _credit(address to, uint256 amount) internal {
pending[to] += amount;
}
function claim() external nonReentrant {
uint256 amount = pending[msg.sender];
pending[msg.sender] = 0;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "TRANSFER_FAIL");
}Two-step admin transfer
address public owner;
address public pendingOwner;
function transferOwnership(address newOwner) external onlyOwner {
pendingOwner = newOwner;
}
function acceptOwnership() external {
require(msg.sender == pendingOwner, "NOT_PENDING");
owner = pendingOwner;
pendingOwner = address(0);
}One-step ownership transfer to a typo'd address bricks the contract. Two-step requires the new owner to accept.
SafeERC20
USDT and other non-standard ERC-20s don't return a bool on transfer(). Old contracts wrongly rely on the bool. Use OpenZeppelin's SafeERC20.safeTransfer or equivalent — it checks for either revert or correct bool, and works across token variants.
Quick reference
When to add a guardian pause
Any time fund movements exceed a threshold per block/per hour. Any time external dependencies (oracles, bridges) could fail. The pause is cheaper than building every defensive check inline.
When to add a circuit breaker
When the protocol allows large outflows and you can express a "normal" outflow envelope. Lending markets, vaults, bridges — yes. AMMs — usually no (they're zero-sum within a swap).
When NOT to use multicall
When any sub-call is payable. When any sub-call depends on a per-function reentrancy guard. When the protocol relies on msg.sender == tx.origin-style heuristics (which you shouldn't anyway).