ERC-4626 Token (Vault) Development
Before ERC-4626, each yield vault implemented its own interface. Yearn V2 had pricePerShare(). Compound provided exchangeRate(). Aave worked through aToken with rebasing. Writing an aggregator that works with multiple vaults simultaneously meant maintaining a zoo of adapters. ERC-4626 standardized this: one interface for all tokenized vaults.
Today ERC-4626 is used by: Yearn V3, Morpho Blue, most liquid staking protocols, all major lending aggregators. The de facto standard for yield-bearing tokens.
What is ERC-4626 and why it matters for integrations
ERC-4626 is an ERC-20 extension that adds standard methods for a vault: deposit/withdraw assets (underlying asset), mint/redeem shares (vault token), conversion between assets and shares.
assets (underlying, for example USDC)
↕ convertToShares / convertToAssets
shares (vault token, for example yvUSDC)
Key point: vault token (shares) is a regular ERC-20 that trades and transfers. The share price grows as yield accumulates. This is fundamentally different from rebasing (stETH), where the number of tokens changes while price stays constant.
Vault mathematics: price per share
Share price in ERC-4626 is determined by totalAssets() / totalSupply():
pricePerShare = totalAssets / totalShares
On deposit, the user receives shares:
sharesToMint = assets * totalShares / totalAssets
On the first deposit (totalShares = 0), a problem arises: any formula with division by zero is invalid. OpenZeppelin solves this through virtual shares: initialize totalShares = 10^decimals, totalAssets = 10^decimals, giving an initial pricePerShare = 1.
Inflation attack on vault
This is a real vulnerability that allows the first depositor to profit at the expense of subsequent ones. Scenario:
- Attacker deposits 1 wei of asset, receives 1 share
- Attacker donates (direct transfer, bypassing deposit) a large amount of asset to the vault
-
pricePerSharespikes dramatically: 1 share now worth much more - Next user deposits 1000 USDC, but due to rounding receives 0 shares (rounding down)
- Their assets go to the attacker through redemption
OpenZeppelin ERC4626 protects against this through virtual shares (ERC4626 v5.0+):
function _decimalsOffset() internal view virtual returns (uint8) {
return 0; // Increase to 3 for additional protection
}
function totalAssets() public view virtual override returns (uint256) {
return _asset.balanceOf(address(this));
}
With _decimalsOffset() = 3, the virtual supply is 10^(3+decimals) shares against 10^decimals assets, making the attack economically infeasible — the attacker would need to deposit a huge sum for minimal gain.
Basic ERC-4626 vault implementation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract SimpleYieldVault is ERC4626, Ownable {
address public strategy;
uint256 public performanceFee; // in basis points (500 = 5%)
constructor(
IERC20 asset_,
string memory name_,
string memory symbol_
) ERC4626(asset_) ERC20(name_, symbol_) Ownable(msg.sender) {}
// Override totalAssets: account for not just vault balance,
// but also assets deployed in strategy
function totalAssets() public view virtual override returns (uint256) {
uint256 vaultBalance = IERC20(asset()).balanceOf(address(this));
uint256 strategyBalance = strategy != address(0)
? IStrategy(strategy).totalAssets()
: 0;
return vaultBalance + strategyBalance;
}
// Hook after deposit — deploy to strategy
function _afterDeposit(uint256 assets, uint256) internal virtual {
if (strategy != address(0)) {
IERC20(asset()).approve(strategy, assets);
IStrategy(strategy).invest(assets);
}
}
// Hook before withdraw — withdraw from strategy
function _beforeWithdraw(uint256 assets, uint256) internal virtual {
uint256 vaultBalance = IERC20(asset()).balanceOf(address(this));
if (assets > vaultBalance && strategy != address(0)) {
IStrategy(strategy).divest(assets - vaultBalance);
}
}
// Custom slippage checks
function deposit(uint256 assets, address receiver)
public
virtual
override
returns (uint256 shares)
{
uint256 maxDeposit = maxDeposit(receiver);
require(assets <= maxDeposit, "ERC4626: deposit more than max");
shares = previewDeposit(assets);
require(shares > 0, "Zero shares");
_deposit(_msgSender(), receiver, assets, shares);
_afterDeposit(assets, shares);
return shares;
}
}
Important edge cases in ERC-4626
Fee-on-transfer underlying asset
If the underlying asset is a fee-on-transfer token (some DeFi tokens with burn mechanics), the vault receives less than specified in deposit(). Correct implementation measures actual balance:
function _deposit(address caller, address receiver, uint256 assets, uint256 shares)
internal override
{
uint256 balanceBefore = IERC20(asset()).balanceOf(address(this));
SafeERC20.safeTransferFrom(IERC20(asset()), caller, address(this), assets);
uint256 actualReceived = IERC20(asset()).balanceOf(address(this)) - balanceBefore;
// Recalculate shares based on actually received amount
shares = convertToShares(actualReceived);
_mint(receiver, shares);
emit Deposit(caller, receiver, actualReceived, shares);
}
Maximal extractable value through preview
Functions previewDeposit() and previewWithdraw() must return exact amounts without fees (EIP-4626 requirement). But if vault takes performance fee, this changes the balance between rounds. Important not to include fee in preview functions, otherwise integrators get wrong UI data.
Rounding direction
ERC-4626 explicitly specifies rounding direction:
-
convertToShares→ floor (down, in vault's favor) -
convertToAssets→ floor (down, in vault's favor) -
previewDeposit→ floor (user gets no more than calculated) -
previewWithdraw→ ceil (vault takes no less than needed) -
previewRedeem→ floor
Violating these rules is an audit finding. Rounding must always favor the vault, otherwise drainage is possible through many small operations.
Integration with yield strategies
A full-featured ERC-4626 vault typically has a separate Strategy contract:
| Component | Responsibility |
|---|---|
| Vault (ERC-4626) | Account for shares, deposit/withdraw, access control |
| Strategy | Deploy assets to protocols (Aave, Curve, Convex) |
| Harvester | Collect rewards, swap to underlying, reinvest |
| Fee Manager | Calculate and distribute performance fees |
Separation of concerns is important for audits: strategy can be replaced without changing vault. Users hold vault shares, while strategy can change through governance.
Testing and auditing
ERC-4626 has an official property test suite: a16z ERC4626 Properties. Run it mandatory — it covers all roundtrip properties and standard invariants.
Foundry fuzz tests on key invariants:
function testFuzz_DepositRedeem(uint256 assets) public {
assets = bound(assets, 1, 1e30);
vm.assume(assets <= token.balanceOf(user));
uint256 shares = vault.deposit(assets, user);
uint256 assetsBack = vault.redeem(shares, user, user);
// May have rounding losses, but no more than 1 wei
assertApproxEqAbs(assetsBack, assets, 1);
}
Developing an ERC-4626 vault with basic strategy: 3-5 working days. Full-featured vault with harvester, fee mechanism, and multiple strategies: 2-3 weeks. Cost is calculated individually.







