On March 2, 2026, an attacker drained approximately $239,000 from the sDOLA Llamalend Market on Ethereum. The weapon? A donate() function that let anyone inflate the vault's share price, triggering cascading liquidations. The vulnerability class — ERC-4626 donation/inflation attacks — has been known since 2022, yet protocols keep shipping vulnerable vaults.
This isn't another "here's what happened" postmortem. This is the security checklist your ERC-4626 vault needs before it touches mainnet.
The Anatomy of a Donation Attack (30-Second Version)
ERC-4626 vaults calculate shares using:
shares = (depositAmount × totalSupply) / totalAssets
Solidity uses integer division (rounds down). If an attacker can inflate totalAssets without minting new shares, the math breaks:
- Vault is empty. Attacker deposits 1 wei → gets 1 share
- Attacker donates 10,000 USDC directly to the vault contract
- Next depositor puts in 9,999 USDC →
(9999 × 1) / 10000 = 0shares - Attacker redeems their 1 share → walks away with ~20,000 USDC
The sDOLA exploit was a variation: the donate() function inflated share price, which tripped liquidation thresholds in the lending market built on top.
The Checklist: 9 Non-Negotiable Security Patterns
1. Use Virtual Offsets (Not Optional Anymore)
OpenZeppelin v5.x added virtual assets and shares to their ERC-4626 implementation. This is now the minimum viable defense.
// OpenZeppelin's approach — adds offset to denominators
function _decimalsOffset() internal pure virtual returns (uint8) {
return 3; // Adds virtual 10^3 = 1000 to calculations
}
// Effective formula becomes:
// shares = (assets × (totalSupply + 10^offset)) / (totalAssets + 1)
The offset makes inflation attacks economically impractical. To steal $1, an attacker would need to donate thousands of dollars — the math no longer works in their favor.
Action: If you're inheriting from ERC4626, override _decimalsOffset() to return at least 3. If you're rolling your own, implement equivalent logic.
2. Burn Dead Shares on First Deposit
The Uniswap V2 approach: permanently lock a small number of shares on the first deposit.
uint256 private constant MINIMUM_SHARES = 1000;
function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override {
if (totalSupply() == 0) {
// Mint dead shares to prevent manipulation
_mint(address(0xdead), MINIMUM_SHARES);
shares -= MINIMUM_SHARES;
}
super._deposit(caller, receiver, assets, shares);
}
This ensures totalSupply is never trivially small. Combined with virtual offsets, this creates defense-in-depth.
3. Harden totalAssets() Against Direct Transfers
Your totalAssets() function should not blindly return the contract's token balance.
// ❌ Vulnerable — anyone can inflate via direct transfer
function totalAssets() public view override returns (uint256) {
return asset.balanceOf(address(this));
}
// ✅ Hardened — tracks deposits explicitly
uint256 private _trackedAssets;
function totalAssets() public view override returns (uint256) {
return _trackedAssets;
}
function _deposit(...) internal override {
_trackedAssets += assets;
super._deposit(...);
}
function _withdraw(...) internal override {
_trackedAssets -= assets;
super._withdraw(...);
}
This is the single most important fix. If totalAssets() can be manipulated by direct transfers, every other defense is weakened.
4. Enforce Minimum Deposit Amounts
Prevent dust deposits that enable share-price manipulation:
uint256 public constant MIN_DEPOSIT = 1e6; // 1 USDC (6 decimals)
function deposit(uint256 assets, address receiver) public override returns (uint256) {
require(assets >= MIN_DEPOSIT, "Deposit too small");
return super.deposit(assets, receiver);
}
Calibrate MIN_DEPOSIT to your asset's decimals and expected deposit sizes.
5. Add Slippage Protection to Deposits and Withdrawals
Users should be able to specify minimum expected shares:
function depositWithSlippage(
uint256 assets,
address receiver,
uint256 minSharesOut
) external returns (uint256 shares) {
shares = deposit(assets, receiver);
require(shares >= minSharesOut, "Slippage exceeded");
}
function withdrawWithSlippage(
uint256 assets,
address receiver,
address owner,
uint256 maxSharesBurned
) external returns (uint256 shares) {
shares = withdraw(assets, receiver, owner);
require(shares <= maxSharesBurned, "Slippage exceeded");
}
This doesn't prevent the attack itself, but prevents users from losing funds to unexpected exchange rate changes.
6. Apply Reentrancy Guards on All State-Changing Functions
ERC-4626 vaults interact with external tokens. If those tokens have callbacks (ERC-777, ERC-1155, or hooks), reentrancy is possible:
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SecureVault is ERC4626, ReentrancyGuard {
function deposit(uint256 assets, address receiver)
public override nonReentrant returns (uint256) {
return super.deposit(assets, receiver);
}
function withdraw(uint256 assets, address receiver, address owner)
public override nonReentrant returns (uint256) {
return super.withdraw(assets, receiver, owner);
}
function redeem(uint256 shares, address receiver, address owner)
public override nonReentrant returns (uint256) {
return super.redeem(shares, receiver, owner);
}
}
The Solv Protocol exploit ($2.7M, March 5) used ERC-3525 reentrancy for double-minting. Don't assume your underlying asset is "simple."
7. Handle Fee-on-Transfer and Rebasing Tokens Explicitly
If your vault accepts fee-on-transfer tokens, the actual received amount differs from the input:
function deposit(uint256 assets, address receiver) public override returns (uint256) {
uint256 balanceBefore = asset.balanceOf(address(this));
SafeERC20.safeTransferFrom(asset, msg.sender, address(this), assets);
uint256 actualReceived = asset.balanceOf(address(this)) - balanceBefore;
// Use actualReceived for share calculation, not assets
uint256 shares = previewDeposit(actualReceived);
_mint(receiver, shares);
return shares;
}
For rebasing tokens (stETH, AMPL), either wrap them first (wstETH) or implement a shares-based internal accounting system that doesn't depend on balanceOf.
8. Implement Rate Limiting and Caps
Protect against flash-loan-powered attacks:
uint256 public depositCap = 1_000_000e18;
uint256 public maxDepositPerBlock = 100_000e18;
mapping(uint256 => uint256) private _blockDeposits;
function deposit(uint256 assets, address receiver) public override returns (uint256) {
require(totalAssets() + assets <= depositCap, "Vault cap reached");
_blockDeposits[block.number] += assets;
require(_blockDeposits[block.number] <= maxDepositPerBlock, "Block limit exceeded");
return super.deposit(assets, receiver);
}
Per-block limits make flash loan attacks significantly harder without impacting normal users.
9. Fuzz and Invariant Test Relentlessly
Your test suite should include these invariants:
// Foundry invariant test
function invariant_noSharesForFree() public {
assertGe(vault.totalAssets(), 0);
if (vault.totalSupply() > 0) {
assertGt(vault.totalAssets(), 0);
}
}
function invariant_redeemNeverExceedsDeposit() public {
uint256 totalRedeemable = vault.previewRedeem(vault.totalSupply());
assertLe(totalRedeemable, vault.totalAssets() + 1); // +1 for rounding
}
function invariant_depositWithdrawRoundTrip() public {
// Deposit then immediate withdraw should not be profitable
// (after accounting for rounding)
}
Run forge test --match-test invariant with at least 10,000 runs. The sDOLA vulnerability would have been caught by a simple "can external donations change share price beyond threshold" invariant.
Quick Reference: Defense-in-Depth Stack
| Layer | Defense | Stops |
|---|---|---|
| Math | Virtual offsets | Share price inflation |
| Math | Dead shares | Empty vault manipulation |
| State | Internal asset tracking | Direct transfer inflation |
| UX | Slippage protection | User fund loss |
| Guard | Reentrancy locks | Callback exploits |
| Guard | Rate limits | Flash loan attacks |
| Test | Fuzz + invariants | Unknown edge cases |
The Bottom Line
Every item on this checklist existed as known best practice before the sDOLA exploit. The protocol team either didn't know about them or chose not to implement them. In DeFi security, there's no credit for "we meant to add that."
If you're building an ERC-4626 vault today:
- Start from OpenZeppelin's v5.x implementation with
_decimalsOffset() - Track assets internally — never trust
balanceOffor pricing - Add slippage protection as a user-facing feature
- Fuzz it until your CI bill hurts
The $239K lost on March 2 was preventable. The next one doesn't have to happen.
Building secure vaults? I write about smart contract security patterns, DeFi exploit analysis, and audit tooling. Follow @ohmygod for weekly deep-dives.
Top comments (0)