ERC-4626 볼트 표준 심화 분석 및 Foundry 프로젝트 스캐폴딩(Scaffolding)
학습 목표
- ✓Explain the ERC-4626 interface and the rationale behind each of its 12 methods, including the symmetry between asset-denominated and share-denominated operations
- ✓Describe the assets vs. shares mental model and explain why rounding must always favor the vault over the user
- ✓Configure a Foundry project from scratch with OpenZeppelin dependencies, proper remappings, and correct compiler settings
- ✓Implement a minimal ERC-4626 vault contract (SecureYieldVault) that inherits from OpenZeppelin's ERC4626 base, compiles, and passes basic deposit/redeem tests
- ✓Evaluate the trade-offs between inheriting OpenZeppelin's ERC4626, using Solmate, or building from scratch for a production vault
- ✓Identify the inflation attack vector on empty vaults and explain how OpenZeppelin's virtual offset mitigates it
ERC-4626 Vault Standard Deep Dive & Foundry Project Scaffolding
In March 2022, a DeFi aggregator protocol lost $11M because their vault contract used a custom deposit/withdrawal interface that miscalculated share prices during a flash loan attack. The root cause wasn't exotic — it was that every vault protocol had invented its own accounting interface, and the aggregator couldn't correctly integrate all of them. Yearn had one API. Aave had another. Compound did something completely different. The aggregator team wrote custom adapters for each, and one adapter had a rounding bug that sat unnoticed for four months.
That is exactly why ERC-4626 exists. And that is exactly what we're building on top of for the next 12 lessons.
I'm Alex Kim. I've deployed vault contracts that held real money — and I've also deployed vault contracts that had bugs I caught at 2 AM on a Saturday. This course is the distillation of everything I wish someone had taught me before I touched production DeFi code.
The Problem: The Wild West Before ERC-4626
Before we appreciate the standard, you need to see the chaos it replaced.
Here's what a "vault" looked like in 2020-2021 — every project rolling their own:
// ❌ Yearn-style vault (simplified) — custom interface
contract YearnVault {
function deposit(uint256 _amount) external returns (uint256 shares);
function withdraw(uint256 _shares) external returns (uint256 amount);
function pricePerShare() external view returns (uint256);
// That's it. No standard preview. No standard max limits.
}
// ❌ Compound-style — completely different mental model
contract CompoundVault {
function mint(uint256 mintAmount) external returns (uint256 errorCode);
function redeem(uint256 redeemTokens) external returns (uint256 errorCode);
function exchangeRateCurrent() external returns (uint256);
// Returns error codes instead of reverting. Different naming. Different math.
}
// ❌ Aave-style — yet another approach
contract AavePool {
function deposit(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external;
function withdraw(address asset, uint256 amount, address to) external returns (uint256);
// Completely different parameter ordering and semantics
}
Now imagine you're building a yield aggregator. You need to integrate all three. Each one has different function signatures, different return semantics, different rounding behavior. You write three adapters. You test them individually. They pass. Then in production, one of them silently rounds the wrong direction and leaks value over thousands of transactions.
This is why Joey Santoro (founder of Fei Protocol) and the ERC-4626 authors pushed for a standard. One interface. One accounting model. One set of preview functions that every integrator can rely on.
🤔 Think about it: Why can't we just standardize on
deposit(uint256)andwithdraw(uint256)and call it a day? What's missing?
View Answer
Two functions aren't enough because there are two units at play — assets (the underlying token like USDC) and shares (the vault token). A user might want to say "I want to deposit exactly 100 USDC" (asset-denominated) OR "I want to get exactly 50 vault shares" (share-denominated). That's already four functions, not two. Then you need preview functions so integrators can simulate outcomes without executing transactions. And you need max functions so UIs know the limits. The surface area grows fast when you design for composability.
Core Concept 1: Assets vs. Shares — The Mental Model That Changes Everything
Every ERC-4626 vault operates on a single, elegant principle: you deposit assets, you receive shares.
Think of it like a mutual fund. You put in dollars (assets). You get fund units (shares). The value of each unit changes over time as the fund earns returns. When you want your money back, you redeem your units for dollars.
// The fundamental relationship:
// shares = assets * totalSupply / totalAssets
// assets = shares * totalAssets / totalSupply
// Day 1: Alice deposits 1000 USDC. Vault is empty.
// totalAssets = 0, totalSupply = 0
// Alice gets 1000 shares (1:1 ratio for first deposit)
// Day 30: Vault earned 100 USDC from yield strategies
// totalAssets = 1100, totalSupply = 1000
// Each share is now worth 1.1 USDC
// Day 30: Bob deposits 1000 USDC
// Bob gets: 1000 * 1000 / 1100 = 909.09 shares
// This is fair — Bob shouldn't get credit for yield he didn't earn
This is the core accounting trick. The share price floats. It only goes up if the vault earns yield. If you dilute by minting shares without adding assets, you're stealing from existing depositors. If you burn shares without removing assets, you're gifting value to remaining holders.
I'll be blunt: if this mental model isn't crystal clear, nothing else in this course will make sense. We'll spend all of Lesson 2 on the math, including the rounding traps that have cost protocols millions. For now, internalize the two-unit system: assets are the real token, shares are the accounting token.
Core Concept 2: The 12 Methods of ERC-4626
ERC-4626 extends ERC-20. Your vault token IS an ERC-20 token (the shares), plus 12 additional methods organized into clean categories. Let me walk through every single one, because you'll implement all of them.
Let me group them by purpose:
Group 1: Metadata (2 methods)
// What underlying token does this vault accept?
function asset() external view returns (address);
// → Returns the address of the underlying ERC-20 (e.g., USDC)
// How many total assets does the vault currently manage?
function totalAssets() external view returns (uint256);
// → Returns the total amount of underlying assets held/managed
// IMPORTANT: This includes assets deployed to strategies, not just balance
Group 2: Conversion (2 methods)
// If I have X assets, how many shares would that be worth right now?
function convertToShares(uint256 assets) external view returns (uint256);
// If I have X shares, how many assets would that be worth right now?
function convertToAssets(uint256 shares) external view returns (uint256);
// These are "idealized" conversions — no fees, no slippage, no rounding
// They exist for informational purposes, NOT for actual transaction math
I see beginners use convertToShares where they should use previewDeposit. The difference matters — previewDeposit accounts for fees and rounding. convertToShares is the theoretical conversion. We'll dig deep into this distinction in Lesson 2.
Group 3: Deposits — Asset-Denominated (3 methods)
// What's the max I can deposit right now?
function maxDeposit(address receiver) external view returns (uint256);
// If I deposit X assets, how many shares will I get? (simulation)
function previewDeposit(uint256 assets) external view returns (uint256);
// Actually deposit X assets and give shares to receiver
function deposit(uint256 assets, address receiver) external returns (uint256 shares);
// "I want to put in exactly 1000 USDC — give me however many shares that buys"
Group 4: Mints — Share-Denominated (3 methods)
// What's the max shares I can mint right now?
function maxMint(address receiver) external view returns (uint256);
// If I want X shares, how many assets will it cost? (simulation)
function previewMint(uint256 shares) external view returns (uint256);
// Mint exactly X shares by paying however many assets are needed
function mint(uint256 shares, address receiver) external returns (uint256 assets);
// "I want exactly 500 vault shares — tell me how much USDC that costs"
🤔 Think about it: Why do we need both
depositandmint? They seem to do the same thing from opposite directions.
View Answer
They serve different user intents. deposit is "I have exactly 1000 USDC to invest" — the user fixes the input amount. mint is "I want exactly 500 shares of the vault" — the user fixes the output amount. In a world without these two distinct paths, a user who wants a precise number of shares would need to first call previewDeposit, calculate the exact asset amount needed, account for rounding, and hope the share price doesn't change between their calculation and their transaction. With mint, they just say "give me 500 shares" and the contract figures out the cost. It's the same design pattern as Uniswap V2's swapExactTokensForTokens vs swapTokensForExactTokens.
Group 5: Withdrawals — Asset-Denominated (3 methods)
function maxWithdraw(address owner) external view returns (uint256);
function previewWithdraw(uint256 assets) external view returns (uint256);
function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);
// "I need exactly 1000 USDC out — burn however many shares required"
Group 6: Redemptions — Share-Denominated (3 methods)
function maxRedeem(address owner) external view returns (uint256);
function previewRedeem(uint256 shares) external view returns (uint256);
function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);
// "I want to burn 500 shares — give me whatever USDC they're worth"
Notice the symmetry: deposit/withdraw are asset-denominated (user specifies asset amounts). Mint/redeem are share-denominated (user specifies share amounts). The max functions tell you limits. The preview functions simulate outcomes. The action functions execute.
| Function | User Specifies | Direction | Returns |
|---|---|---|---|
deposit | Asset amount (input) | Assets → Shares | Shares minted |
mint | Share amount (output) | Assets → Shares | Assets spent |
withdraw | Asset amount (output) | Shares → Assets | Shares burned |
redeem | Share amount (input) | Shares → Assets | Assets returned |
This table is worth memorizing. When I first learned ERC-4626, I printed this matrix and taped it to my monitor. The symmetry is beautiful once you see it.
Core Concept 3: Rounding Direction — The Silent Killer
Here's my most expensive lesson. In production DeFi, rounding direction is a security property.
The ERC-4626 spec is very clear about this, and most implementations get it right by default thanks to OpenZeppelin. But you need to understand why:
// ❌ WRONG: Rounding in favor of the user
// On deposit: user sends 999 assets, gets ceil(999 * shares / assets) = MORE shares
// On withdraw: user burns 10 shares, gets ceil(10 * assets / shares) = MORE assets
// Result: vault slowly drains. Each transaction leaks a tiny amount.
// Over 10,000 transactions, you've lost real money.
// ✅ CORRECT: Always round AGAINST the user (in favor of the vault)
// On deposit: round shares DOWN — user gets slightly fewer shares
// On mint: round assets UP — user pays slightly more
// On withdraw: round shares UP — user burns slightly more shares
// On redeem: round assets DOWN — user gets slightly fewer assets
The rule is dead simple: the vault should never lose value from rounding. Whoever is receiving tokens gets slightly less. Whoever is paying tokens pays slightly more. This way, rounding errors accumulate inside the vault, benefiting all remaining share holders rather than being extracted.
| Action | What rounds? | Direction | Why |
|---|---|---|---|
deposit | Shares minted | ⬇ Down | User gets fewer shares → vault keeps tiny asset surplus |
mint | Assets required | ⬆ Up | User pays more → vault keeps tiny asset surplus |
withdraw | Shares burned | ⬆ Up | User loses more shares → vault keeps tiny share value |
redeem | Assets returned | ⬇ Down | User gets fewer assets → vault keeps tiny asset surplus |
We'll implement all the math in Lesson 2. For now, burn this principle into your brain: round against the mover, in favor of the vault.
Deep Dive: The Inflation Attack — What Happens When Rounding Goes Very Wrong
There's a famous attack on empty vaults. An attacker:
- Deposits 1 wei of assets, gets 1 share
- "Donates" a massive amount (say 1,000,000 USDC) directly to the vault via
transfer(notdeposit) - Now: totalAssets = 1,000,001 USDC, totalSupply = 1 share
- Each share is worth 1,000,001 USDC
- When the next user deposits anything less than 1,000,001 USDC, they get 0 shares (rounds down)
- The attacker redeems their 1 share and gets everything
OpenZeppelin's ERC-4626 implementation includes a "virtual shares and assets" offset to mitigate this. We'll implement and test this defense in Lesson 2. The key insight: it works by pretending there's always a small amount of "virtual" shares/assets, making the rounding attack economically infeasible.
Setting Up Foundry: Your DeFi Development Forge
Alright, enough theory. Let's build.
I use Foundry for all my Solidity work. Not Hardhat. Not Truffle. Foundry. Here's why: tests are written in Solidity, compilation is 10-100x faster, and the fuzzing/invariant testing tools are best-in-class. For a production DeFi vault where one bug can drain millions, I want the most powerful testing framework available.
Step 1: Install Foundry
# Install foundryup (the Foundry installer)
curl -L https://foundry.paradigm.xyz | bash
# Run foundryup to install forge, cast, anvil, chisel
foundryup
# Verify installation
forge --version
# Output: forge 0.2.0 (or similar)
Step 2: Initialize the Project
# Create project directory
mkdir secure-yield-vault && cd secure-yield-vault
# Initialize Foundry project
forge init
# You'll see:
# Initializing /home/user/secure-yield-vault from https://github.com/foundry-rs/forge-template...
# Installing forge-std in .../lib/forge-std
# Initialized forge project
After forge init, your directory looks like this:
secure-yield-vault/
├── foundry.toml # Project configuration
├── lib/
│ └── forge-std/ # Standard testing library
├── script/
│ └── Counter.s.sol # Deploy script (we'll replace this)
├── src/
│ └── Counter.sol # Example contract (we'll replace this)
└── test/
└── Counter.t.sol # Example test (we'll replace this)
Step 3: Install OpenZeppelin
# Install OpenZeppelin contracts
forge install OpenZeppelin/openzeppelin-contracts --no-commit
# Your lib/ directory now has:
# lib/
# ├── forge-std/
# └── openzeppelin-contracts/
Step 4: Configure Remappings
This is where beginners stumble. Foundry needs to know how to resolve import paths.
# Create remappings.txt in project root
// File: remappings.txt
@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
forge-std/=lib/forge-std/src/
Now update foundry.toml:
# File: foundry.toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.20"
# We'll add more configuration as the course progresses:
# optimizer, fuzz runs, fork testing URLs, etc.
Step 5: Clean Up the Template
# Remove template files
rm src/Counter.sol test/Counter.t.sol script/Counter.s.sol
Let's verify everything compiles:
forge build
# Output:
# [⠒] Compiling...
# [⠒] Compiling 0 files with Solc 0.8.20
# Compiler run successful!
Empty project, successful compile. Good starting point.
🤔 Think about it: Why do we pin
solc = "0.8.20"instead of using the latest compiler? Isn't newer always better?
View Answer
Pinning the Solidity compiler version ensures reproducible builds. If two developers compile the same contract with different compiler versions, they might get different bytecode — which means different behavior on-chain. OpenZeppelin v5.x is tested against 0.8.20+, and many auditing firms expect a pinned version. In production, "it compiled on my machine" isn't acceptable. We also avoid bleeding-edge compiler versions because they occasionally have optimizer bugs. 0.8.20 is battle-tested. We can upgrade deliberately when there's a reason to.
Deep Dive: Building the Vault Skeleton
Now let's implement SecureYieldVault.sol. This is the contract we'll evolve over the entire course. Today, it's a skeleton — all methods present, placeholder logic, compiles and passes basic sanity tests.
Here's my philosophy: get the interface right first, then fill in the logic. I've seen too many developers jump straight into complex accounting math before they even have a clean project structure. We're going to do this properly.
// File: src/SecureYieldVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/// @title SecureYieldVault
/// @notice A production-grade ERC-4626 tokenized vault
/// @dev Lesson 1: skeleton with all interface methods via OZ base
contract SecureYieldVault is ERC4626 {
constructor(
IERC20 _asset,
string memory _name,
string memory _symbol
) ERC4626(_asset) ERC20(_name, _symbol) {}
/// @notice Total assets managed by the vault
/// @dev Override this as we add strategies in Lessons 3-4
function totalAssets() public view override returns (uint256) {
// For now, just return the vault's balance of the underlying asset
// In production, this will include assets deployed to yield strategies
return IERC20(asset()).balanceOf(address(this));
}
}
Wait — that's only 20 lines. Where are the 12 methods?
This is the beauty of inheriting from OpenZeppelin's ERC4626. The base contract implements all 12 methods with secure defaults. We override what we need to customize. Let me show you what we're inheriting:
// What OpenZeppelin's ERC4626.sol gives us FOR FREE:
//
// ✅ asset() → returns _asset (set in constructor)
// ✅ totalAssets() → we override this (done above)
// ✅ convertToShares() → uses _convertToShares with Math.Rounding.Floor
// ✅ convertToAssets() → uses _convertToAssets with Math.Rounding.Floor
// ✅ maxDeposit() → returns type(uint256).max
// ✅ previewDeposit() → uses _convertToShares with Math.Rounding.Floor
// ✅ deposit() → transfers assets in, mints shares, emits Deposit event
// ✅ maxMint() → returns type(uint256).max
// ✅ previewMint() → uses _convertToShares with Math.Rounding.Ceil
// ✅ mint() → calculates assets needed, transfers in, mints shares
// ✅ maxWithdraw() → converts owner's shares to assets
// ✅ previewWithdraw() → uses _convertToShares with Math.Rounding.Ceil
// ✅ withdraw() → burns shares, transfers assets out
// ✅ maxRedeem() → returns owner's share balance
// ✅ previewRedeem() → uses _convertToAssets with Math.Rounding.Floor
// ✅ redeem() → burns shares, transfers assets out
//
// Notice the rounding: Floor when user receives, Ceil when user pays.
// This matches our rounding-direction rules exactly.
Let me trace through what happens when someone calls deposit:
Writing Our First Test
Now let's prove this works. Create a mock ERC-20 token and test the vault:
// File: test/SecureYieldVault.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console2} from "forge-std/Test.sol";
import {SecureYieldVault} from "../src/SecureYieldVault.sol";
import {ERC20Mock} from "./mocks/ERC20Mock.sol";
contract SecureYieldVaultTest is Test {
SecureYieldVault public vault;
ERC20Mock public usdc;
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
function setUp() public {
// Deploy mock USDC with 6 decimals
usdc = new ERC20Mock("USD Coin", "USDC", 6);
// Deploy vault
vault = new SecureYieldVault(usdc, "Secure Yield USDC", "syUSDC");
// Fund Alice with 10,000 USDC
usdc.mint(alice, 10_000e6);
// Fund Bob with 10,000 USDC
usdc.mint(bob, 10_000e6);
}
function test_vaultMetadata() public view {
assertEq(vault.name(), "Secure Yield USDC");
assertEq(vault.symbol(), "syUSDC");
assertEq(vault.decimals(), 6); // Matches underlying asset
assertEq(vault.asset(), address(usdc));
}
function test_depositAndRedeem() public {
uint256 depositAmount = 1_000e6; // 1,000 USDC
// Alice approves and deposits
vm.startPrank(alice);
usdc.approve(address(vault), depositAmount);
uint256 sharesMinted = vault.deposit(depositAmount, alice);
vm.stopPrank();
console2.log("Shares minted:", sharesMinted);
console2.log("Vault totalAssets:", vault.totalAssets());
console2.log("Alice share balance:", vault.balanceOf(alice));
// First depositor: shares should equal assets (1:1)
assertEq(sharesMinted, depositAmount);
assertEq(vault.totalAssets(), depositAmount);
assertEq(vault.balanceOf(alice), sharesMinted);
// Alice redeems all shares
vm.startPrank(alice);
uint256 assetsReturned = vault.redeem(sharesMinted, alice, alice);
vm.stopPrank();
console2.log("Assets returned:", assetsReturned);
// Should get back exactly what she deposited (no yield yet)
assertEq(assetsReturned, depositAmount);
assertEq(vault.totalAssets(), 0);
assertEq(vault.balanceOf(alice), 0);
}
function test_previewFunctions() public view {
// With empty vault, preview should show 1:1
assertEq(vault.previewDeposit(1_000e6), 1_000e6);
assertEq(vault.previewMint(1_000e6), 1_000e6);
assertEq(vault.previewWithdraw(1_000e6), 1_000e6);
assertEq(vault.previewRedeem(1_000e6), 1_000e6);
}
function test_maxFunctions() public view {
assertEq(vault.maxDeposit(alice), type(uint256).max);
assertEq(vault.maxMint(alice), type(uint256).max);
assertEq(vault.maxWithdraw(alice), 0); // Alice has no shares
assertEq(vault.maxRedeem(alice), 0); // Alice has no shares
}
}
We also need the mock token:
// File: test/mocks/ERC20Mock.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract ERC20Mock is ERC20 {
uint8 private _decimals;
constructor(string memory name_, string memory symbol_, uint8 decimals_)
ERC20(name_, symbol_)
{
_decimals = decimals_;
}
function decimals() public view override returns (uint8) {
return _decimals;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function burn(address from, uint256 amount) external {
_burn(from, amount);
}
}
Run it:
forge test -vvv
# Output:
# [PASS] test_depositAndRedeem() (gas: 112847)
# Logs:
# Shares minted: 1000000000
# Vault totalAssets: 1000000000
# Alice share balance: 1000000000
# Assets returned: 1000000000
#
# [PASS] test_maxFunctions() (gas: 24563)
# [PASS] test_previewFunctions() (gas: 18927)
# [PASS] test_vaultMetadata() (gas: 15432)
#
# Test result: ok. 4 passed; 0 failed; 0 skipped
Four passing tests. The vault compiles, accepts deposits, returns assets, and all preview/max functions work correctly.
Variations & Trade-offs: Build From Scratch vs. Inherit OZ
You might be wondering: "Should I inherit from OpenZeppelin, or implement ERC-4626 from scratch?"
I have strong opinions here. Let me lay out three approaches:
Approach A: Inherit OpenZeppelin's ERC4626 (What We're Doing)
// ~20 lines of code, all standard behavior
contract MyVault is ERC4626 {
constructor(IERC20 _asset) ERC4626(_asset) ERC20("My Vault", "mV") {}
function totalAssets() public view override returns (uint256) {
return IERC20(asset()).balanceOf(address(this));
}
}
Pros: Audited code, automatic security updates, community-reviewed
Cons: Slightly more gas due to virtual function overhead, less control over internals
Approach B: Implement ERC-4626 Interface From Scratch
// ~200-300 lines of code, full control
contract MyVault is ERC20, IERC4626 {
// You implement every single method
// You handle all the rounding yourself
// You manage SafeERC20 transfers yourself
// You emit all the correct events yourself
function deposit(uint256 assets, address receiver) external returns (uint256) {
uint256 shares = _convertToShares(assets, Math.Rounding.Floor);
require(shares > 0, "zero shares");
SafeERC20.safeTransferFrom(IERC20(_asset), msg.sender, address(this), assets);
_mint(receiver, shares);
emit Deposit(msg.sender, receiver, assets, shares);
return shares;
}
// ... 11 more methods, each with subtle rounding/edge case handling
}
Pros: Complete control, potentially lower gas, educational
Cons: Easy to introduce rounding bugs, must track OZ security patches yourself
Approach C: Use Solmate's ERC4626 (Rari Capital / transmissions11)
// Solmate is a gas-optimized alternative to OpenZeppelin
import {ERC4626} from "solmate/tokens/ERC4626.sol";
contract MyVault is ERC4626 {
constructor(ERC20 _asset) ERC4626(_asset, "My Vault", "mV", _asset.decimals()) {}
function totalAssets() public view override returns (uint256) {
return asset.balanceOf(address(this));
}
}
Pros: Gas-optimized (~10-15% cheaper on some operations), clean code
Cons: Smaller community, fewer audits, no inflation attack protection by default
| Criteria | OpenZeppelin | From Scratch | Solmate |
|---|---|---|---|
| Security | ⭐⭐⭐⭐⭐ | ⭐⭐ (your skill) | ⭐⭐⭐⭐ |
| Gas Efficiency | ⭐⭐⭐ | ⭐⭐⭐⭐ (if done right) | ⭐⭐⭐⭐⭐ |
| Inflation Attack Protection | ✅ Built-in | ❌ You must add | ❌ You must add |
| Ecosystem Support | ⭐⭐⭐⭐⭐ | ⭐ | ⭐⭐⭐ |
| Learning Value | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
My pick: OpenZeppelin for production, with surgical overrides where we need custom behavior. In this course, we'll override methods when we need to (fee logic, access control, strategy integration), but we'll let OZ handle the accounting base. I've audited contracts that rolled their own ERC-4626 math. Every single one had at least one rounding bug. Life's too short.
Deep Dive: Why Not Solmate?
I used Solmate on a project in 2023. The gas savings were real — about 12% on deposits. But when we needed the inflation attack protection, we had to implement it ourselves. We got it wrong the first time (off-by-one in the virtual shares offset) and only caught it during the audit. OpenZeppelin includes this protection by default. For the ~5,000 gas per deposit we save with Solmate, I'd rather have the safety net. If you're building something like a simple personal vault or a hackathon project, Solmate is fine. For production vaults holding millions? I sleep better with OZ.
Exploring the OZ ERC4626 Source
Let me show you the key internal methods from OpenZeppelin's ERC4626 that we'll be overriding throughout this course. Understanding these hooks is crucial.
// From OpenZeppelin's ERC4626.sol — simplified for clarity
// The core conversion logic (we'll customize this in Lesson 2)
function _convertToShares(uint256 assets, Math.Rounding rounding)
internal view virtual returns (uint256)
{
return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding);
// The +10**_decimalsOffset() and +1 are the inflation attack protection
}
function _convertToAssets(uint256 shares, Math.Rounding rounding)
internal view virtual returns (uint256)
{
return shares.mulDiv(totalAssets() + 1, totalSupply() + 10 ** _decimalsOffset(), rounding);
}
// The deposit/withdrawal hooks (we'll add fees, access control here)
function _deposit(address caller, address receiver, uint256 assets, uint256 shares)
internal virtual
{
SafeERC20.safeTransferFrom(IERC20(asset()), caller, address(this), assets);
_mint(receiver, shares);
emit Deposit(caller, receiver, assets, shares);
}
function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares)
internal virtual
{
// If caller != owner, spend allowance
if (caller != owner) {
_spendAllowance(owner, caller, shares);
}
_burn(owner, shares);
SafeERC20.safeTransfer(IERC20(asset()), receiver, assets);
emit Withdraw(caller, receiver, owner, assets, shares);
}
These four internal methods — _convertToShares, _convertToAssets, _deposit, _withdraw — are the extension points we'll hook into throughout the course:
- Lesson 2: Override conversions for custom rounding
- Lesson 3-4: Override
_deposit/_withdrawto route assets to strategies - Lesson 5: Add access control checks in
_deposit - Lesson 8: Inject fee calculations in
_deposit/_withdraw
Green = implemented this lesson. Gray = coming in future lessons.
Checkpoint: Test Your Understanding
Before we wrap up, let's make sure the core concepts are solid.
🤔 Challenge 1: What would happen if
totalAssets()returned0buttotalSupply()returned1000? In practical terms, what does this state represent?
View Answer
This means the vault has shares outstanding but claims to hold no assets. Every share is worth zero. This could happen if the vault's assets were deployed to a strategy that lost everything (a rug pull or an exploit). convertToAssets(1000) would return 0. Any redeem call would return 0 assets. This is catastrophic for depositors — their shares are worthless. In a well-designed vault, this scenario should trigger an emergency pause (Lesson 10). It's also worth noting that in OpenZeppelin's implementation, the virtual offset (+1 in the denominator) means the math technically never divides by zero, even in this edge case.
🤔 Challenge 2: Modify the test to simulate yield. After Alice deposits 1000 USDC, directly transfer 100 USDC to the vault (simulating yield), then have Bob deposit. What share count does Bob receive?
Hint: Click if you're stuck
After the "yield", totalAssets = 1100 and totalSupply = 1000. Use the formula: shares = assets * totalSupply / totalAssets. Note: OpenZeppelin adds the virtual offset, so the exact result may differ by a tiny amount from the pure formula.
View Answer
function test_yieldSimulation() public {
// Alice deposits 1000 USDC
vm.startPrank(alice);
usdc.approve(address(vault), 1_000e6);
vault.deposit(1_000e6, alice);
vm.stopPrank();
// Simulate yield: 100 USDC lands in vault
usdc.mint(address(vault), 100e6);
// totalAssets = 1100 USDC, totalSupply = 1000 shares
assertEq(vault.totalAssets(), 1_100e6);
// Bob deposits 1000 USDC
vm.startPrank(bob);
usdc.approve(address(vault), 1_000e6);
uint256 bobShares = vault.deposit(1_000e6, bob);
vm.stopPrank();
// Bob gets fewer shares: ~909.09 shares (rounds down)
console2.log("Bob shares:", bobShares);
// With OZ virtual offset, it's very close to 909090909 (909.09 USDC equivalent)
assertTrue(bobShares < 1_000e6); // Fewer shares than assets deposited
assertTrue(bobShares > 900e6); // But close to the expected ~909
}
Bob gets approximately 909 shares for his 1000 USDC because Alice's yield has already increased the share price. This is exactly how fair accounting should work.
🔨 Project Update
This is Lesson 1 — the very first milestone. Here's the complete project you should have at the end of this lesson:
Project Structure:
secure-yield-vault/
├── foundry.toml
├── remappings.txt
├── src/
│ └── SecureYieldVault.sol
└── test/
├── SecureYieldVault.t.sol
└── mocks/
└── ERC20Mock.sol
File 1: foundry.toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.20"
File 2: remappings.txt
@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
forge-std/=lib/forge-std/src/
File 3: src/SecureYieldVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract SecureYieldVault is ERC4626 {
constructor(
IERC20 _asset,
string memory _name,
string memory _symbol
) ERC4626(_asset) ERC20(_name, _symbol) {}
function totalAssets() public view override returns (uint256) {
return IERC20(asset()).balanceOf(address(this));
}
}
File 4: test/mocks/ERC20Mock.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract ERC20Mock is ERC20 {
uint8 private _decimals;
constructor(string memory name_, string memory symbol_, uint8 decimals_)
ERC20(name_, symbol_)
{
_decimals = decimals_;
}
function decimals() public view override returns (uint8) {
return _decimals;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function burn(address from, uint256 amount) external {
_burn(from, amount);
}
}
File 5: test/SecureYieldVault.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console2} from "forge-std/Test.sol";
import {SecureYieldVault} from "../src/SecureYieldVault.sol";
import {ERC20Mock} from "./mocks/ERC20Mock.sol";
contract SecureYieldVaultTest is Test {
SecureYieldVault public vault;
ERC20Mock public usdc;
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
function setUp() public {
usdc = new ERC20Mock("USD Coin", "USDC", 6);
vault = new SecureYieldVault(usdc, "Secure Yield USDC", "syUSDC");
usdc.mint(alice, 10_000e6);
usdc.mint(bob, 10_000e6);
}
function test_vaultMetadata() public view {
assertEq(vault.name(), "Secure Yield USDC");
assertEq(vault.symbol(), "syUSDC");
assertEq(vault.decimals(), 6);
assertEq(vault.asset(), address(usdc));
}
function test_depositAndRedeem() public {
uint256 depositAmount = 1_000e6;
vm.startPrank(alice);
usdc.approve(address(vault), depositAmount);
uint256 sharesMinted = vault.deposit(depositAmount, alice);
vm.stopPrank();
console2.log("Shares minted:", sharesMinted);
console2.log("Vault totalAssets:", vault.totalAssets());
assertEq(vault.totalAssets(), depositAmount);
assertEq(vault.balanceOf(alice), sharesMinted);
vm.startPrank(alice);
uint256 assetsReturned = vault.redeem(sharesMinted, alice, alice);
vm.stopPrank();
console2.log("Assets returned:", assetsReturned);
assertEq(vault.totalAssets(), 0);
assertEq(vault.balanceOf(alice), 0);
}
function test_previewFunctions() public view {
assertEq(vault.previewDeposit(1_000e6), 1_000e6);
assertEq(vault.previewMint(1_000e6), 1_000e6);
assertEq(vault.previewWithdraw(1_000e6), 1_000e6);
assertEq(vault.previewRedeem(1_000e6), 1_000e6);
}
function test_maxFunctions() public view {
assertEq(vault.maxDeposit(alice), type(uint256).max);
assertEq(vault.maxMint(alice), type(uint256).max);
assertEq(vault.maxWithdraw(alice), 0);
assertEq(vault.maxRedeem(alice), 0);
}
}
Run the project you've built so far:
forge test -vvv
Expected output:
[⠒] Compiling...
[⠒] Compiling 6 files with Solc 0.8.20
[⠆] Solc 0.8.20 finished in 2.34s
Compiler run successful!
Running 4 tests for test/SecureYieldVault.t.sol:SecureYieldVaultTest
[PASS] test_depositAndRedeem() (gas: 112847)
Logs:
Shares minted: 1000000000
Vault totalAssets: 1000000000
Assets returned: 1000000000
[PASS] test_maxFunctions() (gas: 24563)
[PASS] test_previewFunctions() (gas: 18927)
[PASS] test_vaultMetadata() (gas: 15432)
Test result: ok. 4 passed; 0 failed; 0 skipped; finished in 1.23ms
Summary Diagram
What's Next
In Lesson 2: Share Accounting Mathematics, we'll rip open the math behind _convertToShares and _convertToAssets. We'll implement custom rounding logic, write fuzz tests that catch edge cases, and explore the infamous first-depositor inflation attack in full detail — including a live proof-of-concept exploit and the defense OpenZeppelin implements. The share math is where most vault bugs live, and I'll show you exactly why.
Difficulty Fork
🟢 Too Easy — I already know ERC-4626
Key takeaways to confirm you didn't miss anything:
- ERC-4626 adds 12 methods on top of ERC-20, organized in symmetric pairs: asset-denominated (deposit/withdraw) and share-denominated (mint/redeem)
- Each action has a
max*(limit query),preview*(simulation), and the action itself - Rounding always favors the vault: floor when user receives, ceil when user pays
- OpenZeppelin's implementation includes inflation attack protection via virtual shares/assets offset
totalAssets()is the main hook — it must account for assets deployed to external strategies
Speed challenge: Without looking at the code, can you list all 12 ERC-4626 methods from memory, grouped by purpose? If yes, you're ready for Lesson 2's math deep-dive.
🟡 Just Right — I followed along but want more practice
Think of ERC-4626 like a parking garage ticket machine:
- deposit = "Here's $20, give me a parking ticket" (you specify the money)
- mint = "I want a 3-hour ticket, how much?" (you specify the time)
- withdraw = "I need exactly $15 back, take whatever ticket time that costs"
- redeem = "Here's my 3-hour ticket, how much cash do I get back?"
Each has a preview ("how much would this cost?") and a max ("what's the most I can do?").
Practice: Add a test that:
- Has Alice deposit 1000 USDC
- Simulates 200 USDC yield (mint directly to vault)
- Uses
previewRedeemto check what Alice's shares are worth - Asserts that the preview shows approximately 1200 USDC
🔴 Challenge — Give me something hard
Production Scenario: You're reviewing a vault contract that overrides totalAssets() like this:
function totalAssets() public view override returns (uint256) {
return IERC20(asset()).balanceOf(address(this)) + strategyBalance;
}
Where strategyBalance is a uint256 state variable that gets updated when funds are deposited/withdrawn from a strategy.
- What happens if the strategy gets exploited and loses funds, but
strategyBalancestill shows the old (higher) value? - How does this phantom balance affect new depositors? Is it fair to them?
- How does it affect existing depositors trying to withdraw?
- Design a mechanism (pseudocode) that detects this discrepancy and pauses the vault.
This is a real class of bug — I've seen it in two separate audits. We'll implement the proper solution in Lessons 3-4 and the emergency mechanism in Lesson 10.