В этой статье мы подробно разберем, как работают основные функции протокола Aave V2, и узнаем, как их использовать заемщикам, ликвидаторам и депозитариям.

1. Введение

Aave — это децентрализованный протокол кредитования с открытым исходным кодом, не требующий посредников, банковских счетов или кредитных рейтингов. Изначально запущенный в 2017 году как ETHLend и переименованный в Aave в 2020 году, протокол стал лидером рынка DeFi. По состоянию на 2026 год, общая заблокированная стоимость (TVL) превышает $40 млрд, обеспечивая глубокую ликвидность в Ethereum, Polygon, Arbitrum, Optimism, Avalanche, Base и других сетях L2.

Ключевые особенности экосистемы:

  • aTokens: Процентные токены (aUSDC, aETH), представляющие долю в пуле. Баланс растёт автоматически каждую секунду («rebasing»). Можно использовать как залог в других протоколах.

  • Гибкие процентные ставки: Заемщики выбирают между стабильной (фиксированная) и переменной (зависит от утилизации) ставкой.

  • Флеш-кредиты: Кредиты без залога при условии возврата средств в рамках одной транзакции. Используются для арбитража и рефинансирования.

  • Безопасность и управление: Управление через DAO (держатели AAVE), Safety Module как страховой фонд.

  • Инновации V3: E-Mode (LTV до 97%), Isolation Mode, Portals для cross-chain ликвидности.

  • Широкая поддержка активов: 50+ токенов с индивидуально настроенными параметрами риска (LTV, Liquidation Threshold, Reserve Factor).

Настройка окружения для тестов

Мой репозиторий содержит следующие изменения относительно оригинального:

Пошаговая инструкция для воспроизведения наших тестов

# 1. Установка Foundry
curl -L https://foundry.paradigm.xyz | bash
foundryup

# 2. Клонирование репозитория
git clone https://github.com/danilastupin/parsing-aave-v2
cd protocol-v2
forge init --force

# 3. Настройка RPC и блока
# Зарегистрируйтесь на https://www.ankr.com/rpc/eth/ и скопируйте endpoint
# На Etherscan найдите номер блока любой выполненной транзакции контракта Aave:
# https://etherscan.io/address/0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9

# 4. Настройка .env файла
MAINNET_RPC_URL=ваш_endpoint
MAINNET_BLOCK_NUMBER=номер_блока

Полезные ссылки

Важно: Функции на Etherscan могут отличаться от функций в GitHub. Etherscan показывает прокси и конкретную задеплоенную версию логики «здесь и сейчас». GitHub содержит полную историю, исходный код всех версий и будущие обновления. Для глубокого анализа уязвимостей опирайтесь на код из репозитория, сверяя его с активной реализацией через механизм Proxy.

Запуск тестов

cd test

# Запустить все тесты
forge test -vv

# Запустить конкретный тест
forge test lendpool.t.sol --match-test testDeposit -vv

# Для более подробного вывода (рекомендуется):
forge test -vvv   # или -vvvv / -vvvvv

2. Функции для вкладчиков

Функция deposit

function deposit(
    address asset,
    uint256 amount,
    address onBehalfOf,
    uint16 referralCode
) external;

Параметр

Описание

asset

Адрес токена для депозита

amount

Количество токенов

onBehalfOf

Адрес получателя aToken (может быть ваш или любой другой)

referralCode

Код для отслеживания партнёра/интегратора

Как работает referralCode
Как работает referralCode

Запуск теста testDeposit

forge test --match-test testDeposit -vv

Итак, тест сразу падает с ошибкой

Revert 3

Для поиска решения ошибок нам нужно открыть контракт в IDE и найти описание ошибки в src/contracts/protocol/libraries/helpers/Errors.sol

string public constant VL_RESERVE_FROZEN = '3'; // 'Action cannot be performed because the reserve is frozen'

Что же это означает?

Это значит, что резерв для USDT заморожен и не может быть сделан депозит этим токеном, но как так, это же один из самых популярных стейблкоинов в Defi. А дело тут в том, что резерв USDT в Aave V2 на Ethereum Mainnet по-прежнему заморожен (на февраль 2026 г.). Это не временная мера, а долгосрочное решение после серьёзного инцидента. Почему так произошло?

Инцидент в марте 2023 года:

