Tutoriel·10 min de lecture·Par Solingo

Construire un Vault ERC-4626 Minimal à Partir de Zéro

ERC-4626 standardise les yield vaults. Implémentation minimale avec deposit, withdraw et shares.

# 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 :

  • Alice est la première déposante : deposit(1 USDC) → mint(1 share)
  • Attaquant transfert directement 1M USDC au vault (pas via deposit)
  • totalAssets = 1M USDC, totalSupply = 1 share
  • Bob deposit(100 USDC) → convertToShares = (100 × 1) / 1M = 0 shares 🚨
  • Bob perd ses 100 USDC, l'attaquant récupère tout
  • 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 :

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement