Автор: Павел Найданов 🕵️♂️
Опр! Vault - это смарт-контракт "хранилище", который позволяет пользователям максимизировать прибыль с активов, которыми они владеют. Пользователи передают свои активы смарт-контракту, который реализует некоторую стратегию заработка за счет использования предоставленных активов с автоматическим начислением процентов и ребалансировки.
В момент передачи пользователем активов в vault, взамен выдаются другие токены(share), приносящие доход. Эти токены со временем растут в цене и представляют собой частичное владение пользователем активов в vault. Их стоимость растет пропорционально росту стоимости активов в пуле vault.
Предположим, что vault принимает ETH(нативная валюта сети Ethereum) в качестве актива для максимизации прибыли. Я могу передать ETH смарт-контракту vault и взамен получу share токен vETH. Share токен - это своего рода долговая расписка vault, которая позволяет получить мой ETH обратно.
Переданный мной ETH, внутри vault, объединяется с ETH других пользователей и используется в различных протоколах для извлечения доходности. Vault проверяет доходность по разным протоколам, когда пользователь передает или снимает активы. Это вызывает ребалансировку активов в пуле, если существует более выгодная возможность получения доходности. Ребалансировка - это изменение соотношения активов между различными протоколами заработка или даже стратегиями с целью извлечения максимальной доходности.
Например! Если MakerDao предлагает более высокий доход от вложения ETH в качестве ликвидности, чем Compound, то vault может принять решение о перемещении всего ETH или части из Compound в MakerDao. MakerDao и Compound - это популярные lending протоколы.
Стандарт ERC-4626 был разработан в рамках предложений по улучшению Ethereum. Он был создан в соавторстве с Джоуи Санторо, основателем протокола Fei.
До момента появления стандарта среди vaults отсутствовала стандартизация, что приводило к разнообразию реализаций. В свою очередь, это затрудняло интеграцию протоколов, реализующих приложения поверх vault.
Сам стандарт представляет собой смарт-контракт, который является расширением стандарта ERC-20 и регламентирует:
- Ввод и вывод активов
- Расчет количества токенов для ввода и вывода активов
- Балансы активов
- Отправку событий
Появление стандарта снизило затраты на интеграцию и повысило надежность реализаций.
Техническая реализация наследуется от ERC-20 стандарта. Это позволяет минтить и сжигать share(долевые) токены в обмен на assets(underlying или базовые) токены. Для этого процесса vault предоставляет стандартные функции: deposit()
, mint()
, redeem()
, burn()
.
Важно! Стандарт может реализовать для vault функционал других стандартов, например ERC-2612: Permit Extension for EIP-20 Signed Approvals.
В стандарте ERC-4626 предусмотрены две функции преобразования:
convertToShares(uint256 assets)
. Рассчитывает количество share токена, которое можно получить за переданное количество базового токена.convertToAssets(uint256 shares)
. Рассчитывает количество базового токена, которое можно получить за переданное количество share токена.
Процесс передачи токенов на контракт vault. В процессе передачи, согласно стандарту, необходимо рассчитать количество share токенов, списать базовый токен, сминтить share токен и отправить solidity событие.
Функция deposit()
может выглядеть подобно.
function deposit(uint256 assets, address receiver) public virtual returns (uint256 shares) {
/// Получаем доступное количество share токена
/// для внесенного количества базового токена(assets)
/// Под капотом вызывается convertToShares(uint256 assets)
shares = previewDeposit(assets);
if (shares == 0) {
/// Если количество share токена равно нулю, то возвращается ошибка
revert ZeroShares();
}
/// Трансфер базового токена на контракт vault
asset.safeTransferFrom(msg.sender, address(this), assets);
/// Минтинг взамена share токена
_mint(receiver, shares);
/// Отправка события, подтверждающее депозит пользователя
emit Deposit(msg.sender, receiver, assets, shares);
}
Процесс передачи share токенов c целью изъятия базового актива, который был вложен через вызов функции deposit()
. Согласно стандарту необходимо принять share токен, рассчитать количество базового токена и сжечь переданное количество share токена.
Функция redeem()
может выглядеть подобно.
function redeem(
uint256 shares,
address receiver,
address owner
) public virtual returns (uint256 assets) {
/// Проверяется, действительно ли указанный адрес вносил базовый токен на контракт
/// или давал ли он разрешение на управление своим депозитом вызывающему функцию
if (msg.sender != owner) {
uint256 allowed = allowance[owner][msg.sender];
if (allowed != type(uint256).max) allowance[owner][msg.sender] = allowed - shares;
}
/// Получаем доступное количество базового токена(assets)
/// при возврате указанного количества share токена
/// Под капотом вызывается convertToAssets(uint256 assets)
assets = previewRedeem(shares);
if (assets == 0) {
/// Если количество базового токена равно нулю, то возвращается ошибка
revert ZeroAssets();
}
/// Сжигание share токена
_burn(owner, shares);
/// Отправка базового токена до получателя
asset.safeTransfer(receiver, assets);
/// Отправка события об успешном окончании процесса снятия базового актива
/// Согласно стандарту у нас есть только событие Withdraw()
emit Withdraw(msg.sender, receiver, owner, assets, shares);
}
Функция mint()
реализует процесс предоставления базового токена контракту vault. Отличается этот процесс от deposit()
тем, что здесь в аргументах функции указывается не количество базового актива, а количество share токена, которое необходимо получить после вызова функции.
Функция mint()
может выглядеть подобно.
function mint(uint256 shares, address receiver) public virtual returns (uint256 assets) {
/// Рассчитывается количество базового актива для передачи на контракт
assets = previewMint(shares);
/// Трансфер базового актива от вызывающего до контракта
asset.safeTransferFrom(msg.sender, address(this), assets);
/// Минтинг share токенов
_mint(receiver, shares);
/// Отправка события об успешности процесса предоставления актива
/// Согласно стандарту у нас есть только событие Deposit
emit Deposit(msg.sender, receiver, assets, shares);
}
Функция withdraw()
реализует процесс изъятия базового токена из контракта vault. Отличается этот процесс от redeem()
тем, что здесь в аргументах функции указывается не количество share токена, а количество базового токена(assets), которое необходимо получить после вызова функции.
Функция withdraw()
может выглядеть подобно.
function withdraw(
uint256 assets,
address receiver,
address owner
) public virtual returns (uint256 shares) {
/// Рассчитывается количество share токена для передачи на контракт
shares = previewWithdraw(assets);
/// Проверяется, действительно ли указанный адрес вносил базовый токен на контракт
/// или давал ли он разрешение на управление своим депозитом вызывающему функцию
if (msg.sender != owner) {
uint256 allowed = allowance[owner][msg.sender];
if (allowed != type(uint256).max) allowance[owner][msg.sender] = allowed - shares;
}
/// Сжигание share токена
_burn(owner, shares);
/// Отправка базового токена до получателя
asset.safeTransfer(receiver, assets);
/// Отправка события об успешном окончании процесса снятия базового актива
emit Withdraw(msg.sender, receiver, owner, assets, shares);
}
Самые популярные библиотеки уже реализовали минимальный функционал для контракта vault. Можно брать контракты, наследоваться от них и дорабатывать свой собственный контракт vault.
- Минимальная реализация vault в библиотеке solmate.
- Минимальная реализация vault в библиотеке openZeppelin.
Важно! Стандарт полностью обратно совместим со стандартом ERC-20.
- Aave vault
- Minimal ERC4626-style tokenized Vault implementation with ERC1155 accounting
- Rari-Capital vault
- Протокол Fuji V2 Himalaya. Контракт YieldVault. Этот контракт наследуется от BaseVault.
- ERC-4626: Tokenized Vaults
- ERC-2612: Permit Extension for EIP-20 Signed Approvals
- ERC-20: Token Standard
- Про ERC-4626 на ethereum.org
- Прекрасный простой пример vault на solidity-by-example.