Part 2 β Module 7: Vaults & Yield
Difficulty: Intermediate
Estimated reading time: ~35 minutes | Exercises: ~3-4 hours
π Table of Contents
ERC-4626 β The Tokenized Vault Standard
The Inflation Attack and Defenses
- The Attack
- Quick Try: Inflation Attack in Foundry
- Defense 1: Virtual Shares and Assets
- Defense 2: Dead Shares
- Defense 3: Internal Accounting
- When Vaults Are Used as Collateral
Yield Aggregation β Yearn V3 Architecture
- The Yield Aggregation Problem
- Yearn V3: The Allocator Vault Pattern
- Allocator Vault Mechanics
- The Curator Model
- Read: Yearn V3 Source
- Job Market: Yield Aggregation
Composable Yield Patterns and Security
- Yield Strategy Comparison
- Pattern 1: Auto-Compounding
- Pattern 2: Leveraged Yield
- Deep Dive: Leveraged Yield Numeric Walkthrough
- Pattern 3: LP + Staking
- Security Considerations for Vault Builders
π‘ ERC-4626 β The Tokenized Vault Standard
Every protocol in DeFi that holds user funds and distributes yield faces the same core problem: how do you track each userβs share of a pool that changes in size as deposits, withdrawals, and yield accrual happen simultaneously?
The answer is vault share accounting β the same shares/assets math that underpins Aaveβs aTokens, Compoundβs cTokens, Uniswap LP tokens, Yearn vault tokens, and MakerDAOβs DSR Pot. ERC-4626 standardized this pattern into a universal interface, and itβs now the foundation of the modular DeFi stack.
Understanding ERC-4626 deeply β the math, the interface, the security pitfalls β gives you the building block for virtually any DeFi protocol. Yield aggregators like Yearn compose these vaults into multi-strategy systems, and the emerging βcuratorβ model (Morpho, Euler V2) uses ERC-4626 vaults as the fundamental unit of risk management.
π‘ Concept: The Core Abstraction
An ERC-4626 vault is an ERC-20 token that represents proportional ownership of a pool of underlying assets. The two key quantities:
- Assets: The underlying ERC-20 token (e.g., USDC, WETH)
- Shares: The vaultβs ERC-20 token, representing a claim on a portion of the assets
The exchange rate = totalAssets() / totalSupply(). As yield accrues (totalAssets increases while totalSupply stays constant), each share becomes worth more assets. This is the βrebasing without rebasingβ pattern β your share balance doesnβt change, but each shareβs value increases.
π» Quick Try:
Read a live ERC-4626 vault on a mainnet fork. This script reads Yearnβs USDC vault to see the share math in action:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Script.sol";
interface IERC4626 {
function asset() external view returns (address);
function totalAssets() external view returns (uint256);
function totalSupply() external view returns (uint256);
function convertToShares(uint256 assets) external view returns (uint256);
function convertToAssets(uint256 shares) external view returns (uint256);
function decimals() external view returns (uint8);
}
contract ReadVault is Script {
function run() external view {
// Yearn V3 USDC vault on mainnet
IERC4626 vault = IERC4626(0xBe53A109B494E5c9f97b9Cd39Fe969BE68f2166c);
uint256 totalAssets = vault.totalAssets();
uint256 totalSupply = vault.totalSupply();
uint8 decimals = vault.decimals();
console.log("=== Yearn V3 USDC Vault ===");
console.log("Total Assets:", totalAssets);
console.log("Total Supply:", totalSupply);
console.log("Decimals:", decimals);
// What's 1000 USDC worth in shares?
uint256 sharesFor1000 = vault.convertToShares(1000 * 10**6);
console.log("Shares for 1000 USDC:", sharesFor1000);
// What's 1000 shares worth in assets?
uint256 assetsFor1000 = vault.convertToAssets(1000 * 10**6);
console.log("Assets for 1000 shares:", assetsFor1000);
// Exchange rate: if shares < assets, the vault has earned yield
if (totalSupply > 0) {
console.log("Rate (assets/share):", totalAssets * 1e18 / totalSupply);
}
}
}
Run with: forge script ReadVault --rpc-url https://eth.llamarpc.com
Notice the exchange rate is > 1.0 β thatβs accumulated yield. Each share is worth more than 1 USDC because the vaultβs strategies have earned profit since launch.
π The Interface
ERC-4626 extends ERC-20 with these core functions:
Informational:
asset()β the underlying token addresstotalAssets()β total underlying assets the vault holds/controlsconvertToShares(assets)β how many shares wouldassetsamount produceconvertToAssets(shares)β how many assets dosharesredeem for
Deposit flow (assets β shares):
maxDeposit(receiver)β max assets the receiver can depositpreviewDeposit(assets)β exact shares that would be minted forassets(rounds down)deposit(assets, receiver)β deposits exactlyassets, mints shares toreceivermaxMint(receiver)β max shares the receiver can mintpreviewMint(shares)β exact assets needed to mintshares(rounds up)mint(shares, receiver)β mints exactlyshares, pulls required assets
Withdraw flow (shares β assets):
maxWithdraw(owner)β max assetsownercan withdrawpreviewWithdraw(assets)β exact shares that would be burned forassets(rounds up)withdraw(assets, receiver, owner)β withdraws exactlyassets, burns shares fromownermaxRedeem(owner)β max sharesownercan redeempreviewRedeem(shares)β exact assets that would be returned forshares(rounds down)redeem(shares, receiver, owner)β redeems exactlyshares, sends assets toreceiver
Critical rounding rules: The standard mandates that conversions always round in favor of the vault (against the user). This means:
- Depositing/minting: user gets fewer shares (rounds down) or pays more assets (rounds up)
- Withdrawing/redeeming: user gets fewer assets (rounds down) or burns more shares (rounds up)
This ensures the vault can never be drained by rounding exploits.
π‘ Concept: The Share Math
shares = assets Γ totalSupply / totalAssets (for deposits β rounds down)
assets = shares Γ totalAssets / totalSupply (for redemptions β rounds down)
When the vault is empty (totalSupply == 0), the first depositor typically gets shares at a 1:1 ratio with assets (implementation-dependent).
As yield accrues, totalAssets increases while totalSupply stays constant, so the assets-per-share ratio grows. Example:
Initial: 1000 USDC deposited β 1000 shares minted
totalAssets = 1000, totalSupply = 1000, rate = 1.0
Yield: Vault earns 100 USDC from strategy
totalAssets = 1100, totalSupply = 1000, rate = 1.1
Redeem: User redeems 500 shares β 500 Γ 1100/1000 = 550 USDC
π Deep Dive: Share Math β Multi-Deposit Walkthrough
Letβs trace a vault through multiple deposits, yield events, and withdrawals to build intuition for how shares track proportional ownership.
Setup: Empty USDC vault, no virtual shares (for clarity).
Step 1: Alice deposits 1,000 USDC
βββββββββββββββββββββββββββββββββββββββββββββββββ
shares_alice = 1000 Γ 0 / 0 β first deposit, 1:1 ratio
shares_alice = 1,000
State: totalAssets = 1,000 | totalSupply = 1,000 | rate = 1.000
ββββββββββββββββββββββββββββββββββββββββββββββββ
β Alice: 1,000 shares (100% of vault) β
β Vault holds: 1,000 USDC β
ββββββββββββββββββββββββββββββββββββββββββββββββ
Step 2: Bob deposits 2,000 USDC
βββββββββββββββββββββββββββββββββββββββββββββββββ
shares_bob = 2000 Γ 1000 / 1000 = 2,000
State: totalAssets = 3,000 | totalSupply = 3,000 | rate = 1.000
ββββββββββββββββββββββββββββββββββββββββββββββββ
β Alice: 1,000 shares (33.3%) β
β Bob: 2,000 shares (66.7%) β
β Vault holds: 3,000 USDC β
ββββββββββββββββββββββββββββββββββββββββββββββββ
Step 3: Vault earns 300 USDC yield (strategy profits)
βββββββββββββββββββββββββββββββββββββββββββββββββ
No shares minted β totalAssets increases, totalSupply unchanged
State: totalAssets = 3,300 | totalSupply = 3,000 | rate = 1.100
ββββββββββββββββββββββββββββββββββββββββββββββββ
β Alice: 1,000 shares β 1,000 Γ 1.1 = 1,100 USDC β
β Bob: 2,000 shares β 2,000 Γ 1.1 = 2,200 USDC β
β Vault holds: 3,300 USDC β
β Yield distributed proportionally β β
ββββββββββββββββββββββββββββββββββββββββββββββββ
Step 4: Carol deposits 1,100 USDC (after yield)
βββββββββββββββββββββββββββββββββββββββββββββββββ
shares_carol = 1100 Γ 3000 / 3300 = 1,000
Carol gets 1,000 shares β same as Alice, but she deposited
1,100 USDC (not 1,000). She's buying in at the higher rate.
State: totalAssets = 4,400 | totalSupply = 4,000 | rate = 1.100
ββββββββββββββββββββββββββββββββββββββββββββββββ
β Alice: 1,000 shares (25%) β 1,100 USDC β
β Bob: 2,000 shares (50%) β 2,200 USDC β
β Carol: 1,000 shares (25%) β 1,100 USDC β
β Vault holds: 4,400 USDC β
ββββββββββββββββββββββββββββββββββββββββββββββββ
Step 5: Alice withdraws everything
βββββββββββββββββββββββββββββββββββββββββββββββββ
assets_alice = 1000 Γ 4400 / 4000 = 1,100 USDC β
Alice deposited 1,000, gets back 1,100 β earned 100 USDC (10%)
State: totalAssets = 3,300 | totalSupply = 3,000 | rate = 1.100
ββββββββββββββββββββββββββββββββββββββββββββββββ
β Bob: 2,000 shares (66.7%) β 2,200 USDC β
β Carol: 1,000 shares (33.3%) β 1,100 USDC β
β Vault holds: 3,300 USDC β
β Rate unchanged after withdrawal β β
ββββββββββββββββββββββββββββββββββββββββββββββββ
Key observations:
- Shares track proportional ownership, not absolute amounts
- Yield accrual increases the rate without minting shares β existing holders benefit automatically
- Late depositors (Carol) buy at the current rate β they donβt capture past yield
- Withdrawals donβt change the exchange rate for remaining holders
- This is exactly how aTokens, cTokens, and LP tokens work under the hood
Rounding in practice: The example above used numbers that divide evenly, but real values rarely do. If Carol deposited 1,099 USDC instead, sheβd get 1099 Γ 3000 / 3300 = 999.09... which rounds down to 999 shares β slightly fewer than the βfairβ amount. This rounding loss is typically negligible (< 1 wei of the underlying), but it accumulates vault-favorably β the vault slowly builds a tiny surplus that protects against rounding-based exploits.
π Read: OpenZeppelin ERC4626.sol
Source: @openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol
Focus on:
- The
_decimalsOffset()virtual function and its role in inflation attack mitigation - How
_convertToSharesand_convertToAssetsadd virtual shares/assets:shares = assets Γ (totalSupply + 10^offset) / (totalAssets + 1) - The rounding direction in each conversion
- How
deposit,mint,withdraw, andredeemall route through_depositand_withdraw
Also compare with Solmateβs implementation (solmate/src/tokens/ERC4626.sol) which is more gas-efficient but less defensive.
π How to Study OpenZeppelin ERC4626.sol
-
Read the conversion functions first β
_convertToShares()and_convertToAssets()are the mathematical core. Notice the+ 10 ** _decimalsOffset()and+ 1terms β these are the virtual shares/assets that defend against the inflation attack. Understand why rounding direction differs between deposit (rounds down = fewer shares for user) and withdraw (rounds up = more shares burned from user). -
Trace a
deposit()call end-to-end β Follow:deposit()βpreviewDeposit()β_convertToShares()β_deposit()βSafeERC20.safeTransferFrom()+_mint(). Map which function handles the math vs the token movement vs the event emission. -
Compare
deposit()vsmint()β Both result in shares being minted, but they specify different inputs.deposit(assets)says βI want to deposit exactly X assets, give me however many shares.βmint(shares)says βI want exactly X shares, pull however many assets needed.β The rounding direction flips between them. Draw a table showing the rounding for all four operations (deposit, mint, withdraw, redeem). -
Read
maxDeposit(),maxMint(),maxWithdraw(),maxRedeem()β These are often overlooked but critical for integration. A vault that returns0formaxDepositsignals itβs paused or full. Protocols integrating your vault MUST check these before attempting operations. -
Compare with Solmateβs ERC4626 β Solmateβs version skips virtual shares (no
_decimalsOffset). This is more gas-efficient but vulnerable to the inflation attack without additional protection. Understanding this trade-off is interview-relevant.
Donβt get stuck on: The _decimalsOffset() virtual function mechanics. Just know: default is 0 (no virtual offset), override to 3 or 6 for inflation protection. The higher the offset, the more expensive the attack becomes, but the more precision you lose for tiny deposits.
π― Build Exercise: Simple Vault
Workspace: workspace/src/part2/module7/exercise1-simple-vault/ β starter file: SimpleVault.sol, tests: SimpleVault.t.sol
Implement a minimal ERC-4626 vault from scratch (no OpenZeppelin ERC4626 or Solmate). Youβll implement 4 functions: _convertToShares, _convertToAssets, deposit, and withdraw. The pre-built wrappers (mint, redeem, all preview/convert/max functions) route through your conversion functions, so once your TODOs work, everything works.
Tests verify:
- First deposit mints shares 1:1
- After yield accrues (donation), new deposits get fewer shares at the correct rate
mintpulls the correct amount of assets (usesCeilrounding)withdrawburns the correct shares (usesCeilrounding)redeemreturns assets including earned yield- Rounding always favors the vault (deposit rounds down, withdraw rounds up)
- Full multi-user cycle matches the curriculum walkthrough (Alice, Bob, yield, Carol, withdrawal)
πΌ Job Market Context
What DeFi teams expect you to know about ERC-4626:
-
βExplain the rounding rules in ERC-4626 and why they matter.β
- Good answer: βConversions round in favor of the vault β fewer shares on deposit, fewer assets on withdrawal β so the vault canβt be drained.β
- Great answer: βThe spec mandates
depositrounds shares down,mintrounds assets up,withdrawrounds shares up,redeemrounds assets down. This creates a tiny vault-favorable spread on every operation. Itβs the same principle as a bankβs bid/ask spread β the vault always wins the rounding.β
-
βHow does ERC-4626 differ from Compound cTokens or Aave aTokens?β
- Good answer: βERC-4626 standardizes the interface. cTokens use an exchange rate, aTokens rebase β both do the same thing differently.β
- Great answer: βcTokens store
exchangeRateand you multiply by your balance. aTokens rebase your balance directly using ascaledBalance Γ liquidityIndexpattern. ERC-4626 abstracts both approaches behindconvertToShares/convertToAssetsβ any protocol can implement the interface however they want. The key win is composability: any ERC-4626 vault works as a strategy in Yearn, as collateral in Morpho, etc.β
-
βWhatβs the first thing you check when auditing a new ERC-4626 vault?β
- Good answer: βI check for the inflation attack β whether the vault uses virtual shares.β
- Great answer: βI check three things: (1) how
totalAssets()is computed β if it readsbalanceOf(address(this))itβs vulnerable to donation attacks; (2) whether thereβs inflation protection (virtual shares or dead shares); (3) whetherpreviewfunctions match actualdeposit/withdrawbehavior, since broken preview functions break all integrators.β
Interview Red Flags:
- β Not knowing what ERC-4626 is (itβs the foundation of modern DeFi infrastructure)
- β Confusing shares and assets (which direction does the conversion go?)
- β Not knowing about the inflation attack and its defenses
Pro tip: The ERC-4626 ecosystem is one of the fastest-growing in DeFi. Morpho, Euler V2, Yearn V3, Ethena (sUSDe), Lido (wstETH adapter), and hundreds of other protocols all use it. Being able to write, audit, and integrate ERC-4626 vaults is a high-demand skill.
π Summary: ERC-4626 β The Tokenized Vault Standard
β Covered:
- The shares/assets abstraction and why itβs the universal pattern for yield-bearing tokens
- ERC-4626 interface β all 16 functions across deposit, mint, withdraw, redeem flows
- Rounding rules: always in favor of the vault (against the user)
- Share math with multi-deposit walkthrough (Alice β Bob β yield β Carol β withdrawal)
- OpenZeppelin vs Solmate implementation trade-offs
Key insight: ERC-4626 is the same math pattern youβve seen in Aave aTokens, Compound cTokens, and Uniswap LP tokens β standardized into a universal interface. Master the share math once, apply it everywhere.
Next: The inflation attack β why empty vaults are dangerous and three defense strategies.
β οΈ The Inflation Attack and Defenses
β οΈ The Attack
The inflation attack (also called the donation attack or first-depositor attack) exploits empty or nearly-empty vaults:
Step 1: Attacker deposits 1 wei of assets, receives 1 share.
Step 2: Attacker donates a large amount (e.g., 10,000 USDC) directly to the vault contract via transfer() (not through deposit()).
Step 3: Now totalAssets = 10,000,000,001 (including the 1 wei), totalSupply = 1. The exchange rate is extremely high.
Step 4: Victim deposits 20,000 USDC. Shares received = 20,000 Γ 1 / 10,000.000001 = 1 share (rounded down from ~2).
Step 5: Attacker and victim each hold 1 share. Attacker redeems for ~15,000 USDC. Attacker profit: ~5,000 USDC stolen from the victim.
The attack works because the large donation inflates the exchange rate, and the subsequent deposit rounds down to give the victim far fewer shares than their deposit warrants.
Run this Foundry test to see the inflation attack in action. It deploys a naive vault and executes all 4 steps:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockUSDC is ERC20 {
constructor() ERC20("USDC", "USDC") {
_mint(msg.sender, 1_000_000e6);
}
function decimals() public pure override returns (uint8) { return 6; }
}
/// @notice Naive vault β no virtual shares, totalAssets = balanceOf
contract NaiveVault is ERC20 {
IERC20 public immutable asset;
constructor(IERC20 _asset) ERC20("Vault", "vUSDC") { asset = _asset; }
function totalAssets() public view returns (uint256) {
return asset.balanceOf(address(this)); // β THE VULNERABILITY
}
function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
uint256 supply = totalSupply();
shares = supply == 0 ? assets : (assets * supply) / totalAssets();
asset.transferFrom(msg.sender, address(this), assets);
_mint(receiver, shares);
}
function redeem(uint256 shares, address receiver) external returns (uint256 assets) {
assets = (shares * totalAssets()) / totalSupply();
_burn(msg.sender, shares);
asset.transfer(receiver, assets);
}
}
contract InflationAttackTest is Test {
MockUSDC usdc;
NaiveVault vault;
address attacker = makeAddr("attacker");
address victim = makeAddr("victim");
function setUp() public {
usdc = new MockUSDC();
vault = new NaiveVault(usdc);
usdc.transfer(attacker, 30_000e6);
usdc.transfer(victim, 20_000e6);
}
function test_inflationAttack() public {
// Step 1: Attacker deposits 1 wei
vm.startPrank(attacker);
usdc.approve(address(vault), type(uint256).max);
vault.deposit(1, attacker);
// Step 2: Attacker donates 10,000 USDC directly
usdc.transfer(address(vault), 10_000e6);
vm.stopPrank();
// Step 3: Victim deposits 20,000 USDC
vm.startPrank(victim);
usdc.approve(address(vault), type(uint256).max);
vault.deposit(20_000e6, victim);
vm.stopPrank();
// Check: victim got only 1 share (should have ~2,000)
assertEq(vault.balanceOf(victim), 1, "Victim got robbed β only 1 share");
// Step 4: Attacker redeems
vm.prank(attacker);
uint256 attackerReceived = vault.redeem(1, attacker);
console.log("Attacker spent: 10,000 USDC");
console.log("Attacker received:", attackerReceived / 1e6, "USDC");
console.log("Victim deposited: 20,000 USDC");
console.log("Victim can redeem:", vault.totalAssets(), "USDC (in vault)");
}
}
Run with forge test --match-test test_inflationAttack -vv. Watch the attacker steal ~5,000 USDC from the victim in 4 steps.
π Deep Dive: Inflation Attack Step-by-Step
NAIVE VAULT (no virtual shares, totalAssets = balanceOf)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Step 1: Attacker deposits 1 wei
βββββββββββββββββββββββββββββββββ
totalAssets = 1 totalSupply = 1
shares_attacker = 1 rate = 1.0
βββββββββββββββββββββββββββββββββββββββ
β Vault: 1 wei β
β Attacker: 1 share (100%) β
βββββββββββββββββββββββββββββββββββββββ
Step 2: Attacker DONATES 10,000 USDC via transfer()
βββββββββββββββββββββββββββββββββββββββββββββββββββββ
balanceOf(vault) = 10,000,000,001 (10k USDC + 1 wei)
totalAssets = 10,000,000,001 totalSupply = 1
rate = 10,000,000,001 per share β INFLATED!
βββββββββββββββββββββββββββββββββββββββ
β Vault: 10,000.000001 USDC β
β Attacker: 1 share (100%) β
β Attacker cost so far: ~10,000 USDCβ
βββββββββββββββββββββββββββββββββββββββ
Step 3: Victim deposits 20,000 USDC
βββββββββββββββββββββββββββββββββββββ
shares = 20,000,000,000 Γ 1 / 10,000,000,001
= 1.999...
= 1 (rounded DOWN β vault-favorable)
totalAssets = 30,000,000,001 totalSupply = 2
βββββββββββββββββββββββββββββββββββββββ
β Vault: 30,000.000001 USDC β
β Attacker: 1 share (50%) β
β Victim: 1 share (50%) β WRONG! β
β Victim deposited 2Γ but gets 50% β
βββββββββββββββββββββββββββββββββββββββ
Step 4: Attacker redeems 1 share
βββββββββββββββββββββββββββββββββ
assets = 1 Γ 30,000,000,001 / 2 = 15,000 USDC
βββββββββββββββββββββββββββββββββββββββββββββββ
β Attacker spent: 10,000 USDC (donation) β
β + 0 USDC (1 wei deposit)β
β Attacker received: 15,000 USDC β
β Attacker PROFIT: 5,000 USDC β
β β
β Victim deposited: 20,000 USDC β
β Victim can redeem: 15,000 USDC β
β Victim LOSS: 5,000 USDC β
βββββββββββββββββββββββββββββββββββββββββββββββ
WITH VIRTUAL SHARES (OpenZeppelin, offset = 3)
βββββββββββββββββββββββββββββββββββββββββββββββ
Same attack, but conversion uses virtual shares/assets:
shares = assets Γ (totalSupply + 1000) / (totalAssets + 1)
After donation (Step 2):
totalAssets = 10,000,000,001 totalSupply = 1
Victim deposits 20,000 USDC (Step 3):
shares = 20,000,000,000 Γ (1 + 1000) / (10,000,000,001 + 1)
= 20,000,000,000 Γ 1001 / 10,000,000,002
= 2,001 β victim gets ~2000 shares!
Attacker has 1 share, victim has 2,001 shares. totalSupply = 2,002.
totalAssets = 30,000,000,001 (10k donation + 20k deposit + 1 wei)
Attacker redeems 1 share (conversion also uses virtual shares/assets):
assets = 1 Γ (30,000,000,001 + 1) / (2,002 + 1000)
= 30,000,000,002 / 3,002
= 9,993,338 β ~$10 USDC
Attacker LOSS: ~$9,990 USDC β Attack is UNPROFITABLE
Why virtual shares work: The 1000 virtual shares in the denominator mean the attackerβs donation is spread across 1001 shares (1 real + 1000 virtual), not just 1. The attacker canβt monopolize the inflated rate.
β οΈ Why It Still Matters
This isnβt theoretical. The Resupply protocol was exploited via this vector in 2025, and the Venus Protocol lost approximately 86 WETH to a similar attack on ZKsync in February 2025. Any protocol using ERC-4626 vaults as collateral in a lending market is at risk if the vaultβs exchange rate can be manipulated.
π‘οΈ Defense 1: Virtual Shares and Assets (OpenZeppelin approach)
OpenZeppelinβs ERC4626 (since v4.9) adds a configurable decimal offset that creates βvirtualβ shares and assets:
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view returns (uint256) {
return assets.mulDiv(
totalSupply() + 10 ** _decimalsOffset(), // virtual shares
totalAssets() + 1, // virtual assets
rounding
);
}
With _decimalsOffset() = 3, there are always at least 1000 virtual shares and 1 virtual asset in the denominator. This means even an empty vault behaves as if it already has deposits, making donation attacks unprofitable because the attackerβs donation is diluted across the virtual shares.
The trade-off: virtual shares capture a tiny fraction of all yield (the virtual shares βearnβ yield that belongs to no one). This is negligible in practice.
π‘οΈ Defense 2: Dead Shares (Uniswap V2 approach)
On the first deposit, permanently lock a small amount of shares (e.g., mint shares to address(0) or address(1)). This ensures totalSupply is never trivially small.
function _deposit(uint256 assets, address receiver) internal {
if (totalSupply() == 0) {
uint256 deadShares = 1000;
_mint(address(1), deadShares);
_mint(receiver, _convertToShares(assets) - deadShares);
} else {
_mint(receiver, _convertToShares(assets));
}
}
This is simpler but slightly punishes the first depositor (they lose the value of the dead shares).
π‘οΈ Defense 3: Internal Accounting (Aave V3 approach)
Donβt use balanceOf(address(this)) for totalAssets(). Instead, track deposits and withdrawals internally. Direct token transfers (donations) donβt affect the vaultβs accounting.
uint256 private _totalManagedAssets;
function totalAssets() public view returns (uint256) {
return _totalManagedAssets; // NOT asset.balanceOf(address(this))
}
function _deposit(uint256 assets, address receiver) internal {
_totalManagedAssets += assets;
// ...
}
This is the most robust defense but requires careful bookkeeping β you must update _totalManagedAssets correctly for every flow (deposits, withdrawals, yield harvest, losses).
β οΈ When Vaults Are Used as Collateral
The inflation attack becomes especially dangerous when ERC-4626 tokens are used as collateral in lending protocols. If a lending protocol prices collateral using vault.convertToAssets(shares), an attacker can:
- Inflate the vaultβs exchange rate via donation
- Deposit vault shares as collateral (now overvalued)
- Borrow against the inflated collateral value
- The exchange rate normalizes (or the attacker redeems), leaving the lending protocol with bad debt
Defense: lending protocols should use time-weighted or externally-sourced exchange rates for ERC-4626 collateral, not the vaultβs own convertToAssets() at a single point in time.
π― Build Exercise: Inflation Attack Defense
Workspace: workspace/src/part2/module7/exercise2-inflation-attack/ β starter files: NaiveVault.sol, DefendedVault.sol, tests: InflationAttack.t.sol
The pre-built NaiveVault.sol demonstrates the inflation attack β no student code needed there, just study it. Your task is to implement DefendedVault.sol: a vault that uses OpenZeppelinβs virtual shares defense. Youβll implement 2 functions: _convertToShares and _convertToAssets, both using the (totalSupply + virtualShareOffset) / (totalAssets + 1) formula.
Tests verify:
- NaiveVault attack succeeds: attacker profits ~5,000 USDC from a 10,000 USDC donation
- DefendedVault attack fails: same attack leaves attacker with ~6 USDC (massive loss)
- DefendedVault works correctly for normal operations: deposit, yield accrual, redeem all function properly with negligible virtual share loss (1 raw unit)
π Summary: The Inflation Attack and Defenses
β Covered:
- The inflation (donation/first-depositor) attack β step-by-step mechanics
- Real exploits: Resupply (2025), Venus Protocol on ZKsync (2025)
- Defense 1: Virtual shares and assets (OpenZeppelinβs
_decimalsOffset) - Defense 2: Dead shares (Uniswap V2 approach β lock minimum liquidity)
- Defense 3: Internal accounting (track
_totalManagedAssetsinstead ofbalanceOf) - Collateral pricing risk when ERC-4626 tokens are used in lending markets
Key insight: The inflation attack is the #1 ERC-4626 security concern. Virtual shares (OpenZeppelin) are the most common defense, but internal accounting is the most robust. Never use raw balanceOf(address(this)) for critical pricing in any vault.
Next: Yield aggregation architecture β how Yearn V3 composes ERC-4626 vaults into multi-strategy systems.
π‘ Yield Aggregation β Yearn V3 Architecture
π‘ Concept: The Yield Aggregation Problem
A single yield source (e.g., supplying USDC on Aave) gives you one return. But there are dozens of yield sources for USDC: Aave, Compound, Morpho, Curve pools, Balancer pools, DSR, etc. Each has different risk, return, and capacity. A yield aggregatorβs job is to:
- Accept deposits in a single asset
- Allocate those deposits across multiple yield sources (strategies)
- Rebalance as conditions change
- Handle deposits/withdrawals seamlessly
- Account for profits and losses correctly
π‘ Concept: Yearn V3: The Allocator Vault Pattern
Yearn V3 redesigned their vault system around ERC-4626 composability:
Allocator Vault β An ERC-4626 vault that doesnβt generate yield itself. Instead, it holds an ordered list of strategies and allocates its assets among them. Users deposit into the Allocator Vault and receive vault shares. The vault manages the allocation.
Tokenized Strategy β An ERC-4626 vault that generates yield from a single external source. Strategies are stand-alone β they can receive deposits directly from users or from Allocator Vaults. Each strategy inherits from BaseStrategy and overrides three functions:
// Required overrides:
function _deployFunds(uint256 _amount) internal virtual;
// Deploy assets into the yield source
function _freeFunds(uint256 _amount) internal virtual;
// Withdraw assets from the yield source
function _harvestAndReport() internal virtual returns (uint256 _totalAssets);
// Harvest rewards, report total assets under management
The delegation pattern: TokenizedStrategy is a pre-deployed implementation contract. Your strategy contract delegates all ERC-4626, accounting, and reporting logic to it via delegateCall in the fallback function. You only write the three yield-specific functions above.
π§ Allocator Vault Mechanics
Adding strategies: The vault manager calls vault.add_strategy(strategy_address). Each strategy gets a max_debt parameter β the maximum the vault will allocate to that strategy.
Debt allocation: The DEBT_MANAGER role calls vault.update_debt(strategy, target_debt) to move funds. The vault tracks currentDebt per strategy. When allocating, the vault calls strategy.deposit(). When deallocating, it calls strategy.withdraw().
Reporting: When vault.process_report(strategy) is called:
- The vault calls
strategy.convertToAssets(strategy.balanceOf(vault))to get current value - Compares to
currentDebtto determine profit or loss - If profit: records gain, charges fees (via Accountant contract), mints fee shares
- If loss: reduces strategy debt, reduces overall vault value
Profit unlocking: Profits arenβt immediately available to withdrawers. They unlock linearly over a configurable profitMaxUnlockTime period. This prevents sandwich attacks where someone deposits right before a harvest and withdraws right after, capturing yield they didnβt contribute to.
π Deep Dive: Profit Unlocking β Numeric Walkthrough
Why does profit unlocking matter? Without it, an attacker can sandwich the harvest() call to steal yield. Letβs trace both scenarios.
SCENARIO A: NO PROFIT UNLOCKING (vulnerable)
βββββββββββββββββββββββββββββββββββββββββββββ
Setup: Vault has 100,000 USDC, 100,000 shares, rate = 1.0
Strategy earned 10,000 USDC profit (not yet reported)
Timeline:
T=0 Attacker sees harvest() in mempool
Attacker deposits 100,000 USDC β gets 100,000 shares
State: totalAssets = 200,000 | totalSupply = 200,000
T=1 harvest() executes, reports 10,000 profit
State: totalAssets = 210,000 | totalSupply = 200,000
Rate: 1.05 per share
T=2 Attacker redeems 100,000 shares
Receives: 100,000 Γ 210,000 / 200,000 = 105,000 USDC
Attacker PROFIT: 5,000 USDC (in ONE block!)
Legitimate depositors earned 5,000 USDC instead of 10,000.
Attacker captured 50% of the yield by holding for 1 block.
SCENARIO B: WITH PROFIT UNLOCKING (profitMaxUnlockTime = 6 hours)
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Setup: Same β 100,000 USDC, 100,000 shares, 10,000 profit pending
Timeline:
T=0 Attacker deposits 100,000 USDC β gets 100,000 shares
State: totalAssets = 200,000 | totalSupply = 200,000
T=1 harvest() executes, reports 10,000 profit
But profit is LOCKED β it unlocks linearly over 6 hours.
Immediately available: 0 USDC of profit
State: totalAssets = 200,000 (profit not yet in totalAssets)
totalSupply = 200,000
Rate: still 1.0
T=2 Attacker redeems immediately
Receives: 100,000 Γ 200,000 / 200,000 = 100,000 USDC
Attacker PROFIT: 0 USDC β sandwich FAILED
After 1 hour: 1,667 USDC unlocked (10,000 / 6)
After 3 hours: 5,000 USDC unlocked
After 6 hours: 10,000 USDC fully unlocked β rate = 1.10
Only depositors who stayed the full 6 hours earn the yield.
How the unlock works mechanically: Yearn V3 tracks fullProfitUnlockDate and profitUnlockingRate. The vaultβs totalAssets() includes only the portion of profit that has unlocked so far: unlockedProfit = profitUnlockingRate Γ (block.timestamp - lastReport). This smooths the share price increase over the unlock period.
The trade-off: Longer unlock times are more sandwich-resistant but delay yield recognition for legitimate depositors. Most vaults use 6-24 hours as a balance.
π‘ Concept: The Curator Model
The broader trend in DeFi (2024-25) extends Yearnβs pattern: protocols like Morpho and Euler V2 allow third-party βcuratorsβ to deploy ERC-4626 vaults that allocate to their underlying lending markets. Curators set risk parameters, choose which markets to allocate to, and earn management/performance fees. Users choose a curator based on risk appetite and track record.
This separates infrastructure (the lending protocol) from risk management (the curatorβs vault), creating a modular stack:
- Layer 1: Base lending protocol (Morpho Blue, Euler V2, Aave)
- Layer 2: Curator vaults (ERC-4626) that allocate across Layer 1 markets
- Layer 3: Meta-vaults that allocate across curator vaults
Each layer uses ERC-4626, so they compose naturally.
π Read: Yearn V3 Source
VaultV3.sol: yearn/yearn-vaults-v3
- Focus on
process_report()β how profit/loss is calculated and fees charged - The withdrawal queue β how the vault pulls funds from strategies when a user withdraws
- The
profitMaxUnlockTimemechanism
TokenizedStrategy: yearn/tokenized-strategy
- The
BaseStrategyabstract contract β the three functions you override - How
report()triggers_harvestAndReport()and handles accounting
Morpho MetaMorpho Vault: morpho-org/metamorpho β A production curator vault built on Morpho Blue. Compare with Yearn V3: both are ERC-4626 allocator vaults, but MetaMorpho allocates across Morpho Blue lending markets while Yearn allocates across arbitrary strategies.
π How to Study Yearn V3 Architecture
-
Start with a strategy, not the vault β Read a simple strategy implementation first (Yearn publishes example strategies). Find the three overrides:
_deployFunds(),_freeFunds(),_harvestAndReport(). These are typically 10-30 lines each. Understanding what a strategy does grounds the rest of the architecture. -
Read the TokenizedStrategy delegation pattern β Your strategy contract doesnβt implement ERC-4626 directly. It delegates to a pre-deployed
TokenizedStrategyimplementation viadelegateCallin the fallback function. This means all the accounting, reporting, and ERC-4626 compliance lives in one shared contract. Focus on: how doesreport()call your_harvestAndReport()and then update the strategyβs total assets? -
Read VaultV3βs
process_report()β This is the core allocator vault function. Trace: how it callsstrategy.convertToAssets()to get current value, compares tocurrentDebtto compute profit/loss, charges fees via the Accountant, and handles profit unlocking. TheprofitMaxUnlockTimemechanism is the key anti-sandwich defense. -
Study the withdrawal queue β When a user withdraws from the allocator vault and idle balance is insufficient, the vault pulls from strategies in queue order. Read how
_withdraw()iterates through strategies, callsstrategy.withdraw(), and handles partial fills. This is where withdrawal liquidity risk manifests. -
Map the role system β Yearn V3 uses granular roles:
ROLE_MANAGER,DEBT_MANAGER,REPORTING_MANAGER, etc. Understanding who can call what clarifies the trust model: vault managers control allocation, reporting managers trigger harvests, and the role manager controls access.
Donβt get stuck on: The Vyper syntax in VaultV3 (Yearn V3 vaults are written in Vyper, not Solidity). The logic maps directly to Solidity concepts β @external = external, @view = view, self.variable = this.variable. Focus on the architecture, not the syntax.
πΌ Job Market Context
What DeFi teams expect you to know about yield aggregation:
-
βHow would you design a multi-strategy vault from scratch?β
- Good answer: βAn ERC-4626 vault that holds a list of strategies, allocates debt to each, and pulls from them in order on withdrawal.β
- Great answer: βIβd follow the allocator pattern: the vault is an ERC-4626 shell with an ordered strategy queue. Each strategy is also ERC-4626 for composability. Key design decisions: (1) debt management β who sets target allocations and how often; (2) withdrawal queue priority β which strategies to pull from first (idle β lowest-yield β most-liquid); (3) profit accounting β harvest reports go through a
process_report()that separates profit from fees and unlocks profit linearly to prevent sandwich attacks; (4) loss handling β reduce share price proportionally rather than reverting.β
-
βWhatβs the difference between Yearn V3 and MetaMorpho?β
- Good answer: βBoth are ERC-4626 allocator vaults, but Yearn allocates across arbitrary strategies while MetaMorpho allocates across Morpho Blue lending markets.β
- Great answer: βThe key difference is the strategy universe: Yearn strategies can do anything (LP, leverage, restaking), so the vault manager has more flexibility but more risk surface. MetaMorpho is constrained to Morpho Blue markets β the curator picks which markets to allocate to and sets caps, but all the underlying lending logic is in Morpho Blue itself. This constraint makes MetaMorpho easier to reason about and audit. The trend is toward this modular stack: protocol layer (Morpho Blue) handles mechanics, curator layer (MetaMorpho) handles risk allocation.β
-
βHow do you prevent a vault manager from rugging depositors?β
- Good answer: βUse a timelock on strategy changes and cap allocations per strategy.β
- Great answer: βDefense in depth: (1) granular role system β separate who can add strategies vs who can allocate debt vs who can trigger reports; (2) strategy allowlists with timelocked additions β depositors see new strategies before funds flow; (3) per-strategy max debt caps to limit blast radius; (4) depositor-side
max_lossparameter on withdrawal β revert if the vault is trying to return less than expected; (5) the Yearn V3 approach of requiring strategy contracts to be pre-audited and whitelisted.β
Interview Red Flags:
- β Thinking vault managers have unrestricted access to user funds (they shouldnβt β debt limits and roles constrain them)
- β Not understanding profit unlocking (the #1 sandwich defense for yield vaults)
- β Confusing Yearn V2 and V3 architecture (V3βs ERC-4626-native design is fundamentally different)
Pro tip: The curator/vault-as-a-service model is the fastest-growing DeFi architectural pattern in 2025. Being able to articulate the trade-offs between Yearn V3 (flexible strategies, higher risk surface) vs MetaMorpho (constrained to lending, easier to audit) vs Euler V2 (modular with custom vault logic) signals you understand the current state of DeFi infrastructure.
π― Build Exercise: Simple Allocator
Workspace: workspace/src/part2/module7/exercise3-simple-allocator/ β starter files: SimpleAllocator.sol, MockStrategy.sol, tests: SimpleAllocator.t.sol
Build a simplified Yearn V3-style allocator vault. Youβll implement 4 functions: totalAssets (multi-source accounting across idle + strategies), allocate (deploy idle funds to a strategy), deallocate (return funds from a strategy to idle), and redeem (withdrawal queue that pulls from idle first, then strategies in order).
The pre-built MockStrategy.sol is a minimal deposit/withdraw wrapper β yield is simulated in tests by minting tokens directly to the strategy contract.
Tests verify:
- Deposit 10,000 into allocator, all funds sit idle initially
- Allocate 5,000 to Strategy A, 3,000 to Strategy B, keep 2,000 idle β totalAssets unchanged
- Allocation reverts if exceeding idle balance
- Deallocate returns funds from strategy to idle, debt tracking updates correctly
- After yield accrues in strategies, totalAssets reflects the increased value automatically
- Redeem 8,000 shares: withdrawal queue drains idle first, then Strategy A, then partial Strategy B
- Debt tracks original allocation (not yield) β profit = strategy.totalValue() - debt
π Summary: Yield Aggregation β Yearn V3 Architecture
β Covered:
- The yield aggregation problem β why allocating across multiple sources matters
- Yearn V3 Allocator Vault pattern β vault holds strategies, not yield sources directly
- TokenizedStrategy delegation pattern β your strategy delegates ERC-4626 logic to a shared implementation
- The three overrides:
_deployFunds(),_freeFunds(),_harvestAndReport() - Debt allocation mechanics:
max_debt,update_debt(),process_report() - Profit unlocking as anti-sandwich defense (
profitMaxUnlockTime) - The Curator model (Morpho, Euler V2) β modular risk management layers
Key insight: The allocator vault pattern separates yield generation (strategies) from risk management (the vault). ERC-4626 composability means each layer can plug into the next β this is how modern DeFi infrastructure is being built.
Next: Composable yield patterns (auto-compounding, leveraged yield, LP staking) and critical security considerations for vault builders.
π‘ Composable Yield Patterns and Security
π Yield Strategy Comparison
| Strategy | Typical APY | Risk Level | Complexity | Key Risk | Example |
|---|---|---|---|---|---|
| Single lending | 2-8% | Low | Low | Protocol hack, bad debt | Aave USDC supply |
| Auto-compound | 4-12% | Low-Med | Medium | Swap slippage, keeper costs | Yearn Aave strategy |
| Leveraged yield | 8-25% | Medium-High | High | Liquidation, rate inversion | Recursive borrowing on Aave |
| LP + staking | 10-40% | High | High | Impermanent loss, reward token dump | Curve/Convex USDC-USDT |
| Vault-of-vaults | 5-15% | Medium | Very High | Cascading losses, liquidity fragmentation | Yearn allocator across strategies |
| Delta-neutral | 5-20% | Medium | Very High | Funding rate reversal, basis risk | Ethena USDe (spot + short perp) |
APY ranges are illustrative and vary significantly with market conditions. Higher APY = higher risk.
π‘ Concept: Pattern 1: Auto-Compounding
Many yield sources distribute rewards in a separate token (e.g., COMP tokens from Compound, CRV from Curve). Auto-compounding sells these reward tokens for the underlying asset and re-deposits:
1. Deposit USDC into Compound β earn COMP rewards
2. Harvest: claim COMP, swap COMP β USDC on Uniswap
3. Deposit the additional USDC back into Compound
4. totalAssets increases β share price increases
Build consideration: The harvest transaction pays gas and incurs swap slippage. Only economical when accumulated rewards exceed costs. Most vaults use keeper bots that call harvest based on profitability calculations.
π‘ Concept: Pattern 2: Leveraged Yield (Recursive Borrowing)
Combine lending with borrowing to amplify yield:
1. Deposit 1000 USDC as collateral on Aave β earn supply APY
2. Borrow 800 USDC against collateral β pay borrow APY
3. Re-deposit the 800 USDC β earn supply APY on it too
4. Repeat until desired leverage is reached
Net yield = (Supply APY Γ leverage) - (Borrow APY Γ (leverage - 1))
Only profitable when supply APY + incentives > borrow APY, which is common when protocols distribute governance token rewards. The flash loan strategies from Module 5 make this achievable in a single transaction.
Risk: Liquidation if collateral value drops. The strategy must manage health factor carefully and deleverage automatically if it approaches the liquidation threshold.
π Deep Dive: Leveraged Yield β Numeric Walkthrough
SETUP
βββββ
Aave USDC market:
Supply APY: 3.0%
Borrow APY: 4.5%
AAVE incentive (supply + borrow): +2.0% effective
Max LTV: 80%
Starting capital: 10,000 USDC
LOOP-BY-LOOP RECURSIVE BORROWING
βββββββββββββββββββββββββββββββββ
Loop 0: Deposit 10,000 USDC
Collateral: 10,000 | Debt: 0 | Effective exposure: 10,000
Loop 1: Borrow 80% β 8,000 USDC, re-deposit
Collateral: 18,000 | Debt: 8,000 | Exposure: 18,000
Loop 2: Borrow 80% of new 8,000 β 6,400 USDC, re-deposit
Collateral: 24,400 | Debt: 14,400 | Exposure: 24,400
Loop 3: Borrow 80% of 6,400 β 5,120 USDC, re-deposit
Collateral: 29,520 | Debt: 19,520 | Exposure: 29,520
... converges to:
Loop β: Collateral: 50,000 | Debt: 40,000 | Leverage: 5Γ
(Geometric series: 10,000 / (1 - 0.8) = 50,000)
In practice, 3 loops gets you ~3Γ leverage. Flash loans skip looping
entirely β borrow the full target amount in one tx (see Module 5).
APY CALCULATION AT 3Γ LEVERAGE (3 loops β 29,520 exposure)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Supply yield: 29,520 Γ 3.0% = +$885.60
Borrow cost: 19,520 Γ 4.5% = -$878.40
AAVE incentive: 29,520 Γ 2.0% = +$590.40 (on total exposure)
βββββββββββββββββββββββββββββββββββββββββββββ
Net profit: $597.60
On 10,000 capital β 5.98% APY (vs 5.0% unleveraged: 3% + 2%)
WHEN IT GOES WRONG β RATE INVERSION
ββββββββββββββββββββββββββββββββββββ
Market heats up. Borrow APY spikes to 8%, incentives drop to 0.5%:
Supply yield: 29,520 Γ 3.0% = +$885.60
Borrow cost: 19,520 Γ 8.0% = -$1,561.60
AAVE incentive: 29,520 Γ 0.5% = +$147.60
βββββββββββββββββββββββββββββββββββββββββββββ
Net profit: -$528.40 β LOSING MONEY
The strategy must monitor rates and deleverage automatically when
net yield turns negative. Good strategies check this on every harvest().
HEALTH FACTOR CHECK
βββββββββββββββββββ
At 3Γ leverage (3 loops):
Collateral: 29,520 USDC | Debt: 19,520 USDC
LT = 86% for stablecoins on Aave V3
HF = (29,520 Γ 0.86) / 19,520 = 25,387 / 19,520 = 1.30 β
Since both collateral and debt are USDC (same asset), price movement
doesn't affect HF β the risk is purely rate inversion, not liquidation.
For cross-asset leverage (e.g., deposit ETH, borrow USDC), price
movement is the primary liquidation risk (see Module 5 walkthrough).
π‘ Concept: Pattern 3: LP + Staking
Provide liquidity to an AMM pool, then stake the LP tokens for additional rewards:
1. Deposit USDC β swap half to ETH β provide USDC/ETH liquidity on Uniswap
2. Stake LP tokens in a reward contract (or Convex/Aura for Curve/Balancer)
3. Earn: trading fees + liquidity mining rewards + boosted rewards
4. Harvest: claim all rewards, swap to USDC, re-provide liquidity
This is the model behind Yearnβs Curve strategies (Curve LP β stake in Convex β earn CRV+CVX), which have historically been among the highest and most consistent yield sources.
π Pattern 4: Vault Composability
Because ERC-4626 vaults are ERC-20 tokens, they can be used as:
- Collateral in lending protocols: Deposit sUSDe (Ethenaβs staked USDe vault token) as collateral on Aave, borrow against your yield-bearing position
- Liquidity in AMMs: Create a trading pair with a vault token (e.g., wstETH/sDAI pool)
- Strategy inputs for other vaults: A Yearn allocator vault can add any ERC-4626 vault as a strategy, including another allocator vault (vault-of-vaults)
This composability is why ERC-4626 adoption has been so rapid β each new vault automatically works with every protocol that supports the standard.
β οΈ Security Considerations for Vault Builders
1. totalAssets() must be manipulation-resistant. If totalAssets() reads external state that can be manipulated within a transaction (DEX spot prices, raw token balances), your vault is vulnerable. Use internal accounting or time-delayed oracles.
2. Withdrawal liquidity risk. If all assets are deployed to strategies, a large withdrawal can fail. Maintain an βidle bufferβ (percentage of assets not deployed) and implement a withdrawal queue that pulls from strategies in priority order.
3. Strategy loss handling. Strategies can lose money (smart contract hack, bad debt in lending, impermanent loss). The vault must handle losses gracefully β reduce share price proportionally, not revert on withdrawal. Yearn V3βs max_loss parameter lets users specify acceptable loss on withdrawal.
4. Sandwich attack on harvest. An attacker sees a pending harvest() transaction that will increase totalAssets. They front-run with a deposit (buying shares cheap), let harvest execute (share price increases), then back-run with a withdrawal (redeeming at higher price). Defense: profit unlocking over time (Yearnβs profitMaxUnlockTime), deposit/withdrawal fees, or private transaction submission.
5. Fee-on-transfer and rebasing tokens. ERC-4626 assumes standard ERC-20 behavior. Fee-on-transfer tokens deliver less than the requested amount on transferFrom. Rebasing tokens change balances outside of transfers. Both break naive vault accounting. Use balance-before-after checks (Module 1 pattern) and avoid rebasing tokens as underlying assets.
6. ERC-4626 compliance edge cases. The standard requires specific behaviors for max functions (must return type(uint256).max or actual limit), preview functions (must be exact or revert), and empty vault handling. Non-compliant implementations cause integration failures across the ecosystem. Test against the ERC-4626 property tests.
πΌ Job Market Context
What DeFi teams expect you to know about vault security:
-
βHow would you prevent sandwich attacks on a yield vault?β
- Good answer: βUse profit unlocking β spread harvested yield over hours/days so an attacker canβt capture it instantly.β
- Great answer: βThree layers: (1) linear profit unlocking via
profitMaxUnlockTime(Yearnβs approach) β profits accrue to share price gradually; (2) deposit/withdrawal fees that punish short-term deposits; (3) private transaction submission (Flashbots Protect) for harvest calls so MEV searchers canβt see them in the mempool.β
-
βA protocol wants to use your ERC-4626 vault token as collateral. What do you warn them about?β
- Good answer: βDonβt use
convertToAssets()directly for pricing β it can be manipulated via donation.β - Great answer: βThree risks: (1) the vaultβs exchange rate can be manipulated within a single transaction (donation attack) β use a TWAP or oracle for pricing; (2) the vault may have withdrawal liquidity constraints (strategy funds locked, withdrawal queue) β so liquidation may fail; (3) the vaultβs
totalAssets()may include unrealized gains that could reverse (strategy loss, depeg). They should readmaxWithdraw()to check actual liquidity.β
- Good answer: βDonβt use
-
βWhat yield strategy patterns have you built or reviewed?β
- Good answer: βAuto-compounders that claim rewards and reinvest, leveraged staking.β
- Great answer: βIβve worked with (1) auto-compounders with keeper economics (harvest only when reward value exceeds gas + slippage); (2) leveraged yield via recursive borrowing with automated health factor management; (3) LP strategies that handle impermanent loss reporting; (4) allocator vaults that rebalance across multiple strategies based on utilization and APY signals.β
Hot topics (2025-26):
- ERC-4626 as collateral in lending markets (Morpho, Euler V2, Aave V3.1)
- Curator/vault-as-a-service models replacing monolithic vault managers
- Restaking vaults (EigenLayer, Symbiotic) β ERC-4626 wrappers around restaking positions
- Real-world asset (RWA) vaults β tokenized treasury yields via ERC-4626
π― Build Exercise: Auto Compounder
Workspace: workspace/src/part2/module7/exercise4-auto-compounder/ β starter files: AutoCompounder.sol, MockSwap.sol, tests: AutoCompounder.t.sol
Build an ERC-4626 vault with harvest and linear profit unlocking. Youβll implement 3 functions: _lockedProfit (linear unlock calculation), totalAssets (balance minus locked profit), and harvest (swap reward tokens to underlying asset, lock the new profit).
The pre-built MockSwap.sol is a minimal 1:1 swap router. Reward tokens are minted to the vault in tests to simulate earned rewards.
Tests verify:
- Standard 1:1 first deposit
- After harvest, profit is fully locked β totalAssets unchanged, share price unchanged
- Profit unlocks linearly: 50% unlocked at the halfway point (3 hours of 6-hour period)
- After full unlock period, totalAssets equals the complete balance and share price reflects all yield
- Sandwich attack is unprofitable: attacker deposits before harvest, redeems after β gets zero profit because profit is locked
- Consecutive harvests: remaining locked profit from a previous harvest carries over and combines with newly harvested profit
π Summary: Composable Yield Patterns and Security
β Covered:
- Auto-compounding: claim rewards β swap β re-deposit, keeper economics
- Leveraged yield: recursive borrowing, health factor management, flash loan shortcuts
- LP + staking: AMM liquidity + reward farming (Curve/Convex pattern)
- Vault composability: ERC-4626 tokens as collateral, LP assets, or strategy inputs
- Six critical security considerations for vault builders
- Sandwich attack on harvest and profit unlocking defense
Internalized patterns: ERC-4626 is the TCP/IP of DeFi yield (universal vault interface). Share math is the same pattern everywhere (Aave aTokens, Compound cTokens, LP tokens, Yearn vaults, DSR). The inflation attack is real and ongoing (virtual shares or internal accounting are non-negotiable). Profit unlocking prevents sandwich attacks (linear unlock over hours/days). The allocator pattern is the future (Yearn V3, Morpho curators, Euler V2 vaults). Leveraged yield is profitable only when incentives exceed the borrow-supply spread. The curator model separates infrastructure from risk management (each layer using ERC-4626).
Key insight: ERC-4626 composability is a double-edged sword. It enables powerful yield strategies (vault-of-vaults, vault tokens as collateral), but every layer of composition adds attack surface. The security checklist (manipulation-resistant totalAssets, withdrawal liquidity, loss handling, sandwich defense, token edge cases, compliance) is non-negotiable for production vaults.
β οΈ Common Mistakes
Mistake 1: Using balanceOf(address(this)) for totalAssets()
// WRONG β vulnerable to donation attack
function totalAssets() public view returns (uint256) {
return asset.balanceOf(address(this));
}
// CORRECT β internal accounting
uint256 private _managedAssets;
function totalAssets() public view returns (uint256) {
return _managedAssets;
}
Mistake 2: Wrong rounding direction in conversions
// WRONG β rounds in favor of the USER (attacker can drain vault)
function convertToShares(uint256 assets) public view returns (uint256) {
return assets * totalSupply() / totalAssets(); // rounds down = fewer shares (OK for deposit)
}
function previewWithdraw(uint256 assets) public view returns (uint256) {
return assets * totalSupply() / totalAssets(); // rounds down = fewer shares burned (BAD!)
}
// CORRECT β withdraw must round UP (burn more shares = vault-favorable)
function previewWithdraw(uint256 assets) public view returns (uint256) {
return Math.mulDiv(assets, totalSupply(), totalAssets(), Math.Rounding.Ceil);
}
Mistake 3: Not handling the empty vault case
// WRONG β division by zero when totalSupply == 0
function convertToShares(uint256 assets) public view returns (uint256) {
return assets * totalSupply() / totalAssets(); // 0/0 on first deposit!
}
// CORRECT β handle first deposit explicitly or use virtual shares
function convertToShares(uint256 assets) public view returns (uint256) {
uint256 supply = totalSupply();
return supply == 0 ? assets : Math.mulDiv(assets, supply, totalAssets());
}
Mistake 4: Instantly reflecting harvested yield in share price
// WRONG β enables sandwich attack on harvest
function harvest() external {
uint256 profit = strategy.claim();
_managedAssets += profit; // share price jumps instantly
}
The fix: track the harvested profit and unlock it linearly over a time period (e.g., 6 hours). totalAssets() should subtract the still-locked portion so the share price rises gradually, not in a single block. Think about what state you need to record at harvest time, and how totalAssets() can compute the unlocked fraction using block.timestamp. Exercise 4 (AutoCompounder) has you implement this pattern.
Mistake 5: Not checking maxDeposit/maxWithdraw before operations
// WRONG β assumes vault always accepts deposits
function depositIntoVault(IERC4626 vault, uint256 assets) external {
vault.deposit(assets, msg.sender); // reverts if vault is paused or full!
}
// CORRECT β check limits first
function depositIntoVault(IERC4626 vault, uint256 assets) external {
uint256 maxAllowed = vault.maxDeposit(msg.sender);
require(assets <= maxAllowed, "Exceeds vault deposit limit");
vault.deposit(assets, msg.sender);
}
π Cross-Module Concept Links
Backward References (concepts from earlier modules used here)
| Source | Concept | How It Connects |
|---|---|---|
| Part 1 Module 1 | mulDiv with rounding | Vault conversions use Math.mulDiv with explicit rounding direction β rounds down for deposits, up for withdrawals |
| Part 1 Module 1 | Custom errors | Vault revert patterns (DepositExceedsMax, InsufficientShares) use typed errors from Module 1 |
| Part 1 Module 2 | Transient storage | Reentrancy guard for vault deposit/withdraw uses transient storage pattern from Module 2 |
| Part 1 Module 5 | Fork testing | ERC-4626 Quick Try reads a live Yearn vault on mainnet fork β fork testing from Module 5 enables this |
| Part 1 Module 5 | Invariant testing | ERC-4626 property tests (a16z suite) use invariant/fuzz patterns from Module 5 |
| Part 1 Module 6 | Proxy / delegateCall | Yearn V3 TokenizedStrategy uses delegateCall to shared implementation β proxy pattern from Module 6 |
| M1 | SafeERC20 | All vault deposit/withdraw flows use SafeERC20 for underlying token transfers |
| M1 | Fee-on-transfer tokens | Break naive vault accounting β balance-before-after check from M1 is required |
| M2 | MINIMUM_LIQUIDITY / dead shares | Uniswap V2βs dead shares defense is the same pattern as Defense 2 (burn shares to address(1)) |
| M2 | AMM swaps / MEV | Auto-compound harvest routes through DEXs β slippage and sandwich risks from M2 apply directly |
| M3 | Oracle pricing | Vault tokens used as lending collateral need oracle pricing β canβt trust the vaultβs own convertToAssets() |
| M4 | Index-based accounting | shares Γ rate = assets is the same pattern as Aaveβs scaledBalance Γ liquidityIndex |
| M5 | Flash loans | Enable single-tx recursive leverage; also enable atomic sandwich attacks on harvest |
| M6 | MakerDAO DSR / sDAI | DSR Pot is a vault pattern; sDAI is an ERC-4626 wrapper around it β same share math |
Forward References (where these concepts lead)
| Target | Concept | How It Connects |
|---|---|---|
| M8 | Invariant testing for vaults | Property-based tests verify vault rounding, share price monotonicity, withdrawal guarantees |
| M8 | Composability attack surfaces | Multi-layer vault composition creates novel attack vectors covered in M8 threat models |
| M9 | Vault shares as collateral | Integration capstone uses ERC-4626 vault tokens as building blocks |
| M9 | Yield aggregator integration | Capstone combines vault patterns with flash loans and liquidation mechanics |
| Part 3 M1 | Liquid staking vaults | LST wrappers (wstETH) are ERC-4626 vaults β same share math, same inflation risk, applied to staking yield |
| Part 3 M3 | Structured product vaults | Structured products compose ERC-4626 vaults into layered yield strategies with tranching and risk segmentation |
| Part 3 M5 | MEV and vault harvests | MEV searchers target vault harvest transactions β sandwich attacks on harvest() and profit unlocking as defense |
| Part 3 M8 | Governance over vault parameters | Vault configuration (strategy caps, fee rates, unlock periods) managed through governance mechanisms |
π Production Study Order
Study these implementations in order β each builds on concepts from the previous:
| # | Repository | Why Study This | Key Files |
|---|---|---|---|
| 1 | OpenZeppelin ERC4626 | Foundation implementation with virtual shares defense β the reference all others compare against | ERC4626.sol (conversion math, rounding), Math.sol (mulDiv) |
| 2 | Solmate ERC4626 | Minimal gas-efficient alternative β no virtual shares, shows the trade-off between safety and efficiency | ERC4626.sol (compare rounding, no _decimalsOffset) |
| 3 | Yearn TokenizedStrategy | The delegation pattern β how a strategy delegates ERC-4626 logic to a shared implementation via delegateCall | TokenizedStrategy.sol (accounting, reporting), BaseStrategy.sol (the 3 overrides) |
| 4 | Yearn VaultV3 | Allocator vault with profit unlocking, role system, and multi-strategy debt management | VaultV3.vy (process_report, update_debt, profit unlock), TECH_SPEC.md |
| 5 | Morpho MetaMorpho | Production curator vault β allocates across Morpho Blue lending markets, real-world fee/cap/queue mechanics | MetaMorpho.sol (allocation logic, fee handling, withdrawal queue) |
| 6 | a16z ERC-4626 Property Tests | Comprehensive compliance test suite β run against any vault to verify rounding, preview accuracy, edge cases | ERC4626.prop.sol (all property tests), README (how to integrate) |
Reading strategy: Start with OZ ERC4626 (1) to understand the math foundation. Compare with Solmate (2) to see what βno virtual sharesβ means in practice. Then read a simple Yearn strategy (3) to understand the user-facing abstraction. VaultV3 (4) shows how strategies compose into an allocator. MetaMorpho (5) is the most production-complete curator vault. Finally, run the a16z tests (6) against your own implementations.
π Resources
ERC-4626:
- EIP-4626 specification
- Ethereum.org ERC-4626 overview
- OpenZeppelin ERC-4626 implementation + security guide
- OpenZeppelin ERC4626.sol source
Inflation attack:
- MixBytes β Overview of the inflation attack
- OpenZeppelin β ERC-4626 exchange rate manipulation risks
- SpeedrunEthereum β ERC-4626 vault security
Yearn V3:
ERC-4626 Property Tests:
- a16z ERC-4626 property tests β Comprehensive property-based test suite for ERC-4626 compliance. Run these against any vault implementation to verify spec-correctness: rounding invariants, preview accuracy, max function behavior, and edge cases. If your vault passes these, it will integrate correctly with the broader ERC-4626 ecosystem.
- OpenZeppelin ERC-4626 tests β OpenZeppelinβs own test suite covers the virtual share mechanism and rounding behavior.
Modular DeFi / Curators:
- Morpho documentation
- Euler V2 documentation
- MetaMorpho source β Production ERC-4626 curator vault
Navigation: β Module 6: Stablecoins & CDPs | Module 8: DeFi Security β