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

Проектируем и разрабатываем блокчейн-решения полного цикла: от архитектуры смарт-контрактов до запуска DeFi-протоколов, NFT-маркетплейсов и криптобирж. Аудит безопасности, токеномика, интеграция с существующей инфраструктурой.
Показано 1 из 1 услугВсе 1306 услуг
Разработка ERC-4626 токена (vault)
Средняя
~3-5 рабочих дней
Часто задаваемые вопросы
Направления блокчейн-разработки
Этапы блокчейн-разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1221
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1163
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    855
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1056
  • image_logo-advance_0.png
    Разработка логотипа компании B2B Advance
    561
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    828

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