# Construire un Vault ERC-4626 Minimal à Partir de Zéro
ERC-4626 est le standard qui manquait à DeFi : une interface unifiée pour tous les yield vaults (Yearn, Aave, Compound, etc.). Implémentons-le from scratch.
Pourquoi ERC-4626 ?
Avant ERC-4626, chaque protocole inventait sa propre interface :
// Yearn
yVault.deposit(amount);
yVault.withdraw(shares);
// Aave
aToken.mint(amount);
aToken.burn(shares);
// Compound
cToken.mint(amount);
cToken.redeem(shares);
Problèmes :
- Pas d'interopérabilité
- Agrégateurs complexes (1inch, Zapper)
- Risques d'intégration (chaque vault est différent)
ERC-4626 standardise tout :
// Toujours la même interface
vault.deposit(assets, receiver);
vault.withdraw(assets, receiver, owner);
L'Interface ERC-4626
interface IERC4626 is IERC20 {
// Metadata
function asset() external view returns (address);
// Deposit / Withdrawal Logic
function deposit(uint256 assets, address receiver) external returns (uint256 shares);
function mint(uint256 shares, address receiver) external returns (uint256 assets);
function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);
function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);
// Accounting
function totalAssets() external view returns (uint256);
function convertToShares(uint256 assets) external view returns (uint256);
function convertToAssets(uint256 shares) external view returns (uint256);
// Limits
function maxDeposit(address) external view returns (uint256);
function maxMint(address) external view returns (uint256);
function maxWithdraw(address owner) external view returns (uint256);
function maxRedeem(address owner) external view returns (uint256);
// Preview
function previewDeposit(uint256 assets) external view returns (uint256);
function previewMint(uint256 shares) external view returns (uint256);
function previewWithdraw(uint256 assets) external view returns (uint256);
function previewRedeem(uint256 shares) external view returns (uint256);
}
Clés :
- deposit/mint : déposer des assets, recevoir des shares
- withdraw/redeem : brûler des shares, recevoir des assets
- convertToShares/Assets : ratio de conversion
- preview : simuler une opération sans l'exécuter
Implémentation Minimale
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol";
contract SimpleVault is ERC20, IERC4626 {
using SafeERC20 for IERC20;
IERC20 private immutable _asset;
constructor(
IERC20 asset_,
string memory name,
string memory symbol
) ERC20(name, symbol) {
_asset = asset_;
}
// ==================== METADATA ====================
function asset() public view returns (address) {
return address(_asset);
}
function totalAssets() public view returns (uint256) {
return _asset.balanceOf(address(this));
}
// ==================== CONVERSION ====================
function convertToShares(uint256 assets) public view returns (uint256) {
uint256 supply = totalSupply();
return supply == 0 ? assets : (assets * supply) / totalAssets();
}
function convertToAssets(uint256 shares) public view returns (uint256) {
uint256 supply = totalSupply();
return supply == 0 ? shares : (shares * totalAssets()) / supply;
}
// ==================== DEPOSIT ====================
function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
require(assets <= maxDeposit(receiver), "Exceeds max");
shares = previewDeposit(assets);
_asset.safeTransferFrom(msg.sender, address(this), assets);
_mint(receiver, shares);
emit Deposit(msg.sender, receiver, assets, shares);
}
function mint(uint256 shares, address receiver) public returns (uint256 assets) {
require(shares <= maxMint(receiver), "Exceeds max");
assets = previewMint(shares);
_asset.safeTransferFrom(msg.sender, address(this), assets);
_mint(receiver, shares);
emit Deposit(msg.sender, receiver, assets, shares);
}
// ==================== WITHDRAW ====================
function withdraw(
uint256 assets,
address receiver,
address owner
) public returns (uint256 shares) {
require(assets <= maxWithdraw(owner), "Exceeds max");
shares = previewWithdraw(assets);
if (msg.sender != owner) {
_spendAllowance(owner, msg.sender, shares);
}
_burn(owner, shares);
_asset.safeTransfer(receiver, assets);
emit Withdraw(msg.sender, receiver, owner, assets, shares);
}
function redeem(
uint256 shares,
address receiver,
address owner
) public returns (uint256 assets) {
require(shares <= maxRedeem(owner), "Exceeds max");
assets = previewRedeem(shares);
if (msg.sender != owner) {
_spendAllowance(owner, msg.sender, shares);
}
_burn(owner, shares);
_asset.safeTransfer(receiver, assets);
emit Withdraw(msg.sender, receiver, owner, assets, shares);
}
// ==================== PREVIEW ====================
function previewDeposit(uint256 assets) public view returns (uint256) {
return convertToShares(assets);
}
function previewMint(uint256 shares) public view returns (uint256) {
return convertToAssets(shares);
}
function previewWithdraw(uint256 assets) public view returns (uint256) {
return convertToShares(assets);
}
function previewRedeem(uint256 shares) public view returns (uint256) {
return convertToAssets(shares);
}
// ==================== LIMITS ====================
function maxDeposit(address) public pure returns (uint256) {
return type(uint256).max;
}
function maxMint(address) public pure returns (uint256) {
return type(uint256).max;
}
function maxWithdraw(address owner) public view returns (uint256) {
return convertToAssets(balanceOf(owner));
}
function maxRedeem(address owner) public view returns (uint256) {
return balanceOf(owner);
}
}
Fonctionnalités :
- ✅ Dépôt d'assets → mint de shares
- ✅ Retrait d'assets → burn de shares
- ✅ Ratio dynamique (shares/assets)
- ✅ Allowance support (withdraw pour le compte d'un autre)
Ajouter une Stratégie de Yield
Notre vault ne fait rien pour l'instant. Ajoutons Aave :
import {IPool} from "@aave/core-v3/contracts/interfaces/IPool.sol";
contract AaveVault is SimpleVault {
IPool public immutable aavePool;
IERC20 public immutable aToken;
constructor(
IERC20 asset_,
IPool aavePool_,
IERC20 aToken_
) SimpleVault(asset_, "Aave USDC Vault", "avUSDC") {
aavePool = aavePool_;
aToken = aToken_;
asset_.approve(address(aavePool_), type(uint256).max);
}
function totalAssets() public view override returns (uint256) {
// Les assets sont dans Aave, pas dans le vault
return aToken.balanceOf(address(this));
}
function deposit(uint256 assets, address receiver)
public
override
returns (uint256 shares)
{
shares = previewDeposit(assets);
_asset.safeTransferFrom(msg.sender, address(this), assets);
// Déposer dans Aave
aavePool.supply(address(_asset), assets, address(this), 0);
_mint(receiver, shares);
emit Deposit(msg.sender, receiver, assets, shares);
}
function withdraw(
uint256 assets,
address receiver,
address owner
) public override returns (uint256 shares) {
shares = previewWithdraw(assets);
if (msg.sender != owner) {
_spendAllowance(owner, msg.sender, shares);
}
// Retirer d'Aave
aavePool.withdraw(address(_asset), assets, receiver);
_burn(owner, shares);
emit Withdraw(msg.sender, receiver, owner, assets, shares);
}
}
Résultat : les utilisateurs déposent dans votre vault, qui dépose automatiquement dans Aave. Le yield s'accumule via les aTokens.
Attaque d'Inflation des Shares
Scénario :
Mitigation 1 : Mint Initial Virtuel (OpenZeppelin)
uint256 private constant _INITIAL_SUPPLY = 1e6; // 1M shares virtuelles
function convertToShares(uint256 assets) public view override returns (uint256) {
uint256 supply = totalSupply() + _INITIAL_SUPPLY;
uint256 totalAssets_ = totalAssets() + 1; // +1 pour éviter division par 0
return (assets * supply) / totalAssets_;
}
Mitigation 2 : Dead Shares (Solmate)
constructor(...) {
// Burn 1000 shares au déploiement
_mint(address(0xdead), 1000);
}
Recommandation : utilisez OpenZeppelin ERC4626 qui inclut la protection par défaut.
Tests avec Foundry
// test/Vault.t.sol
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/SimpleVault.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockERC20 is ERC20 {
constructor() ERC20("Mock", "MOCK") {
_mint(msg.sender, 1_000_000e18);
}
}
contract VaultTest is Test {
SimpleVault vault;
MockERC20 asset;
address alice = address(0x1);
address bob = address(0x2);
function setUp() public {
asset = new MockERC20();
vault = new SimpleVault(asset, "Vault", "vMOCK");
asset.transfer(alice, 1000e18);
asset.transfer(bob, 1000e18);
}
function testDeposit() public {
vm.startPrank(alice);
asset.approve(address(vault), 100e18);
uint256 shares = vault.deposit(100e18, alice);
assertEq(shares, 100e18);
assertEq(vault.balanceOf(alice), 100e18);
assertEq(vault.totalAssets(), 100e18);
vm.stopPrank();
}
function testWithdraw() public {
// Alice dépose
vm.startPrank(alice);
asset.approve(address(vault), 100e18);
vault.deposit(100e18, alice);
// Alice retire
uint256 assets = vault.redeem(50e18, alice, alice);
assertEq(assets, 50e18);
assertEq(vault.balanceOf(alice), 50e18);
assertEq(asset.balanceOf(alice), 950e18);
vm.stopPrank();
}
function testYieldAccrual() public {
// Alice dépose
vm.prank(alice);
asset.approve(address(vault), 100e18);
vault.deposit(100e18, alice);
// Simuler du yield : +10%
asset.transfer(address(vault), 10e18);
assertEq(vault.totalAssets(), 110e18);
assertEq(vault.convertToAssets(100e18), 110e18); // 1 share = 1.1 assets
// Alice retire tout
vm.prank(alice);
uint256 assets = vault.redeem(100e18, alice, alice);
assertEq(assets, 110e18); // Elle récupère le yield
}
function testTwoDepositors() public {
// Alice dépose 100
vm.startPrank(alice);
asset.approve(address(vault), 100e18);
vault.deposit(100e18, alice);
vm.stopPrank();
// Yield de 50%
asset.transfer(address(vault), 50e18);
// Bob dépose 100 (totalAssets = 150)
vm.startPrank(bob);
asset.approve(address(vault), 100e18);
uint256 shares = vault.deposit(100e18, bob);
// shares = (100 × 100) / 150 = 66.66
assertEq(shares, 66666666666666666666);
vm.stopPrank();
// Alice retire : (100 × 250) / 166.66 = 150
vm.prank(alice);
uint256 assets = vault.redeem(100e18, alice, alice);
assertEq(assets, 150e18);
}
}
forge test -vv
# ✅ 4 tests passed
Intégration avec Agrégateurs
ERC-4626 permet des agrégateurs comme Yearn v3 ou Idle Finance :
contract MetaVault is ERC4626 {
IERC4626[] public vaults; // Aave, Compound, Yearn
function deposit(uint256 assets, address receiver) public override {
// Split entre les vaults selon leur APY
uint256 aaveAmount = (assets * 40) / 100;
uint256 compoundAmount = (assets * 30) / 100;
uint256 yearnAmount = (assets * 30) / 100;
vaults[0].deposit(aaveAmount, address(this));
vaults[1].deposit(compoundAmount, address(this));
vaults[2].deposit(yearnAmount, address(this));
// Mint des shares au user
}
}
Conclusion
ERC-4626 simplifie radicalement l'intégration des yield vaults. En 2026, c'est le standard de facto pour :
- Yield aggregators (Yearn, Beefy)
- Lending protocols (Aave, Morpho)
- Liquid staking (Lido, Rocket Pool)
Next steps :
- Implémentez une stratégie avec Aave ou Compound
- Ajoutez des fees (performance fee, management fee)
- Testez l'attaque d'inflation et les mitigations
Ressources :