Розробка 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), конвертація між активами та shares.
активи (underlying, наприклад USDC)
↕ convertToShares / convertToAssets
shares (vault token, наприклад yvUSDC)
Ключовий момент: vault token (shares) — це звичайний ERC-20, який торгується та передається. Ціна share зростає по мірі накопичення yield. Це принципово відрізняється від rebasing (stETH), де кількість токенів змінюється, а ціна залишається постійною.
Математика vault: ціна за 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
- Атакуючий донує (прямий transfer, обходячи deposit) велику кількість активу у vault
-
pricePerShareрізко зростає: 1 share тепер коштує багато - Наступний користувач депонує 1000 USDC, але через округлення отримує 0 shares (округлення вниз)
- Його активи достаються атакуючому через redemption
OpenZeppelin ERC4626 захищає від цього через virtual 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 активів, що робить атаку економічно невигідною — атакуючий повинен депонувати величезну суму для мінімальної вигоди.
Реалізація базового 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);
}
}
// Користувацькі перевірки slippage
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);
}
Максимальна екстрагована вартість через preview
Функції previewDeposit() та previewWithdraw() повинні повертати точну кількість без комісій (вимога EIP-4626). Але якщо vault бере performance fee — це змінює баланс між раундами. Важливо не включати fee в preview функції, інакше інтегратори отримають неправильні дані для UI.
Напрямок округлення
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 тестів: a16z ERC4626 Properties. Запускайте їх обов'язково — вони охоплюють усі roundtrip властивості та інваріанти стандарту.
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 тижні. Вартість розраховується індивідуально.







