Разработка бота ликвидаций для Compound
На Compound v3 (Comet) в момент резкого движения рынка — скажем, ETH падает на 15% за час — сотни позиций одновременно пересекают порог ликвидации. Первый ликвидатор, который успел вызвать absorb, забирает дисконтированные коллатеральные активы. Второй — ничего не получает. Это конкурентная среда с миллисекундами как единицей измерения: Compound-ликвидации ловят MEV-боты с прямыми подключениями к builder'ам и кастомными Rust-имплементациями. Разработать бота, который работает в этой среде — нетривиальная задача.
Механика ликвидаций в Compound v3
Compound v3 кардинально отличается от v2 по модели ликвидаций. В v2 был liquidateBorrow — ликвидатор погашает долг за заёмщика и получает его коллатераль с liquidation bonus (8-15%). В v3 (Comet) введена двухшаговая модель:
Шаг 1: absorb. Любой адрес может вызвать absorb(address absorber, address[] calldata accounts) для несостоятельных позиций. Comet принимает коллатераль на свой баланс, списывает долг, и absorber получает вознаграждение из reserves протокола.
Шаг 2: buyCollateral. После absorb коллатераль доступна для покупки через buyCollateral(address asset, uint minAmount, uint baseAmount, address recipient) по цене ниже рыночной — дисконт определяется storeFrontPriceFactor (обычно 90-95% от цены Chainlink oracle).
Ключевое: profit на ликвидации в v3 приходит от arbitrage между ценой покупки у Comet и рыночной ценой. Это означает, что бот должен атомарно: вызвать buyCollateral, продать полученный актив на DEX, вернуть base token. Flash loan из Aave или Uniswap v3 для финансирования buyCollateral — стандартный паттерн.
Определение несостоятельных позиций
Позиция несостоятельна, когда borrowing capacity ниже долга. Compound v3 предоставляет isLiquidatable(address account) returns (bool) и getBorrowableOf(address account, address asset, uint amount) returns (uint). Для мониторинга сотен тысяч позиций поллинг каждой через eth_call — непрактично.
Эффективный подход — event-based мониторинг: слушаем Supply, Withdraw, Transfer events от Comet, обновляем локальную копию состояний позиций. Когда цена коллатераля падает (событие Chainlink AnswerUpdated) — пересчитываем health factor для позиций с этим коллатералем. Структура данных — sorted set в Redis по health factor, что позволяет за O(log n) находить ближайшие к ликвидации позиции.
Архитектура бота
Мониторинг позиций
Два уровня: The Graph subgraph для исторических данных и начальной загрузки, WebSocket подписка на события через ethers.js provider.on или viem watchContractEvent для реального времени. Subgraph обновляется с задержкой в 1-3 блока — для конкурентных ликвидаций этого достаточно, бот должен быть быстрее при фактическом движении цены.
Chainlink price feeds — через AggregatorV3Interface с проверкой updatedAt (staleness check). Если oracle не обновлялся более heartbeat периода — цену не используем, позиции не ликвидируем. Compound v3 сам имеет staleness protection, но бот должен проверять независимо.
Smart contract ликвидатора
Атомарная ликвидация через flash loan:
contract CompoundLiquidator {
function liquidate(
address comet,
address[] calldata accounts,
address collateralAsset,
uint baseAmount,
address flashLoanPool // Uniswap v3 pool
) external {
// 1. Flash loan base token из Uniswap v3
// 2. absorb(address(this), accounts)
// 3. buyCollateral(collateralAsset, minOut, baseAmount, address(this))
// 4. Своп коллатераля -> base token через DEX
// 5. Возврат flash loan + fee
// 6. Profit идёт на msg.sender или treasury
}
}
Критичная деталь: absorb и buyCollateral — это два отдельных вызова. Между ними другой бот может купить коллатераль. Нужно либо проверять доступный коллатераль перед покупкой (quoteCollateral), либо принять, что в редких случаях транзакция ревертируется.
Gas оптимизация и MEV
Compound-ликвидации на Ethereum mainnet — конкурентная MEV-арена. Ключевые факторы:
| Фактор | Наивный подход | Оптимизированный |
|---|---|---|
| Обнаружение | Polling каждые 12 сек | WebSocket + event-based |
| Submission | Public mempool | Flashbots bundle |
| Gas price | Фиксированный | Dynamic (80th percentile + boost) |
| Execution | EOA транзакция | Контракт-ликвидатор (1 tx) |
Для Arbitrum ситуация иная: sequencer — централизованный, MEV-конкуренция ниже, но FCFS (first-come-first-served) модель означает, что важна latency до sequencer endpoint. Держим ноду в том же датацентре, что и Arbitrum sequencer.
Profitability фильтр
Не каждая ликвидируемая позиция прибыльна. Перед отправкой транзакции рассчитываем:
profit = buyCollateralValue * (1 - storeFrontPriceFactor) - flashLoanFee - gasCost - swapSlippage
Если profit < threshold (обычно $50-100 с учётом риска) — пропускаем. Иначе бот будет сжигать газ на неприбыльных ликвидациях в периоды высоких fees.
Процесс работы
Анализ архитектуры Compound v3 (2-3 дня). Изучаем ABI, тестируем вызовы на Goerli/Sepolia, разворачиваем fork-среду через Foundry anvil.
Мониторинг системы (1 неделя). Event listener, Redis для состояний позиций, health factor calculator с учётом всех типов коллатераля.
Smart contract ликвидатора (1 неделя). Разработка, unit-тесты в Foundry, fork-тесты с реальными позициями mainnet.
Integration и dry-run (3-5 дней). Запуск в режиме симуляции — бот находит позиции, рассчитывает profit, но не отправляет транзакции. Проверяем точность расчётов.
Деплой и мониторинг (2-3 дня). Деплой контракта, запуск бота, настройка алертов на ошибки и аномалии.
Ориентиры по срокам
Базовый бот для Compound v3 на одном чейне — 2.5-3 недели. Мультипротокольный бот (Compound + Aave + Euler) с оптимизацией под MEV — 6-8 недель. Реализация для Arbitrum/Base добавляет 1 неделю на каждый чейн.
Типичные ошибки
Игнорирование cooldown после absorb. Compound v3 устанавливает внутренние ограничения на частоту absorb для одного счёта — частые вызовы могут ревертироваться. Нужна логика retry с backoff.
Неверный расчёт минимального выхода. buyCollateral принимает minAmount коллатераля. Если поставить слишком жёстко — транзакция ревертируется при малейшем движении oracle. Слишком мягко — открываем себя для sandwich. Динамический расчёт на основе текущей oracle price с 1-2% buffer — правильный подход.
Не проверяется reserves протокола. absorb возможен только если у протокола достаточно reserves для покрытия вознаграждения liquidator'у. При пустых reserves вызов ревертируется. Нужна проверка getReserves() перед отправкой.