В Aave V2 на Ethereum произошёл эксплойт через flashloan, связанный с USDT и DAI, в результате которого было украдено ~$30 млн. Хотя протокол вернул средства за счёт страхового фонда, команда Aave приняла решение: заморозить резервы USDT и DAI в Aave V2 на Ethereum как мера предосторожности. USDT остался замороженным в отличие от DAI (который позже разморозили), USDT так и не был разморожен в Aave V2 на Ethereum. Причина — особенности контракта USDT (отсутствие return в transfer, централизованная пауза и т.д.), что делает его менее безопасным для DeFi-протоколов. Команда Aave перевела фокус на V3, где USDT доступен и не заморожен.

Но V2 на Ethereum остаётся "в режиме поддержки", и USDT в нём намеренно заблокирован. Но нам нужно как-то тестировать наши функции, и поэтому мы можем использовать другой стейблкоин, либо взять более ранний блок транзакции, в .env замените

MAINNET_BLOCK_NUMBER=16800000

Важно: Этот пример показывает нам, что в дальнейшем тестировании при возникновении ошибок мы можем понять, в чем дело и почему падают наши тесты.

Запускаем наш тест еще раз

Результат выполнения:

Before deposit:
  balance aUSDT: 0
  balance user USDT: 50,000,000

After first deposit:
  balance aUSDT: 25,000,000
  balance user USDT: 25,000,000

After second deposit:
  balance aUSDT: 50,000,000
  balance user USDT: 0

Что делает deposit внутри?

  • Валидация через validateDeposit:

    • Проверяются базовые условия перед депозитом: вызывается функция getFlags, которая возвращает 4 булевых флага, в нашем случае нам нужны 2. isActive - активен ли резерв и isFrozen - заморожен ли резерв.

    • Проверяется, что депозит ненулевой.

  • Обновление состояния резерва с помощью updateState:

    • Получает scaledVariableDebt - определяет базу для расчета процентов.

    • Сохраняет "снимок" состояния - гарантирует корректность расчетов.

    • Вычисляет новые индексы - актуализирует стоимость денег во времени.

    • Начисляет проценты в казну - формирует резервный фонд протокола.

    • Обновляет lastUpdateTimestamp - фиксирует момент последнего расчета.

  • Пересчёт ставок, вызывая функцию updateInterestRates:

    • Получает стабильный долг и среднюю ставку - учитывает «историю» фиксированных ставок.

    • Рассчитывает переменный долг через индекс - газ - эффективный расчёт текущего долга.

    • Определяет доступную ликвидность.

    • Корректирует ликвидность на изменения операции - ставки отражают итоговое состояние после транзакции.

    • Вызывает стратегию для расчёта новых ставок - динамическое ценообразование на основе спроса/предложения.

  • Перевод токенов с помощью функции SafeTransferFrom от msg.sender (от нас) на адрес aToken, в данном примере на aUSDT.

  • Проверка разрешения использования как залога, если это первый депозит.

Функция withdraw

function withdraw(
    address asset,
    uint256 amount,
    address to
) external returns (uint256);

Параметр

Описание

asset

Адрес токена для вывода

amount

Количество

to

Адрес получателя

Запускаем testWithdraw

