Section B · Technical Core

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.
Interview line

"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 lastPrice in 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() precede transferFrom() 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.sender is the outer caller — useful, but a footgun if any sub-function trusts address(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:

  1. Checks. All input validation and state preconditions first.
  2. Effects. All state changes second.
  3. 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).