Разработка ERC-4626 токена (vault)
До ERC-4626 каждый yield vault реализовывал собственный интерфейс. Yearn V2 имел pricePerShare(). Compound давал exchangeRate(). Aave работал через aToken с rebasing. Написать агрегатор, который работает с несколькими vault-ами одновременно, означало поддерживать зоопарк адаптеров. ERC-4626 стандартизировал это: один интерфейс для всех токенизированных vault-ов.
Сейчас ERC-4626 используют: Yearn V3, Morpho Blue, большинство liquid staking протоколов, все крупные lending агрегаторы. Стандарт де-факто для yield-bearing токенов.
Что такое ERC-4626 и почему это важно для интеграций
ERC-4626 — это расширение ERC-20, которое добавляет стандартные методы для vault-а: deposit/withdraw активами (underlying asset), mint/redeem shares (vault token), конвертация между assets и shares.
assets (underlying, например USDC)
↕ convertToShares / convertToAssets
shares (vault token, например yvUSDC)
Ключевое: vault token (shares) — это обычный ERC-20, который торгуется и передаётся. Цена share растёт по мере накопления yield. Это фундаментально отличается от rebasing (stETH), где количество токенов меняется, а цена постоянна.
Математика vault: price per share
Цена share в ERC-4626 определяется через totalAssets() / totalSupply():
pricePerShare = totalAssets / totalShares
При депозите пользователь получает shares:
sharesToMint = assets * totalShares / totalAssets
При первом депозите (totalShares = 0) возникает проблема: любая формула с делением на нуль невалидна. OpenZeppelin решает это через virtual shares: инициализируем totalShares = 10^decimals, totalAssets = 10^decimals, что даёт начальный pricePerShare = 1.
Инфляционная атака на vault
Это реальная уязвимость, которая позволяет первому depositor-у заработать за счёт последующих. Сценарий:
- Атакующий депонирует 1 wei актива, получает 1 share
- Атакующий donates (прямой transfer, минуя deposit) большое количество актива в vault
-
pricePerShareрезко вырастает: 1 share теперь стоит много - Следующий пользователь депонирует 1000 USDC, но из-за округления получает 0 shares (округление вниз)
- Его активы достаются атакующему через redemption
OpenZeppelin ERC4626 защищает от этого через виртуальные shares (ERC4626 v5.0+):
function _decimalsOffset() internal view virtual returns (uint8) {
return 0; // Увеличьте до 3 для дополнительной защиты
}
function totalAssets() public view virtual override returns (uint256) {
return _asset.balanceOf(address(this));
}
С _decimalsOffset() = 3 виртуальный запас составляет 10^(3+decimals) shares при 10^decimals assets, что делает атаку экономически невыгодной — атакующий должен депонировать огромную сумму для минимальной выгоды.
Реализация базового ERC-4626 vault
// 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; // в basis points (500 = 5%)
constructor(
IERC20 asset_,
string memory name_,
string memory symbol_
) ERC4626(asset_) ERC20(name_, symbol_) Ownable(msg.sender) {}
// Переопределяем totalAssets: учитываем не только баланс vault,
// но и активы, задеплоенные в стратегию
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;
}
// Хук после deposit — деплоим в стратегию
function _afterDeposit(uint256 assets, uint256) internal virtual {
if (strategy != address(0)) {
IERC20(asset()).approve(strategy, assets);
IStrategy(strategy).invest(assets);
}
}
// Хук перед withdraw — забираем из стратегии
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);
}
}
// Кастомные слипедж-проверки
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;
}
}
Важные edge cases в ERC-4626
Fee-on-transfer underlying asset
Если underlying asset — fee-on-transfer токен (некоторые DeFi токены с burn механизмом), vault получает меньше, чем указано в deposit(). Правильная реализация замеряет реальный баланс:
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;
// Пересчитываем shares по реально полученной сумме
shares = convertToShares(actualReceived);
_mint(receiver, shares);
emit Deposit(caller, receiver, actualReceived, shares);
}
Maximal extractable value через preview
Функции previewDeposit() и previewWithdraw() должны возвращать точное количество без комиссий (EIP-4626 требование). Но если vault берёт performance fee — это меняет баланс между rounds. Важно не включать fee в preview функции, иначе интеграторы получат неверные данные для UI.
Rounding direction
ERC-4626 явно специфицирует направление округления:
-
convertToShares→ floor (вниз, в пользу vault) -
convertToAssets→ floor (вниз, в пользу vault) -
previewDeposit→ floor (пользователь получает не больше, чем расчётное) -
previewWithdraw→ ceil (vault берёт не меньше, чем нужно) -
previewRedeem→ floor
Нарушение этих правил — это audit finding. Округление всегда должно быть в пользу vault, иначе возможен drain через множество маленьких операций.
Интеграция с yield стратегиями
Полноценный ERC-4626 vault обычно имеет отдельный Strategy контракт:
| Компонент | Ответственность |
|---|---|
| Vault (ERC-4626) | Учёт shares, deposit/withdraw, управление доступом |
| Strategy | Деплой активов в протоколы (Aave, Curve, Convex) |
| Harvester | Сбор rewards, swap в underlying, reinvest |
| Fee Manager | Расчёт и распределение performance fee |
Разделение ответственности важно для аудита: strategy может быть заменена без изменения vault. Пользователи держат shares vault-а, а strategy может меняться через governance.
Тестирование и аудит
ERC-4626 имеет официальный набор property tests: a16z ERC4626 Properties. Запускаем их обязательно — они покрывают все roundtrip-свойства и invariants стандарта.
Foundry fuzz тесты на ключевые инварианты:
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);
// Могут быть потери на округление, но не более 1 wei
assertApproxEqAbs(assetsBack, assets, 1);
}
Разработка ERC-4626 vault с базовой стратегией: 3-5 рабочих дней. Полноценный vault с harvester, fee механизмом и несколькими стратегиями: 2-3 недели. Стоимость рассчитывается индивидуально.







