← Back to all posts

5 Security Patterns Every DeFi Vault Needs

I've spent the past month auditing DeFi vaults on Cantina and Code4rena. Most of the bugs I find follow the same patterns. Here are the five security mechanisms every vault should have, and the bugs that happen when they don't.

1. Timelocked Strategy Changes

A vault owner who can instantly add a new strategy can drain every depositor in a single transaction. Propose a malicious strategy, add it with 100% allocation, rebalance, and call withdraw on the strategy to a personal address. Done in one block.

The fix is a timelock between proposal and execution:

uint256 public constant TIMELOCK_DURATION = 2 days;

struct PendingStrategy {
    address strategy;
    uint256 readyAt;
}

function proposeStrategy(address strategy) external onlyOwner {
    pendingStrategy = PendingStrategy({
        strategy: strategy,
        readyAt: block.timestamp + TIMELOCK_DURATION
    });
}

function executeAddStrategy(uint256 allocationBps) external onlyOwner {
    if (block.timestamp < pendingStrategy.readyAt)
        revert TimelockNotExpired(pendingStrategy.readyAt);
    // ... add strategy
}

The 2-day window gives depositors time to exit if they don't trust the new strategy. This is the single most important safety mechanism a vault can have.

2. Allocation Caps

Without allocation validation, an owner can set one strategy to 200% allocation and another to -100%. Or more subtly, set a single untested strategy to 100% and put all depositor funds at risk.

Enforce that total allocations never exceed 100% (10,000 basis points):

function _validateTotalAllocation(uint256 additionalBps) internal view {
    uint256 totalBps = additionalBps;
    for (uint256 i; i < strategies.length;) {
        totalBps += strategyInfo[strategies[i]].allocationBps;
        unchecked { ++i; }
    }
    if (totalBps > 10_000) revert AllocationExceedsBps(totalBps);
}

This seems obvious but I've seen it missing in production contracts. The setAllocation function is particularly dangerous because it can be called independently of addStrategy and is easy to overlook.

3. Fault-Tolerant Emergency Withdrawal

Here's a pattern I see in almost every vault audit: the emergency withdrawal function iterates over strategies and calls withdraw() on each one. If any single strategy reverts, the entire emergency withdrawal fails.

// BAD: one reverting strategy blocks all recovery
function emergencyWithdrawAll() external onlyOwner {
    for (uint256 i; i < strategies.length; i++) {
        IStrategy(strategies[i]).withdraw(
            IStrategy(strategies[i]).totalAssets()
        );
    }
}

// GOOD: catch failures and recover what you can
function emergencyWithdrawAll() external onlyOwner {
    _pause();
    uint256 totalRecovered;
    for (uint256 i; i < strategies.length;) {
        try IStrategy(strategies[i]).withdraw(
            IStrategy(strategies[i]).totalAssets()
        ) returns (uint256 recovered) {
            totalRecovered += recovered;
        } catch {}
        unchecked { ++i; }
    }
    emit EmergencyWithdrawExecuted(totalRecovered);
}

The try/catch ensures that if Strategy A is completely broken (paused, hacked, or just buggy), you can still recover funds from strategies B and C. Also notice the _pause() call: in an emergency, you want to block new deposits immediately.

4. Withdrawal-Side Strategy Pulling

ERC-4626 vaults need to handle the case where the vault's idle balance is less than the withdrawal amount. If all funds are deployed to strategies, a naive implementation will revert on redeem().

function _withdraw(
    address caller,
    address receiver,
    address owner,
    uint256 assets,
    uint256 shares
) internal override {
    uint256 idle = IERC20(asset()).balanceOf(address(this));
    if (idle < assets) {
        _pullFromStrategies(assets - idle);
    }
    super._withdraw(caller, receiver, owner, assets, shares);
}

function _pullFromStrategies(uint256 deficit) internal {
    for (uint256 i; i < strategies.length && deficit > 0;) {
        uint256 available = IStrategy(strategies[i]).totalAssets();
        uint256 toPull = deficit < available ? deficit : available;
        if (toPull > 0) {
            uint256 pulled = IStrategy(strategies[i]).withdraw(toPull);
            deficit -= pulled < deficit ? pulled : deficit;
        }
        unchecked { ++i; }
    }
}

Two things matter here: (1) pull from multiple strategies if needed, not just one, and (2) use the actual returned amount from withdraw(), not the requested amount. Strategies may return less than requested due to slippage or withdrawal fees.

5. Performance Fee Caps

Without a maximum fee, a malicious or compromised owner can set the performance fee to 100% and take all yield. But the more subtle bug is in how fees are collected.

Many vaults take fees by transferring underlying tokens to the fee recipient. This breaks the share/asset ratio for all depositors. The correct approach is minting new shares:

uint256 public constant MAX_PERFORMANCE_FEE_BPS = 2_000; // 20% cap

function _harvestStrategy(address strategy) internal {
    uint256 harvested = IStrategy(strategy).harvest();
    if (harvested > 0 && performanceFeeBps > 0) {
        uint256 fee = harvested * performanceFeeBps / 10_000;
        uint256 feeShares = convertToShares(fee);
        if (feeShares > 0) {
            _mint(feeRecipient, feeShares);
        }
    }
}

Minting shares dilutes all holders proportionally, which is the correct accounting. The fee recipient holds shares that represent their claim on the vault's assets, and everyone's share/asset ratio stays consistent.

What Auditors Actually Look For

After reviewing dozens of vault contracts, the highest-signal bugs aren't exotic reentrancy attacks. They're accounting errors:

If you're building a vault, get these five patterns right first. Everything else is optimisation.

Full implementation with 28 tests: multi-strategy-vault