Difficulty: Intermediate
ERC-4626, the Tokenized Vault Standard defined in EIP-4626, establishes a common interface for yield-bearing vaults. Before ERC-4626, every DeFi protocol implemented its own vault/staking mechanism with different function signatures, making integration complex and error-prone. Yearn used yTokens, Compound used cTokens, and Aave used aTokens, each with unique interfaces. ERC-4626 standardizes this by extending ERC-20 with deposit/withdraw/redeem semantics, enabling any vault to be composed with any frontend, aggregator, or other protocol.
The core concept is simple: users deposit an underlying ERC-20 asset into the vault and receive vault shares (also ERC-20 tokens) in return. These shares represent the user's proportional ownership of the vault's total assets. As the vault earns yield (from lending, staking, farming, or any other strategy), the total assets grow while the share supply stays constant, making each share worth progressively more of the underlying asset. When users withdraw, they redeem their shares for the underlying asset, receiving their original deposit plus their proportional share of earned yield.
ERC-4626 defines four key mutative functions: `deposit(assets, receiver)` takes an exact amount of underlying assets and mints the corresponding shares, `mint(shares, receiver)` mints an exact number of shares and pulls the required assets, `withdraw(assets, receiver, owner)` burns shares to return an exact amount of underlying assets, and `redeem(shares, receiver, owner)` burns an exact number of shares and returns the corresponding assets. This dual-entry approach (specify assets or specify shares) gives integrators maximum flexibility.
The standard also includes preview and conversion functions that enable accurate UI display and pre-transaction estimation. `convertToShares(assets)` and `convertToAssets(shares)` show the current exchange rate. `previewDeposit`, `previewMint`, `previewWithdraw`, and `previewRedeem` simulate the exact outcome of each operation, accounting for any fees or slippage. The `maxDeposit`, `maxMint`, `maxWithdraw`, and `maxRedeem` functions report the maximum values for each operation, allowing frontends to validate input before submitting transactions.
Security considerations for ERC-4626 vaults include the inflation attack (where an attacker front-runs the first depositor by donating assets to the vault, manipulating the share price), rounding direction (deposits should round down shares in favor of the vault, withdrawals should round up assets in favor of the vault), and ensuring that the share-to-asset ratio cannot be manipulated through direct token transfers to the vault contract. OpenZeppelin's ERC-4626 implementation includes mitigations for the inflation attack by using a virtual offset in the share calculation.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
// A simple yield vault that earns yield from an external strategy
contract YieldVault is ERC4626, Ownable {
uint256 public lastHarvestTime;
uint256 public performanceFee = 1000; // 10% in basis points
uint256 public constant FEE_DENOMINATOR = 10_000;
address public feeRecipient;
event Harvested(uint256 profit, uint256 fee);
constructor(
IERC20 _asset,
address _feeRecipient,
address _owner
)
ERC20("Yield Vault Shares", "yvToken")
ERC4626(_asset)
Ownable(_owner)
{
feeRecipient = _feeRecipient;
lastHarvestTime = block.timestamp;
}
// Simulate yield harvesting (in production, this would
// interact with lending protocols, DEXs, etc.)
function harvest(uint256 profit) external onlyOwner {
// In a real vault, profit comes from strategy execution
// Here we simulate by assuming assets were transferred in
uint256 fee = (profit * performanceFee) / FEE_DENOMINATOR;
uint256 netProfit = profit - fee;
// Transfer fee to fee recipient
if (fee > 0) {
IERC20(asset()).transfer(feeRecipient, fee);
}
lastHarvestTime = block.timestamp;
emit Harvested(netProfit, fee);
}
function setPerformanceFee(uint256 _fee) external onlyOwner {
require(_fee <= 3000, "Fee too high"); // Max 30%
performanceFee = _fee;
}
// Override totalAssets to return the vault's total holdings
function totalAssets() public view override returns (uint256) {
return IERC20(asset()).balanceOf(address(this));
}
}
// Usage example:
// 1. Deploy vault with USDC as underlying asset
// 2. User approves vault to spend their USDC
// 3. User calls deposit(1000e6, userAddress) to deposit 1000 USDC
// 4. User receives vault shares representing their ownership
// 5. Vault earns yield over time, increasing totalAssets
// 6. User calls redeem(shares, userAddress, userAddress) to withdraw
// 7. User receives more USDC than they deposited (original + yield)
This vault extends OpenZeppelin's ERC4626 to add performance fees on harvested yield. The totalAssets() override reports the vault's token balance, and as profits accumulate, each share becomes worth more of the underlying asset.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// Simplified vault math for educational purposes
contract VaultMath {
uint256 public totalShares;
uint256 public totalAssets;
mapping(address => uint256) public shares;
// Deposit: assets -> shares
function deposit(uint256 assets) external {
uint256 sharesToMint;
if (totalShares == 0) {
// First depositor: 1:1 ratio
sharesToMint = assets;
} else {
// shares = assets * totalShares / totalAssets
// Example: deposit 100 when total is 1000 assets / 500 shares
// shares = 100 * 500 / 1000 = 50 shares
sharesToMint = (assets * totalShares) / totalAssets;
}
shares[msg.sender] += sharesToMint;
totalShares += sharesToMint;
totalAssets += assets;
}
// Withdraw: shares -> assets
function redeem(uint256 _shares) external {
require(shares[msg.sender] >= _shares, "Insufficient shares");
// assets = shares * totalAssets / totalShares
// Example: redeem 50 shares when total is 1200 assets / 500 shares
// assets = 50 * 1200 / 500 = 120 (profit of 20!)
uint256 assetsToReturn = (_shares * totalAssets) / totalShares;
shares[msg.sender] -= _shares;
totalShares -= _shares;
totalAssets -= assetsToReturn;
}
// Simulate yield being earned
function addYield(uint256 amount) external {
totalAssets += amount;
// Share supply stays the same, so each share is now worth more
}
// View: how much is my position worth?
function previewRedeem(uint256 _shares) external view returns (uint256) {
if (totalShares == 0) return 0;
return (_shares * totalAssets) / totalShares;
}
// View: how many shares for a deposit?
function previewDeposit(uint256 assets) external view returns (uint256) {
if (totalShares == 0) return assets;
return (assets * totalShares) / totalAssets;
}
}
This stripped-down example illustrates the core math: shares represent proportional ownership. When yield is added (totalAssets increases without new shares), each existing share becomes worth more. Depositing 100 assets into a 1000-asset / 500-share vault gives 50 shares. If yield grows assets to 1200, those 50 shares are now worth 120.
ERC-4626, Vault, Shares, Yield