Розробка ERC-4626 токена (vault)

Проєктуємо та розробляємо блокчейн-рішення повного циклу: від архітектури смарт-контрактів до запуску DeFi-протоколів, NFT-маркетплейсів та криптобірж. Аудит безпеки, токеноміка, інтеграція з наявною інфраструктурою.
Показано 1 з 1Усі 1306 послуг
Розробка ERC-4626 токена (vault)
Середній
~3-5 днів
Часті запитання

Напрямки блокчейн-розробки

Етапи блокчейн-розробки

Останні роботи

  • image_website-b2b-advance_0.webp
    Розробка сайту компанії B2B ADVANCE
    1284
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1196
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    901
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1119
  • image_logo-advance_0.webp
    Розробка логотипу компанії B2B Advance
    586
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    853

Розробка 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. Атакуючий депонує 1 wei активу, отримує 1 share
  2. Атакуючий донує (прямий transfer, обходячи deposit) велику кількість активу у vault
  3. pricePerShare різко зростає: 1 share тепер коштує багато
  4. Наступний користувач депонує 1000 USDC, але через округлення отримує 0 shares (округлення вниз)
  5. Його активи достаються атакуючому через 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 тижні. Вартість розраховується індивідуально.