Резюме: В статье разобран механизм работы криптокредитования и ликвидаций в Web3-протоколах (Aave v3, Morpho Blue). Рассмотрены ключевые метрики (Health Factor, LTV, Liquidation Threshold), сценарии проскальзывания при сбоях оракулов Chainlink, а также представлен Python-скрипт мониторинга для автоматической ликвидации позиций.
Как работает криптокредитование и риски ликвидации
Криптокредит — это не классический банк, где дежурный клерк смотрит на справку 2-НДФЛ и одобряет ипотеку под залог бабушкиной квартиры. В Web3 всем абсолютно плевать на твое имя, кредитную историю и планы на жизнь. Здесь работает только сухая математика смарт-контрактов: дал залог (collateral), получил избыточный заем (overcollateralized loan), не уследил за Health Factor — боты-ликвидаторы с потрохами сожрут твое обеспечение за доли миллисекунды.
Возьмем классическую схему на Aave v3 или Morpho Blue. Ты закидываешь $10,000 в WBTC, чтобы взять $6,000 в USDC. Зачем? Чтобы не продавать биткоин, не платить налог на прирост капитала и одновременно получить кэш на туземун очередного щиткоина или покупку железа. Выглядит как грааль. На бумаге.
А потом происходит стандартный пролив рынка на 18% за 15 минут. Ликвидность вымывается из стаканов. Оракул Chainlink обновляет цену с задержкой в пару блоков из-за спама в сети, и твоя позиция летит в ликвидационный ад.
Анатомия ликвидации: почему залог улетает мгновенно
Главная метрика, за которой ты обязан следить 24/7 — это Health Factor (HF). Formula банальна:
HF = ∑ (Collaterali × LTVi) / Total Borrowed
Если HF > 1, ты в безопасности. Если HF ≤ 1, твоя позиция становится доступной для ликвидации абсолютно любому участнику сети, у кого задеплоен соответствующий скрипт.
| Параметр | Пояснение | Реальный пример (ETH пул) |
|---|---|---|
| Max LTV | Максимальный процент займа от суммы залога | 80% ($800 займа на $1000 ETH) |
| Liquidation Threshold (LT) | Порог, при котором позиция становится ликвидируемой | 82.5% |
| Liquidation Bonus | Скидка на залог, которую получает ликвидатор | 5% |
| Reserve Factor | Доля протокола от процентного дохода | 15% |
Хотя нет, постой. На бумаге 82.5% звучит запасно, но в реале надо учитывать фантомные проскальзывания и мейннет-газ. Когда оракул push-типа пушит обновленный прайс в контракт, MEV-боты уже стоят в очереди в Flashbots-бундле. Они выкупают твою позицию с 5% дисконтом, моментально гасят долг через флеш-займ (flash loan) на Uniswap v3 и забирают чистую разницу себе.
Результат? Ты остался с USDC, которые ты уже успел куда-то всадить, но твоего WBTC больше нет. Протокол забрал штраф, бот забрал спред. Ты в минусе.
Скрипт ликвидатора: как боты видят твою слабость
Сам полгода назад наблюдал, как на Arbitrum один кривой оракул отправил в молоко депозитов на 200k баксов буквально за три блока. Скрипт ликвидации пишется на коленке, но решают миллисекунды и прямой доступ к MEV-реле.
Вот рабочий прототип на Python через web3.py, который мониторит заемщика в LendingPool и моментально готовит транзакцию, если HF упал ниже единицы.
import os
import time
from web3 import Web3
from web3.exceptions import ContractLogicError, TransactionNotFound
# ------------------------------------------------------------------------------
# 1. Конфигурация и подсоединение
# ------------------------------------------------------------------------------
RPC_URL = os.getenv("RPC_URL", "https://arb-mainnet.g.alchemy.com/v2/YOUR_API_KEY")
PRIVATE_KEY = os.getenv("PRIVATE_KEY", "0x_YOUR_PRIVATE_KEY")
w3 = Web3(Web3.HTTPProvider(RPC_URL))
if not w3.is_connected():
raise RuntimeError("RPC connection failed. Check your RPC_URL.")
ACCOUNT = w3.eth.account.from_key(PRIVATE_KEY)
# Контракты Aave v3 на Arbitrum
POOL_ADDRESS = Web3.to_checksum_address("0x794a61358D6845594F94dc1DB02A252b5b4814aD")
DATA_PROVIDER_ADDRESS = Web3.to_checksum_address("0x69FA0fee221AD11012B2f52097e8F6B7EAcF57E7")
TARGET_USER = Web3.to_checksum_address("0x0000000000000000000000000000000000000000")
COLLATERAL_ASSET = Web3.to_checksum_address("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f") # WBTC
DEBT_ASSET = Web3.to_checksum_address("0xaf88d065e77c8cC2239327C5EDb3A432268e5831") # USDC
# ------------------------------------------------------------------------------
# 2. ABI
# ------------------------------------------------------------------------------
POOL_ABI = [
{
"inputs": [{"internalType": "address", "name": "user", "type": "address"}],
"name": "getUserAccountData",
"outputs": [
{"internalType": "uint256", "name": "totalCollateralBase", "type": "uint256"},
{"internalType": "uint256", "name": "totalDebtBase", "type": "uint256"},
{"internalType": "uint256", "name": "availableBorrowsBase", "type": "uint256"},
{"internalType": "uint256", "name": "currentLiquidationThreshold", "type": "uint256"},
{"internalType": "uint256", "name": "ltv", "type": "uint256"},
{"internalType": "uint256", "name": "healthFactor", "type": "uint256"}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{"internalType": "address", "name": "collateralAsset", "type": "address"},
{"internalType": "address", "name": "debtAsset", "type": "address"},
{"internalType": "address", "name": "user", "type": "address"},
{"internalType": "uint256", "name": "debtToCover", "type": "uint256"},
{"internalType": "bool", "name": "receiveAToken", "type": "bool"}
],
"name": "liquidationCall",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}
]
DATA_PROVIDER_ABI = [
{
"inputs": [
{"internalType": "address", "name": "asset", "type": "address"},
{"internalType": "address", "name": "user", "type": "address"}
],
"name": "getUserReserveData",
"outputs": [
{"internalType": "uint256", "name": "currentATokenBalance", "type": "uint256"},
{"internalType": "uint256", "name": "currentStableDebt", "type": "uint256"},
{"internalType": "uint256", "name": "currentVariableDebt", "type": "uint256"},
{"internalType": "uint256", "name": "principalStableDebt", "type": "uint256"},
{"internalType": "uint256", "name": "scaledVariableDebt", "type": "uint256"},
{"internalType": "uint256", "name": "stableBorrowRate", "type": "uint256"},
{"internalType": "uint256", "name": "liquidityRate", "type": "uint256"},
{"internalType": "uint40", "name": "stableRateLastUpdated", "type": "uint40"},
{"internalType": "bool", "name": "usageAsCollateralEnabled", "type": "bool"}
],
"stateMutability": "view",
"type": "function"
}
]
ERC20_ABI = [
{
"inputs": [
{"internalType": "address", "name": "owner", "type": "address"},
{"internalType": "address", "name": "spender", "type": "address"}
],
"name": "allowance",
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{"internalType": "address", "name": "spender", "type": "address"},
{"internalType": "uint256", "name": "amount", "type": "uint256"}
],
"name": "approve",
"outputs": [{"internalType": "bool", "name": "", "type": "bool"}], # FIX: Иправлен тип на bool
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [{"internalType": "address", "name": "account", "type": "address"}],
"name": "balanceOf",
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function"
}
]
pool = w3.eth.contract(address=POOL_ADDRESS, abi=POOL_ABI)
data_provider = w3.eth.contract(address=DATA_PROVIDER_ADDRESS, abi=DATA_PROVIDER_ABI)
debt_token = w3.eth.contract(address=DEBT_ASSET, abi=ERC20_ABI)
tx_in_flight = False
# ------------------------------------------------------------------------------
# 3. Безопасные хелперы газа и нонса
# ------------------------------------------------------------------------------
def get_fee_parameters():
"""Запрашивает параметры EIP-1559 с фоллбэком для сетей без baseFeePerGas."""
latest_block = w3.eth.get_block('latest')
base_fee = latest_block.get('baseFeePerGas')
if base_fee is not None:
max_priority_fee = w3.to_wei(0.1, 'gwei')
max_fee = base_fee * 2 + max_priority_fee
return {
'maxFeePerGas': max_fee,
'maxPriorityFeePerGas': max_priority_fee
}
else:
# Фоллбэк для Legacy сетей / кастомных L2
return {'gasPrice': w3.eth.gas_price}
def ensure_allowance(required_amount: int) -> bool:
"""Проверяет и ставит approve с обязательной проверкой receipt status."""
current_allowance = debt_token.functions.allowance(ACCOUNT.address, POOL_ADDRESS).call()
if current_allowance >= required_amount:
return True
print("[*] Недостаточно allowance. Отправляем approve...")
nonce = w3.eth.get_transaction_count(ACCOUNT.address, 'pending')
tx_params = {
'chainId': w3.eth.chain_id,
'from': ACCOUNT.address,
'nonce': nonce,
**get_fee_parameters()
}
tx = debt_token.functions.approve(POOL_ADDRESS, 2**256 - 1).build_transaction(tx_params)
signed_tx = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=30)
if receipt['status'] == 1:
print(f"[+] Approve успешно подтвержден в блоке {receipt['blockNumber']}")
return True
else:
print(f"[!] ОШИБКА: Approve транзакция отклонена (reverted)!")
return False
# ------------------------------------------------------------------------------
# 4. Основная логика вызова ликвидации
# ------------------------------------------------------------------------------
def execute_liquidation(user_address: str):
global tx_in_flight
if tx_in_flight:
print("[!] Локальный процесс заблокирован (транзакция уже отправлена).")
return
# FIX #9: Быстрая перепроверка HF перед дорогой подготовкой
account_data = pool.functions.getUserAccountData(user_address).call()
health_factor = account_data[5] / 10**18
if health_factor >= 1.0:
print(f"[-] Отмена: HF позиции изменился на {health_factor:.6f} (>= 1.0)")
return
# FIX #11: Расчет точного долга в токенах USDC
reserve_data = data_provider.functions.getUserReserveData(DEBT_ASSET, user_address).call()
actual_debt_tokens = reserve_data[1] + reserve_data[2] # stable + variable
if actual_debt_tokens == 0:
print("[!] Долг по выбранному токену отсутствует.")
return
debt_to_cover = actual_debt_tokens // 2
# Проверка собственного баланса USDC
our_balance = debt_token.functions.balanceOf(ACCOUNT.address).call()
if our_balance < debt_to_cover:
print(f"[!] Мало USDC на балансе. Нужно: {debt_to_cover}, Есть: {our_balance}")
debt_to_cover = our_balance
if debt_to_cover == 0:
return
# FIX #8: Проверка успешности исполнения approve
if not ensure_allowance(debt_to_cover):
return
# Используем 'pending' nonce для защиты от конфликтов внутри аккаунта
nonce = w3.eth.get_transaction_count(ACCOUNT.address, 'pending')
tx_params = {
'chainId': w3.eth.chain_id,
'from': ACCOUNT.address,
'nonce': nonce,
**get_fee_parameters()
}
# FIX #5: Симуляция непосредственно перед сборкой
try:
estimated_gas = pool.functions.liquidationCall(
COLLATERAL_ASSET,
DEBT_ASSET,
user_address,
debt_to_cover,
False
).estimate_gas(tx_params)
tx_params['gas'] = int(estimated_gas * 1.2)
except ContractLogicError as e:
print(f"[!] Смарт-контракт отклонил симуляцию liquidationCall: {e}")
return
except Exception as e:
print(f"[!] Ошибка при оценке газа: {e}")
return
# Сборка и отправка
try:
tx_in_flight = True
tx_data = pool.functions.liquidationCall(
COLLATERAL_ASSET,
DEBT_ASSET,
user_address,
debt_to_cover,
False
).build_transaction(tx_params)
signed_tx = w3.eth.account.sign_transaction(tx_data, PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
print(f"[+] Транзакция отправлена! Tx Hash: {tx_hash.hex()}")
# FIX #10: Анализ статуса рецепта ликвидации
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=30)
if receipt['status'] == 1:
print(f"[УСПЕХ] Позиция успешно ликвидирована в блоке {receipt['blockNumber']}!")
else:
print(f"[ПРОВАЛ] Транзакция смайнена, но вернула REVERT (Status 0). Газ сгорел.")
except Exception as e:
print(f"[!] Критическая ошибка отправки: {e}")
finally:
tx_in_flight = False
# ------------------------------------------------------------------------------
# 5. Мониторинг
# ------------------------------------------------------------------------------
def monitor_user(user_address: str):
account_data = pool.functions.getUserAccountData(user_address).call()
health_factor = account_data[5] / 10**18
print(f"Target: {user_address} | Health Factor: {health_factor:.6f}")
if health_factor < 1.0:
print("[!] ОБНАРУЖЕНА ПОЗИЦИЯ ДЛЯ ЛИКВИДАЦИИ!")
execute_liquidation(user_address)
if __name__ == "__main__":
while True:
try:
monitor_user(TARGET_USER)
time.sleep(1)
except KeyboardInterrupt:
print("\n Остановка бота пользователем.")
break
except Exception as e:
print(f"ERROR: {e}")
time.sleep(3)В L2 мемпула нет. Вообще. Всё работает по FCFS. Кто первый встал — того и тапки.
И пока твой скрипт тупит на time.sleep(1), специализированные HFT-боты сидят на сокетах в той же стойке, где крутится валидатор. Шансов вручную перегнать маржин-колл у тебя НОЛЬ.
Где реально спрятана выгода (и почему все продолжают брать займы)
Зачем так рисковать?
- Арбитраж ставок (Interest Rate Arbitrage). Ты берешь USDT под 4% APY на платформе A и кладешь в Vault под 12% APY на платформе B. Дельта в 8% твоя. Но тут зарыт риск смарт-контракта: если платформу B депегнут или взломают через reentrancy, ты останешься должен платформе A реальные бабки.
- Делегирование ликвидности и Токенизация залога. Взгляните на тот же Pendle или Morpho. Ты закладываешь stETH, получаешь стейблы, на них покупаешь PT (Principal Tokens) с фиксированной доходностью. Сложно? Да. Доходно? До первых проблем с оракулом LST-токенов.
- Налоговая оптимизация. Фиксация прибыли = уплата налогов. Займ под залог подорожавшего актива = отсутствие факта продажи. Нет продажи — нет налога. Прагматично? Абсолютно.
Но люди забывают про каскадные ликвидации. В мае 2021-го и на крахе FTX целые протоколы становились неплатежеспособными (bad debt), потому что цена залога падала быстрее, чем боты успевали закрывать долги. В итоге пострадали простые лэндеры, которые просто дали свои стейблы под процент.
Изолированные маржинальные пулы vs Cross-Margin: где зарыта мина
Многие новички влипают на банальном незнании архитектуры учета рисков. Есть два принципиально разных подхода: Cross-Margin (общий котел) и Isolated Markets (изолированные рынки вроде Morpho или Euler v2).
В Cross-Margin (дефолт на Aave) весь твой залоговый портфель работает как единый щит. Закинул WBTC, ETH и DAI — взял USDC. Если просядет только WBTC, тебя подстрахует подушка из DAI. Удобно? Да. Но если один из твоих залоговых активов катится в бездну (вспомним депег stETH в 2022 или скам какого-нибудь bridged-токена), он тянет за собой ВСЮ позицию. Ликвидируют всё подчистую.
В Isolated-пулах ты изолируешь риск конкретной пары. Например, кастомный ваулт wstETH / USDC.
[Твой депо] ---> [Изолированный пул A (ETH/USDC)] ---> Риск ограничен пулом A
---> [Изолированный пул B (PEPE/USDC)] ---> Скам PEPE = минус только пул BЕсли условный щиткоин в пуле B летит на 99% вниз — ты теряешь только то, что положил в пул B. Твой основной эфир в пуле A сидит в полной безопасности.
Да, управлять тремя разными позициями — геморрой. Газа уходит больше. Но это единственная адекватная стратегия, когда ты работаешь с высокой волатильностью.
Оракулы: где вас скамят без взлома смарт-контракта
Смарт-контракт лендинга сам по себе глух и слеп. Он понятия не имеет, сколько стоит ETH прямо сейчас. Он верит оракулу. И вот тут начинается самое интересное.
Основные проблемы оракулов, на которых горят депозиты:
- Stale Prices (Протухшие цены). Оракул пушит цену не каждую секунду (это разорит любого провайдера на газе), а при отклонении, скажем, на 0.5% или раз в 24 часа (heartbeat). Если рынок совершает импульсивный прострел вниз на 5% за 3 секунды и тут же откупается, оракул может обновить цену НА САМОМ ДНЕ. Контракт видит «0.99 HF», дает отмашку ликвидаторам, тебя закрывают. Рынок отскочил, а ты остался у разбитого корыта.
- Illiquid DEX Feeds (Манипуляция спотом). Если протокол использует TWAP-оракул с Uniswap v3 для малоликвидного токена, кит может одной крупной продажей задвинуть цену в пол, вызвать каскад ликвидаций в лендинге, а потом откупить актив обратно. Это классическая классика.
Таблица ниже показывает реальную математику потерь при ликвидации на разных уровнях просадки залога (при стартовом LTV 75% и Liquidation Bonus 5%):
| Падение цены залога | Health Factor | Статус позиции | Потери заемщика от начального депозита |
|---|---|---|---|
| -5% | 1.26 | Безопасно | 0% (только нереализованный убыток залога) |
| -15% | 1.13 | Зона риска | 0% (нужна доливка) |
| -25% | 0.99 | ЛИКВИДАЦИЯ | ~15-20% (штраф ликвидатора + спред) |
| -40% (Flash Crash) | < 0.80 | Полный выжиг | 100% залога утеряно (остался только заем) |
Чек-лист выживания: как брать кредит и не сесть на колик
Если уж лезешь в децентрализованный заем, держи жесткие правила гигиены. Я сам их выжег на собственной шкуре после пары неприятных ревертов и принудительных закрытий.
- Держи HF не ниже 1.5 - 1.8. Забудь про игру на грани при HF 1.05. Любая сопля на графике — и ты труп.
- Автоматизируй доливку (Self-Kicker). Используй сервисы вроде Gelato Automation или Chainlink Automation. Напиши простейший бот, который при падении HF ниже 1.2 переводит стейблы с твоего кошелька в контракт для погашения части долга.
- Мониторь оракулы. Знай, какой оракул стоит в твоем пуле. Если там Chainlink push-type — смотри heartbeat. Если Pyth — проверяй confidence intervals.
- Хеджируй через перпы. Взял заем под ETH? Открой шорт по ETH на деривативах с плечом 1x на сумму займа. Это залочит твои риски в долларовом эквиваленте (delta-neutral).
Криптокредит — это мощнейший рычаг для капитала, но в руках неопытного пользователя он превращается в гильотину с таймером. Контракту всё равно на твои ожидания рынка.