Розробка бота ліквідацій для Compound
На Compound v3 (Comet) у момент різкого руху ринку — скажімо, ETH падає на 15% за годину — сотні позицій одночасно перетинають поріг ліквідації. Перший ліквідатор, який успів викликати absorb, забирає дисконтовані collateral активи. Другий — не отримує нічого. Це конкурентне MEV-середовище з мілісекундами як одиницею вимірювання: Compound-ліквідації ловлять MEV-боти з прямими підключеннями до builder'ів та кастомними Rust-реалізаціями. Розробити бота, який працює в цьому середовищі — нетривіальна задача.
Механіка ліквідацій у Compound v3
Compound v3 кардинально відрізняється від v2 за моделлю ліквідацій. У v2 був liquidateBorrow — ліквідатор погашує борг за позичальника та отримує його collateral з liquidation bonus (8-15%). У v3 (Comet) введена двохшагова модель:
Крок 1: absorb. Будь-яка адреса може викликати absorb(address absorber, address[] calldata accounts) для неспроможних позицій. Comet приймає collateral на свій баланс, списує борг, і absorber отримує нагороду з reserves протоколу.
Крок 2: buyCollateral. Після absorb collateral доступна для покупки через buyCollateral(address asset, uint minAmount, uint baseAmount, address recipient) за ціною нижче ринкової — дисконт визначається storeFrontPriceFactor (зазвичай 90-95% від ціни Chainlink oracle).
Ключове: прибуток на ліквідації у 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» события з Comet, обновляємо локальні копії позицій. Коли ціна collateral змінюється (событие Chainlink AnswerUpdated`) — перерахуємо health factor для позицій з цим collateral. Структура даних — sorted set у Redis за health factor, що дозволяє за O(log n) знаходити позиції близько до ліквідації.
Архітектура бота
Моніторинг позицій
Два рівні: The Graph subgraph для історичних даних та початкової загрузки, WebSocket підписка на события через ethers.js provider.on або viem watchContractEvent для real-time. 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. Своп collateral -> base token через DEX
// 5. Повернення flash loan + fee
// 6. Profit йде на msg.sender або treasury
}
}
Критична деталь: absorb та buyCollateral — два окремих виклики. Між ними інший бот може купити collateral. Потрібно або перевіряти доступний collateral перед покупкою (quoteCollateral), або приймати, що в редких випадках транзакція ревертується.
Gas оптимізація та MEV
Compound-ліквідації на Ethereum mainnet — конкурентна MEV-арена. Ключові фактори:
| Фактор | Наївний підхід | Оптимізований |
|---|---|---|
| Обнаруження | Polling кожні 12 сек | WebSocket + event-based |
| Submission | Публічний 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 < поріг (зазвичай $50-100 з врахуванням ризику) — пропускаємо. Інакше бот буде сжигати газ на неприбуткових ліквідаціях в періоди високих fees.
Процес роботи
Аналіз архітектури Compound v3 (2-3 дні). Вивчаємо ABI, тестуємо вызови на Goerli/Sepolia, розгортаємо fork-середовище через Foundry anvil.
Мониторінг система (1 тиждень). Event listener, Redis для станів позицій, health factor калькулятор з врахуванням всіх типів collateral.
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 collateral. Якщо поставити занадто строго — транзакція ревертується при мінімальному русі oracle. Занадто м'яко — відкриваємо себе для sandwich. Динамічний розрахунок на основі поточної oracle price з буфером 1-2% — правильний підхід.
Не перевіряється reserves протоколу. absorb» можливий тільки якщо у протоколу достатньо reserves для покриття нагороди liquidator'у. При пустих reserves виклик ревертується. Потрібна перевірка getReserves()` перед відправкою.







