Нажмите ESC, чтобы закрыть

Как работает криптовалютный кредит: Выгода или Риск? LTV и MEV

Резюме: В статье разобран механизм работы криптокредитования и ликвидаций в 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).

Криптокредит — это мощнейший рычаг для капитала, но в руках неопытного пользователя он превращается в гильотину с таймером. Контракту всё равно на твои ожидания рынка.

Сделать краткую выжимку этой статьи с помощью:

FAQ

Ликвидация в Aave v3 запускается автоматически, когда показатель Health Factor позиции опускается ниже 1,0 из-за падения цены залога или роста стоимости заемного актива. Внешние ликвидаторы вызывают функцию liquidationCall на контракте Pool, погашая до 50% накопленного долга пользователя в обмен на изъятие эквивалента его залогового обеспечения с премией (Liquidation Bonus) от 1% до 15%.

Health Factor рассчитывается путем умножения общей стоимости залога в ETH на его взвешенный порог ликвидации (Liquidation Threshold) с последующим делением полученного значения на совокупный долг в ETH. Для поддержания коэффициента выше 1,0 заемщик должен отслеживать рыночную волатильность, вносить дополнительный допустимый залог или частично гасить задолженность через функцию repay до момента отправки транзакции ликвидатором.

Прибыльность ликвидации определяется разницей между полученной залоговой премией, совокупными сетевыми расходами по модели EIP-1559 и проскальзыванием (slippage) при обмене активов на DEX. Разработчики ботов проводят статическую симуляцию вызова через метод estimateGas перед отправкой транзакции, гарантируя, что значения maxFeePerGas и maxPriorityFeePerGas не превышают чистую прибыль от изъятия залога.
Piter Wacker

I am a trading specialist with expertise in market analysis, risk management, and investment strategies. I focus on identifying opportunities, executing trades with discipline, and delivering consistent results.

...

Поделитесь своим мнением

Ваш e-mail не будет опубликован. Обязательные поля отмечены *