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:
- Stale
totalAssets()— If your total assets calculation doesn't include pending yield from strategies, share prices will be wrong. Every deposit/withdrawal compounds the error. - First depositor inflation attack — ERC-4626's known issue where the first depositor can manipulate the share price. OpenZeppelin's implementation includes virtual offset protection.
- Strategy withdrawal return value ignored — If you request 100 USDC from a strategy but only get 99 (due to fees), and you don't track this, 1 USDC disappears from your accounting every withdrawal.
- Missing reentrancy guards on external calls — Strategies are external contracts. If one of them calls back into the vault during a
withdraw(), and your vault's state hasn't been updated yet, you have a reentrancy bug.
If you're building a vault, get these five patterns right first. Everything else is optimisation.
Full implementation with 28 tests: multi-strategy-vault