Balances before withdraw:
balance usdt 49999950
balance aUSDT 50
Balances after withdraw:
balance usdt 50000000
balance aUSDT 0
  • Получение резерва токена aUSDT, получение баланса user`a aUSDT.

  • Проверка, допустим ли вывод , вызывая функцию validateWithdraw:

    • Проверяет, что сумма вывода ненулевая.

    • Проверяет обеспеченность баланса пользователя.

    • Проверяет активности резерва.

    • Проверяет разрешения уменьшения баланса с помощью функции balanceDecreaseAllowed - проверяет, безопасно ли уменьшить баланс залога на указанную сумму, не приведя позицию пользователя к ликвидации (healhFactor <1.0).

    • Обновляет состояние резерва.

    • Вызывает updateInterestRates.

    • Проверяет, если запрашиваемое кол-во на вывод средств равно максимальному балансу aUSDT, пользователю заблокирована возможность залога.

    • Сжигает aUSDT токены пользователя.

    • Переводит USDT токены на указанный адрес.

3. Функции для заемщиков

Функция borrow

function borrow(
    address asset,
    uint256 amount,
    uint256 interestRateMode,
    uint16 referralCode,
    address onBehalfOf
) external;

Параметр

Описание

asset

Адрес токена, который мы хотим занять

amount

Количество

interestRateMode

Процентная ставка (STABLE или VARIABLE)

referralCode

Код для отслеживания партнёра/интегратора

onBehalfOf

Адрес получателя заемного токена

  • Получение резервов выбранного нами токена , в нашем случае это USDC.

  • Вызов внутренней функции _executeBorrow:

    • Получает цену токена USDC с помощью оракла в ETH.

    • Вызывает функцию validateBorrow для проверки условий заёма:

      • Обновляет состояние резервов.

      • Далее происходит минтинг долгового токена в зависимости от режима ставки:

        Если STABLE - фиксированная ставка - проценты не накапливаются через индекс - ставка фиксирована, расчет происходит при погашении.

        Если VARIABLE - плавающая ставка - проценты накапливаются непрерывно через рост индекса, без хранения timestamp'ов.

Пользователь занял 1000 USDC при index = 1.0
Через неделю index = 1.05 (накоплено 5% годовых)
Текущий долг = 1000 * 1.05 / 1.0 = 1050 USDC

В testBorrow мы будем использовать DAI как обеспечение, так как USDT имеет LTV = 0 %, то есть его нельзя использовать как залог!

Результат:

Balances before borrow:
balance dai 0
balance aDAI 50000000000000000000
balance usdc 25000000
Balances after borrow:
balance dai 0
balance aDAI 50000000000000000000
balance usdc 40000000

Функция repay

function repay(
    address asset,
    uint256 amount,
    uint256 rateMode,
    address onBehalfOf
) external returns (uint256);

Параметр

Описание

asset

Адрес заемного базового актива, ранее заимствованного

amount

Сумма для погашения

rateMode

Режим процентной ставки по долгу, который пользователь хочет погасить: 1 для стабильного, 2 для переменного

onBehalfOf

Адрес пользователя, чей долг погашается

В testRepay мы сначала делаем депозит в DAI и получаем aDAI, затем берем заем за счет обеспечения наших aDAI токены USDC . Затем с помощью функции repay мы возвращаем заем и платим проценты. Ожидалось, что мы получим меньше aDAI, так как мы поставили таймкод 1 день - vm.warp(block.timestamp + 1 days);. Но оказалось совсем наоборот! Мы получили больше aDAI, чем было до заема! На данном этапе мы встречаем причину, почему aave - популярный defi protocol. Чтобы разобраться, мы напишем еще один тест testRepayv2 для разбора, откуда взялись дополнительные aDAI в нашей копилке.

Сценарий теста

  • Мы делаем депозит в 50 DAI и получаем 50 aDAI .

  • Затем берем заем на 25 USDC в счет залога наших 50 aDAI.

  • Проматываем время на день вперед.

  • Возвращаем заем с помощью функции repay

Результаты тестов:

Balances after deposit , before borrow:
balance dai 0
balance aDAI 50000000000000000000
balance usdc 25000000
Balances after borrow:
balance dai 0
balance aDAI 50000000000000000000
balance usdc 50000000
BEFORE TIME WARP
RESERVE STATE (AFTER WARP):
aDAI balance:   50001688841359931915
Balances after repay:
balance dai 0
balance aDAI 50001688841359931915
balance usdc 24998081
profit aDAI: 1688841359931915
withheld interest usdc:  1919
  • Как мы видим, мы получили больше aDAI на выходе - это начисленные проценты за использование наших aDAI в протоколе.

  • Получили меньше USDC: Наши изначальные 25 USDC минус проценты за взятие заёма.

Как же начисляются проценты для депозиторов?

Когда мы делаем vm.warp(1 days), ничего не происходит. Проценты не начисляются автоматически. Они «накапливаются в воздухе» и применяются только при первом действии с пулом (депозит / заем / погашение).

Еще раз

  • Мы депонируем 50 DAI, получаем 50 aDAI.

  • Внутри aDAI сохраняется масштабированный баланс = 50 (без процентов).

  • Проходит 1 день.

  • Ничего не происходит. liquidityIndex остаётся 1.0.

  • Кто-то взаимодействует с пулом, происходит триггер начисления.

  • Вызывается reserve.updateState() -> _updateIndexes().

  • Расчёт множителя для депозита:

uint256 cumulatedLiquidityInterest = MathUtils.calculateLinearInterest(currentLiquidityRate, timestamp);

calculateLinearInterest для ставки возвращает 1.02 * RAY.

uint256 timeDifference = block.timestamp.sub(uint256(lastUpdateTimestamp));
  return (rate.mul(timeDifference) / SECONDS_PER_YEAR).add(WadRayMath.ray());
  • Применяется множитель к индексу:

newLiquidityIndex = cumulatedLiquidityInterest.rayMul(liquidityIndex);
reserve.liquidityIndex = uint128(newLiquidityIndex);
  • Мы видим проценты в балансе aDAI:

uint256 aDaiBalance = aDAI.balanceOf(user1); 
  • Внутри AToken.balanceOf() происходит:

return scaledBalance.rayMul(liquidityIndex);

Итог: Мы получили + ~ 0.0005 DAI — это проценты за использование наших средств другими заемщиками. Проценты «встроены» в liquidityIndex.

Как начисляются проценты за наш заем USDC (для заемщиков):

  • Мы берём заем 25 USDC

  • Внутри VariableDebtToken сохраняется масштабированный долг = 25 (без процентов).

  • Проходит 1 день

  • Ничего не происходит. variableBorrowIndex остаётся 1.0.

  • Мы погашаем заем, вызывается updateState(), в нем _updateIndexes().

  • Расчёт множителя для займа:

uint256 cumulatedVariableBorrowInterest = MathUtils.calculateCompoundedInterest(reserve.currentVariableBorrowRate, timestamp)
  • calculateCompoundedInterest:

uint256 exp = block.timestamp.sub(uint256(lastUpdateTimestamp));
uint256 ratePerSecond = rate / SECONDS_PER_YEAR;
uint256 secondTerm = exp.mul(expMinusOne).mul(basePowerTwo) / 2;
uint256 thirdTerm = exp.mul(expMinusOne).mul(expMinusTwo).mul(basePowerThree) / 6;
return RAY.add(ratePerSecond.mul(exp)).add(secondTerm).add(thirdTerm);
  • Для ставки возвращает ~ 1.0006 * RAY.

  • Применяется множитель к индексу:

newVariableBorrowIndex = cumulatedVariableBorrowInterest.rayMul(variableBorrowIndex);
reserve.variableBorrowIndex = uint128(newVariableBorrowIndex);
  • Получение долга с процентами:

(uint256 stableDebt, uint256 variableDebt) = Helpers.getUserCurrentDebt(onBehalfOf, reserve);
  • Внутри getUserCurrentDebt() происходит:

uint256 scaledDebt = IVariableDebtToken(...).balanceOf(user); // = 25
return scaledDebt.rayMul(reserve.variableBorrowIndex); // 25 * 1.00006 = ~ 25.001919 USDC
  • Погашение полной суммы:

paybackAmount = variableDebt; // = ~ 25.001919 USDC
IERC20(asset).safeTransferFrom(msg.sender, aToken, paybackAmount);

С нашего кошелька списывается ~ 25.001919 USDC, а не 25. Мы вернули 25 USDC основного долга + ~ 0.002 USDC процентов. Проценты «встроены» в variableBorrowIndex.

Куда уходят проценты?

20% процентов уходит в казну протокола.

80% процентов распределяется между ликвидаторами через рост liquidityIndex.

Функция swapBorrowRateMode

function swapBorrowRateMode(
    address asset,
    uint256 rateMode
) external;

Параметр

Описание

asset

Адрес заимствованного базового актива

rateMode

Режим процентной ставки по долгу, который хотим поменять

Главная идея, долг не исчезает и не появляется заново. Он просто "перетекает" из одного токена в другой.

В Aave долг представлен токенами:

Если долг под Variable ставку - у нас на руках токены VariableDebtToken.

Если долг под Stable ставку - у нас на руках токены StableDebtToken.

Эта функция делает 2 вещи:

  • Сжигает (burn) наши текущие долговые токены.

  • Минтит (mint) новые долговые токены другого типа на ту же сумму (долг появляется в новой форме).

Теперь немного разберемся по шагам функции:

  • Получение данных резерва.

DataTypes.ReserveData storage reserve = _reserves[asset];

Функция обращается к хранилищу контракта (_reserves) и находит данные именно по тому активу, который мы указали (USDC). В структуре reserve хранятся адреса токенов долга(stableDebtTokenAddress, variableDebtTokenAddress), текущие индексы ставок и другая математика, необходимая для расчета.

  • Проверка текущего долга пользователя

(uint256 stableDebt, uint256 variableDebt) = Helpers.getUserCurrentDebt(msg.sender, reserve);

Вызывается getUserCurrentDebt функция, которая смотрит балансы долговых токенов у msg.sender. Мы получаем две цифры. Если у нас Variable долг, то stableDebt будет 0, а variableDebt будет равен сумме займа (у нас 25 USDC). Если у нас Stable долг, то наоборот. Нужно это для того, чтобы знать, сколько именно нужно сжечь и сколько заминтить.

  • Валидация.

    • Проверяет активен ли резерв.

    • Проверяет на какую ставку переход.

    • Проверяет доступность данной ставки.

    • Защита от злоупотребления:

require(!userConfig.isUsingAsCollateral(reserve.id) ||
reserve.configuration.getLtv() == 0 ||
stableDebt.add(variableDebt) > IERC20(reserve.aTokenAddress).balanceOf(msg.sender),
Errors.VL_COLLATERAL_SAME_AS_BORROWING_CURRENCY);

Эта проверка защищает протокол от хитрой схемы: "Заложить $100, занять $100 под переменную ставку, а потом сразу переключиться на стабильную, чтобы зафиксировать низкую ставку, имея 100% обеспечение". Протокол запрещает переход на Stable, если ваш долг меньше или равен залогу (в одном и том же активе).

  • Обновление состояния резерва.

Перед тем как поменять долг, протокол должен начислить все накопленные проценты до текущего времени. Сделано это потому что индексы ставок (variableBorrowIndex) растут со временем. Если не обновить состояние сейчас, расчет суммы нового долга будет неверным. Параметр rateMode, который мы указываем означает "Текущий режим долга, который я подтверждаю и хочу сменить".

Мы напишем 2 теста, что понять разницу происходящего.

Наш первый тест testSwapBorrowRateModev1 нам показывает:

  • У нас есть 25 USDC долга под Stable ставку. На балансе 25 токенов StableDebtToken. VariableDebtToken = 0.

  • Вызываем swapBorrowRateMode(address(usdc), 1). "У меня режим 1 (StableDebtToken), хочу сменить".

  • Проходит 1 год. Стабильная ставка начисляла проценты по фиксированному графику.

Before Swap - Stable: 25000000 Variable: 0
After Swap - Stable: 0 Variable: 27740286

Долг вырос до ~ 27.77.

Наш второй тест testSwapBorrowRateModev2 делает обратное (variable -> stable).

Before Swap - Stable: 0 Variable: 25000000
After Swap - Stable: 25710641 Variable: 0

По результатам долг оказался меньше ~ 25.71.

В итоге мы видим, что за этот год выгоднее была stable ставка, в следующем году ситуация на рынке может измениться, и чтобы не упустить тренд - существует наша функция.

Функция rebalanceStableBorrowRate

Параметр

Описание

asset

Адрес заимствованного базового актива

user

Адрес пользователя, которому необходимо выполнить ребалансировку

Эта функция - механизм защиты для кредиторов (депозиторов), а не для заемщиков. Ее цель: Принудительно обновить процентную ставку по существующему стабильному долгу пользователя до текущей рыночной ставки, если условия в пуле стали критическими.

Простыми словами, если слишком много людей взяли деньги под низкую фиксированную ставку, и из-за этого тем, кто положил деньги в банк (депозиторам), почти ничего не капает, протокол имеет право сказать: «Ваша льготная ставка отменяется, теперь вы платите столько же, сколько все остальные прямо сейчас».

По шагам:

  • Подготовка данных

DataTypes.ReserveData storage reserve = _reserves[asset];
IERC20 stableDebtToken = IERC20(reserve.stableDebtTokenAddress);
IERC20 variableDebtToken = IERC20(reserve.variableDebtTokenAddress);
address aTokenAddress = reserve.aTokenAddress;
uint256 stableDebt = IERC20(stableDebtToken).balanceOf(user);

Функция находит резерв актива и проверяет, сколько у конкретного пользователя есть долга под стабильную ставку. Если долга нет, дальше идти смысла нет (хотя проверка будет внутри валидации).

  • Валидация validateRebalanceStableBorrowRate:

    • Проверка активности резерва.

    • Расчет общей суммы долга

uint256 totalDebt = stableDebtToken.totalSupply().add(variableDebtToken.totalSupply()).wadToRay();
  • Расчет доступной ликвидности:

    • Смотрит сколько свободных денег лежит в пуле.

    • Функция проверяет баланс базового актива (например USDC) на адресе aUSDC.

    • Получаем ликвидность в Ray.

uint256 availableLiquidity = IERC20(reserveAddress).balanceOf(aTokenAddress).wadToRay();
  • Расчет коэффициента использования (Usage Ratio):

    • Вычисляет процент загруженности пула.

    • Usage Ratio = Весь долг / Весь долг + Свободные деньги.(общая ликвидность).

    • Если результат близок к 1.0 ray, то значит пул пуст.

  • Получение текущей доходности депозиторов:

uint256 currentLiquidityRate = reserve.currentLiquidityRate;
  • Получение максимальной возможной переменной ставки:

uint256 maxVariableBorrowRate = IReserveInterestRateStrategy(reserve.interestRateStrategyAddress).getMaxVariableBorrowRate();
  • Проверка на кризис:

    • Проверяет, пуст ли пул, страдают ли депозиторы.

    • Сработает, если загруженность > 95 % и доход депозиторов критически низок.

require(usageRatio >= REBALANCE_UP_USAGE_RATIO_THRESHOLD &&currentLiquidityRate <=
maxVariableBorrowRate.percentMul(REBALANCE_UP_LIQUIDITY_RATE_THRESHOLD),
Errors.LP_INTEREST_RATE_REBALANCE_CONDITIONS_NOT_MET);

Это самый важный этап. Протокол не может просто так менять ставку кому попало. Внутри этой проверки должны соблюдаться два жестких условия: Высокая нагрузка на пул: Использовано более 95% всех доступных средств в резерве (Usage ratio > 95%). То есть денег почти не осталось. Низкий доход депозиторам: Текущая доходность для тех, кто депозитит деньги, упала ниже определенного порога (REBALANCE_UP_THRESHOLD * maxVariableBorrowRate).

Почему это происходит? Потому что много людей заняли деньги давно под низкий стабильный процент (например, 2%), а текущая рыночная ставка уже 10%. Разница "съедается" протоколом, и депозиторы почти не получают прибыли. Это угрожает ликвидности. Если эти условия не выполнены, вызов функции упадет с ошибкой. Любой пользователь может вызвать эту функцию, но сработает она только при кризисе в резерве.

  • Обновление состояния

  • Ребалансировка

IStableDebtToken(address(stableDebtToken)).burn(user, stableDebt);
IStableDebtToken(address(stableDebtToken)).mint(user, user, stableDebt, reserve.currentStableBorrowRate);

Здесь происходит самое интересное. В отличие от swap, где менялся тип долга (StableVariable), здесь тип остается тем же (Stable Stable), но меняются параметры:

Burn: Сжигается старый долг пользователя с его старой низкой ставкой (например, 2%). Долг обнуляется.

Mint: Сразу же создается новый долг на ту же сумму (например, 100 USDC), но с параметром reserve.currentStableBorrowRate — то есть с текущей высокой ставкой (например, 10%).

Результат для пользователя таков, что сумма долга не меняется мгновенно. Но будущие платежи резко вырастут, так как теперь проценты капают по новой, высокой ставке.

  • Пересчет ставок пула

reserve.updateInterestRates(asset, aTokenAddress, 0, 0);

После того как часть долгов была переведена на высокую ставку, общая статистика пула меняется. Доходность для депозиторов должна вырасти. Функция пересчитывает глобальные ставки, чтобы депозиторы начали получать справедливые проценты.

Представьте ситуацию:

В пуле есть $1 млн.

$950k занято под стабильные 3% (взяты год назад).

Рыночная ставка сейчас 15%.

Депозиторы видят, что их APY упал до 0.5%, и начинают массово забирать деньги из пула, чтобы положить их куда-то еще.

Возникает риск банкротства пула.

Функция rebalanceStableBorrowRate решает эту проблему:

Кто-то (бот или любой пользователь) вызывает её.

Протокол проверяет: "Да, у нас загруженность 95%, депозиторы страдают".

Протокол говорит заемщикам: "Извините, ваша льготная ставка 3% аннулируется. Теперь вы платите 15%, как все".

Доходность пула восстанавливается, депозиторы успокаиваются и не забирают ликвидность.

Функция rebalanceStableBorrowRate — это «аварийный клапан». Она позволяет протоколу в экстренной ситуации лишить заемщиков их фиксированной низкой ставки и перевести их на текущую высокую, чтобы спасти экономику пула и обеспечить выплаты депозиторам.

Функция setUserUseReserveAsCollateral

function setUserUseReserveAsCollateral(
    address asset,
    bool useAsCollateral
) external;

Параметр

Описание

asset

Адрес депонированного базового актива

useAsCollateral

true, если пользователь хочет использовать депозит в качестве залога, false в противном случае

Здесь все просто, позволяет пользователю установить на свой aToken ограничение быть залогом. Если true - aToken может быть залогом, false - нет.

Наш тест testSetUserUseReserveAsCollateral показывает, если мы установим aDAI в false, то при попытке взять заем нам выдаст ошибку "9":

string public constant VL_COLLATERAL_BALANCE_IS_0 = '9'; // 'The collateral balance is 0'

4. Функции для ликвидаторов

Функция liquidationCall

function liquidationCall(
    address collateralAsset,
    address debtAsset,
    address user,
    uint256 debtToCover,
    bool receiveAToken
) external;

Параметр

Описание

collateralAsset

Актив залога, который получит ликвидатор

debtAsset

Актив долга, который погашает ликвидатор

user

Адрес заемщика, чья позиция ликвидируется

debtToCover

Сумма долга для погашения

receiveAToken

Флаг true - ликвидатор получит aToken, false - получит базовый актив (например USDC)

Тест testLiquidationCall

  • Мы депонируем DAI для обеспечения нашего заема, затем занимаем 37 USDC, именно это количество мы можем занять за счет нашего залога 50ти aDAI.

  • Затем мы проверяем heatlhfactor user3, он > 1.

  • Далее мы получаем адрес оракла, с помощью которого мы смотрим цены на токены.

  • Далее мы деплоим наш mock-оракл и подменяем его вместо адреса оригинального оракла. Это нужно для выполнения сценария падения цены.

  • С помощью функции setAssetPrice устанавливаем цены для DAI и USDC - 1$.

  • С помощью той же функции искусственно роняем цену dai в 10 раз. Теперь healthfactor user3 < 1.

  • Теперь ликвидатор может ликвидировать позицию.

  • Ликвидатор гасит примерно 4.8 USDC (сумма, необходимая, чтобы выкупить весь доступный залог с учетом бонуса для ликвидатора).

  • В логах тестов видно, что у user3 было 50 DAI, стало 0. То есть весь залог забрали, долг уменьшился с 37 до ~ 32 USDC.

В нашем тесте после ликвидации у пользователя остался долг ~32 USDC при нулевом залоге. Это не мгновенный убыток протокола, но рискованная ситуация. Пользователь заблокирован от новых займов до полного погашения. Если рынок упадет еще сильнее и долг превысит стоимость любого возможного будущего залога, протокол спишет остаток через Reserve Fund (Safety Module), чтобы защитить вкладчиков.

Более подробно, как происходит расчет:

Расчет производится в LendingPool, но логика в в другом контракте.

  • Инициализация данных

    • collateralReserve: Данные резерва актива, который заложен у пользователя (например, ETH).

    • debtReserve: Данные резерва актива, который пользователь занял и должен вернуть (например, USDC).

    • userConfig: Настройки пользователя (какие активы используются как залог).

  • Проверка Health Factor

  • Расчет: Функция суммирует весь залог пользователя (в USD), взвешивает его на коэффициент ликвидации (LTV), делит на общий долг (в USD).

  • Получение текущего долга пользователя. Считаем реальный долг пользователя на текущий момент с учетом накопленных процентов. Для стабильной ставки: пересчитывается индекс. Для переменной: умножается на текущий индекс займа. vars.userStableDebt + vars.userVariableDebt = Общий долг.

  • Валидация параметров ликвидации - включен ли актив залога пользователем как обеспечение, есть ли долг в debtAsset, разрешена ли ликвидация для этой пары активов.

  • Расчет максимального долга для ликвидации

vars.maxLiquidatableDebt = vars.userStableDebt.add(vars.userVariableDebt).percentMul(
LIQUIDATION_CLOSE_FACTOR_PERCENT);

Формула: MaxDebt = (Stable+Variable) * 0.5, то есть, если ликвидатор хочет покрыть $1000, а макс. можно $500 -> берем $500. Если хочет $100, а можно $500 -> берем $100. vars.actualDebtToLiquidate — это сумма, которую ликвидатор реально потратит.

  • Сколько залога получить за этот долг?

(vars.maxCollateralToLiquidate, vars.debtAmountNeeded) = _calculateAvailableCollateralToLiquidate(
collateralReserve,
debtReserve,
collateralAsset,
debtAsset,
vars.actualDebtToLiquidate,
vars.userCollateralBalance);

Здесь вызывается функция _calculateAvailableCollateralToLiquidate . Она возвращает два значения. maxCollateralToLiquidate - cколько токенов залога получит ликвидатор, debtAmountNeeded - cколько точно нужно заплатить долга.

  • Коррекция суммы долга.

if (vars.debtAmountNeeded < vars.actualDebtToLiquidate) {
vars.actualDebtToLiquidate = vars.debtAmountNeeded;}

Если в предыдущем шаге выяснилось, что залога не хватает на всю сумму debtToCover, мы уменьшаем сумму долга, которую будет платить ликвидатор, до эквивалента доступного залога.

  • Сжигание токенов пользователя, обновление процентных ставок, перевод aToken от user ликвидатору.

  • Оплата долга ликвидатором. Ликвидатор своими деньгами погашает дыру в балансе протокола, образовавшуюся из-за плохого долга пользователя. Протокол становится "целым" по долговому активу.

FlashLoan

Функция flashLoan

function flashLoan(
    address receiverAddress,
    address[] calldata assets,
    uint256[] calldata amounts,
    uint256[] calldata modes,
    address onBehalfOf,
    bytes calldata params,
    uint16 referralCode
) external;

Параметр

Описание

receiverAddress

Адрес контракта, который получит деньги

assets

Массив адресов токенов

amounts

Массив сумм для каждого токена

modes

Вариация действий, если деньги не вернут

onBehalfOf

Адрес, который берет долг

params

Данные для контракта

referralCode

Код для отслеживания партнёра/интегратора

По шагам:

  • Валидация проверяются входные данные (массивы активов, сумм и режимов займа) через ValidationLogic.

  • Расчет комиссии - для каждого актива вычисляется премия (premium) по формуле:

premium = (amount * flashLoanPremiumTotal) / 1000

(Обычно комиссия составляет 0.09%).

  • Трансфер средств - базовые активы переводятся из резервов пула на адрес контракта-получателя (receiverAddress) через вызов transferUnderlyingTo.

  • Вызов получателя - пул вызывает функцию executeOperation на контракте получателя. Именно здесь размещается пользовательская логика (свопы, ликвидация и т.д.).

  • Погашение долга (Post-Execution) - после возврата управления из executeOperation, пул проверяет баланс. Возможны два сценария:

    • Режим NONE (mode 0): Контракт должен вернуть сумму займа + комиссию. Пул обновляет индексы ликвидности и забирает средства через safeTransferFrom.

    • Режим STABLE/VARIABLE (mode 1/2): Если средства не возвращены, система пытается открыть долговую позицию (borrow) на сумму займа, проверяя обеспечение. Если залога недостаточно — транзакция ревертится.

Теперь к нашему тесту, для этого мы создали отдельный тест flashloan.t.sol

forge test flashloan.t.sol -vv
Start  Arbitrage Simulation
1. Contract Balance BEFORE FlashLoan: 0
MockDEX: Transferred profit to contract: 5000000
2. Contract Balance AFTER FlashLoan: 4100000
3. Gross Profit generated: 5000000
4. Premium paid to Aave: 900000
5. Net Profit remaining: 4100000
  • Контракт исполнителя: aave-v2/protocol-v2/test/FlashLoanArbitrage/FlashLoanArbitrage.sol.

  • Наш контракт реализует интерфейс IFlashLoanReceiver. Его задача — получить ликвидность, выполнить арбитражную операцию и вернуть долг.

  • Инициализация - в конструкторе задаются адреса пула и оракла. Оракл используется для симуляции расхождения цен (условие для арбитража).

  • Запуск заема (executeArbitrage):

    • Обновляет цену актива в мок-оракле (создает искусственный дисбаланс).

    • Формирует массивы активов и сумм.

    • Устанавливает modes[0] = 0, что означает требование полного возврата средств в конце транзакции.

Логика исполнения искусственного арбитража описана в комментариях FlashLoanArbitrage.sol.

Используется только чужая ликвидность. Если стратегия убыточна, транзакция ревертится, и мы теряем только газ. Все действия (заем -> своп -> возврат) происходят в одной транзакции. Функция flashLoan в контракте пула выступает в роли диспетчера. Её главная задача — обеспечить атомарность операции: средства должны быть выданы, использованы и возвращены с комиссией в рамках одной транзакции.

Сценарий flashLoan
Сценарий flashLoan

Подводя итоги, мы узнали, что Aave V2 обладает широким функционалом, в рамках которого мы можем реализовывать свои идеи: от получения прибыли при внесении ликвидности в протокол до стратегии арбитража.

Спасибо за уделенное время, буду рад вопросам в комментариях, а так же конструктивной критике.