TL;DR: W artykule rozkładamy na czynniki pierwsze mechanizm pożyczek kryptowalutowych i likwidacji w protokołach Web3 (Aave v3, Morpho Blue). Analizujemy kluczowe metryki (Health Factor, LTV, Liquidation Threshold), scenariusze poślizgu cenowego (slippage) przy opóźnieniach wyroczni Chainlink oraz przedstawiamy skrypt w Pythonie do automatycznego wyłapywania i likwidacji zagrożonych pozycji.
Jak działają pożyczki w DeFi i skąd bierze się ryzyko likwidacji
Pożyczka w crypto to nie tradycyjny bank, gdzie pani w okienku analizuje Twoje zarobki i daje hipotekę pod zastaw mieszkania babci. W Web3 nikt nie pyta o Twoje nazwisko, historię w BIK-u czy życiowe plany. Liczy się tylko chłodna matematyka smart kontraktów: dajesz zastaw (collateral), bierzesz pożyczkę z nadwyżką zabezpieczenia (overcollateralized loan) i jeśli prześpisz spadek Health Factor — boty likwidacyjne zjedzą Twoje depozyty ze smakiem w ułamku milisekundy.
Weźmy klasyczny układ na Aave v3 albo Morpho Blue. Wrzucasz $10,000 w WBTC, żeby wyciągnąć $6,000 w USDC. Po co? Żeby nie sprzedawać bitcoina, nie wywoływać zdarzenia podatkowego i jednocześnie mieć gotówkę na wpakowanie się w kolejnego shitcoina albo zakup nowego sprzętu. Wygląda jak Święty Graal. Na papierze.
A potem wjeżdża standardowy zjazd rynku o 18% w 15 minut. Płynność z arkusza zleceń wyparowuje. Wyrocznia Chainlink aktualizuje cenę z opóźnieniem kilku bloków przez zapchany network, a Twoja pozycja leci prosto do likwidacyjnego piekła.
Anatomia likwidacji: dlaczego Twój zastaw znika w mgnieniu oka
Główna metryka, którą musisz monitorować 24/7, to Health Factor (HF). Wzór jest banalny:
HF = ∑ (Collaterali × LTVi) / Total Borrowed
Jeśli HF > 1, jesteś bezpieczny. Jeśli HF ≤ 1, Twoja pozycja staje się darmowym łupem dla każdego w sieci, kto ma odpalony odpowiedni skrypt.
| Parametr | Wyjaśnienie | Realny przykład (pula ETH) |
|---|---|---|
| Max LTV | Maksymalny procent pożyczki w stosunku do wartości zastawu | 80% ($800 pożyczki na $1000 w ETH) |
| Liquidation Threshold (LT) | Próg, po przekroczeniu którego pozycja kwalifikuje się do likwidacji | 82.5% |
| Liquidation Bonus | Dyskonto na zastaw, które zgarnia likwidator | 5% |
| Reserve Factor | Dola protokołu z przychodów odsetkowych | 15% |
Chociaż czekaj, sprostowanie. Na papierze próg 82.5% brzmi bezpiecznie, ale w praktyce musisz wziąć pod uwagę ukryty slippage i koszty gazu na mainnecie. Gdy wyrocznia typu push pcha odświeżoną cenę do kontraktu, boty MEV już ustawiają się w kolejce w paczce Flashbots. Przejmują Twoją pozycję z 5% dyskontem, błyskawicznie spłacają dług przez flash loan na Uniswap v3 i zgarniają czysty zysk na czysto.
Efekt? Zostajesz z USDC, które zapewne zdążyłeś już gdzieś przepalić, ale po Twoim WBTC nie ma ani śladu. Protokół pobrał karę, bot zgarnął spread. Jesteś na minusie.
Skrypt likwidatora: jak boty wyczuwają słabość Twojej pozycji
Pół roku temu sam widziałem, jak na Arbitrum jedna zbugowana wyrocznia posłała w kosmos depozyty warte 200k zielonych dosłownie w trzy bloki. Prosty skrypt do likwidacji można naskrobać na kolanie, ale o sukcesie decydują milisekundy i bezpośredni dostęp do przekaźników MEV.
Oto działający prototyp w Pythonie z użyciem web3.py, który śledzi pożyczkobiorcę w LendingPool i błyskawicznie przygotowuje transakcję, gdy HF spadnie poniżej jedynki.
import os
import time
from web3 import Web3
from web3.exceptions import ContractLogicError, TransactionNotFound
# ------------------------------------------------------------------------------
# 1. Konfiguracja i połączenie
# ------------------------------------------------------------------------------
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("Brak połączenia z RPC. Sprawdź swój RPC_URL.")
ACCOUNT = w3.eth.account.from_key(PRIVATE_KEY)
# Kontrakty Aave v3 na 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: Poprawiony typ zwrotny na 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. Bezpieczne helpery dla gazu i nonce
# ------------------------------------------------------------------------------
def get_fee_parameters():
"""Pobiera parametry opłat EIP-1559 z fallbackiem dla sieci bez 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:
# Fallback dla sieci typu Legacy / niestandardowych L2
return {'gasPrice': w3.eth.gas_price}
def ensure_allowance(required_amount: int) -> bool:
"""Sprawdza i ustawia approve z obowiązkową weryfikacją statusu receipta."""
current_allowance = debt_token.functions.allowance(ACCOUNT.address, POOL_ADDRESS).call()
if current_allowance >= required_amount:
return True
print("[*] Za mały allowance. Leci transakcja 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 pomyślnie wbity w bloku {receipt['blockNumber']}")
return True
else:
print(f"[!] BŁĄD: Transakcja approve poleciała z revertem!")
return False
# ------------------------------------------------------------------------------
# 4. Główna logika egzekucji likwidacji
# ------------------------------------------------------------------------------
def execute_liquidation(user_address: str):
global tx_in_flight
if tx_in_flight:
print("[!] Wątek zablokowany (transakcja jest już w locie).")
return
# FIX #9: Szybki sanity check HF przed odpaleniem kosztownego setupu
account_data = pool.functions.getUserAccountData(user_address).call()
health_factor = account_data[5] / 10**18
if health_factor >= 1.0:
print(f"[-] Anulowano: HF pozycji podskoczył do {health_factor:.6f} (>= 1.0)")
return
# FIX #11: Wyliczenie dokładnego długu w jednostkach tokena 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("[!] Brak aktywnego długu dla wybranego tokena.")
return
debt_to_cover = actual_debt_tokens // 2
# Sprawdzenie lokalnego salda USDC w portfelu
our_balance = debt_token.functions.balanceOf(ACCOUNT.address).call()
if our_balance < debt_to_cover:
print(f"[!] Za mało USDC na portfelu. Wymagane: {debt_to_cover}, Dostępne: {our_balance}")
debt_to_cover = our_balance
if debt_to_cover == 0:
return
# FIX #8: Weryfikacja, czy approve przeszedł pomyślnie
if not ensure_allowance(debt_to_cover):
return
# Używamy nonce 'pending', aby uniknąć kolizji wewnątrz konta
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: Symulacja transakcji bezpośrednio przed jej złożeniem
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"[!] Kontrakt wypluł revert podczas symulacji liquidationCall: {e}")
return
except Exception as e:
print(f"[!] Błąd przy szacowaniu limitu gazu: {e}")
return
# Składanie i rozgłaszanie transakcji
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"[+] Transakcja wysłana w sieć! Tx Hash: {tx_hash.hex()}")
# FIX #10: Sprawdzenie statusu w receipt po likwidacji
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=30)
if receipt['status'] == 1:
print(f"[SUKCES] Pozycja zlikwidowana w bloku {receipt['blockNumber']}!")
else:
print(f"[FAIL] Transakcja weszła do bloku, ale wywaliła REVERT (Status 0). Gaz poszedł w błoto.")
except Exception as e:
print(f"[!] Krytyczny błąd podczas nadawania transakcji: {e}")
finally:
tx_in_flight = False
# ------------------------------------------------------------------------------
# 5. Pętla monitorująca
# ------------------------------------------------------------------------------
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("[!] NAMIERZONO POZYCJĘ DO LIKWIDACJI!")
execute_liquidation(user_address)
if __name__ == "__main__":
while True:
try:
monitor_user(TARGET_USER)
time.sleep(1)
except KeyboardInterrupt:
print("\nPrzerwano działanie bota na żądanie użytkownika.")
break
except Exception as e:
print(f"ERROR: {e}")
time.sleep(3)Na L2 nie ma czegoś takiego jak mempool. W ogóle. Wszystko śmiga w trybie FCFS (kto pierwszy, ten lepszy). Kto pierwszy wstanie, ten zgarnia pulę.
I podczas gdy Twój skrypt zamula na time.sleep(1), wyspecjalizowane boty HFT siedzą na socketach w tym samym serwerze, gdzie kręci się walidator. Szanse na ręczne wyprzedzenie margin calla masz ZEROWE.
Gdzie tak naprawdę leży zysk (i dlaczego wszyscy dalej biorą pożyczki)
Po co w ogóle pakować się w takie ryzyko?
- Arbitraż stóp procentowych (Interest Rate Arbitrage). Bierzesz USDT na 4% APY na platformie A i wrzucasz do Vaulta na 12% APY na platformie B. Różnica 8% ląduje w Twojej kieszeni. Ale tu kryje się ryzyko smart kontraktu: jeśli platforma B zaliczy depeg albo zostanie zhakowana przez reentrancy, zostajesz z realnym długiem wobec platformy A.
- Delegowanie płynności i tokenizacja zastawu. Spójrz chociażby na Pendle czy Morpho. Deponujesz stETH, dostajesz stablecoiny, kupujesz za nie tokeny PT (Principal Tokens) ze stałym oprocentowaniem. Skomplikowane? Tak. Zyskowne? Jak najbardziej — do pierwszych problemów z wyrocznią tokenów LST.
- Optymalizacja podatkowa. Realizacja zysków = konieczność zapłaty podatku. Pożyczka pod zastaw zyskującego aktywa = brak faktu sprzedaży. Nie ma sprzedaży — nie ma podatku. Pragmatyczne? Jak najbardziej.
Ludzie zapominają jednak o kaskadowych likwidacjach. W maju 2021 roku oraz podczas upadku FTX całe protokoły stawały się niewypłacalne (bad debt), bo cena zastawu leciała na łeb na szyję szybciej, niż boty nadążały z zamykaniem długów. W efekcie oberwali zwykli dostawcy płynności, którzy po prostu wrzucili swoje stablecoiny na procent.
Izolowane pule depozytowe vs Cross-Margin: gdzie tkwi pułapka
Mnóstwo świeżaków wcina się na minę po prostu dlatego, że nie ogarniają architektury zarządzania ryzykiem. W DeFi mamy dwa fundamentalnie różne podejścia: Cross-Margin (jeden wspólny kocioł) oraz Izolowane Rynki (sektorowe rynki w stylu Morpho czy Euler v2).
W Cross-Margin (domyślny tryb na Aave) cały Twój portfel zabezpieczenia działa jak jedna wspólna tarcza. Wrzucasz WBTC, ETH i DAI — po czym pożyczasz USDC. Jeśli spadnie tylko cena WBTC, poduszką bezpieczeństwa staje się DAI. Wygodne? Jak najbardziej. Ale jeśli jeden z Twoich aktywów zabezpieczających leci prosto w przepaść (pamiętamy depeg stETH z 2022 roku czy jakikolwiek przekręt na zmostkowanym tokenie), ciągnie za sobą CAŁĄ pozycję. Czyszczą Cię do zera.
W izolowanych pulach ograniczasz ryzyko tylko do jednej konkretnej pary — na przykład dedykowanego vaulta wstETH / USDC.
[Twój depozyt] ---> [Izolowana pula A (ETH/USDC)] ---> Ryzyko ograniczone do puli A
---> [Izolowana pula B (PEPE/USDC)] ---> Scam na PEPE = strata tylko w puli BJeśli jakiś gównotoken w puli B zaliczy glebę o 99% — tracisz wyłącznie to, co wpłaciłeś do puli B. Twój główny Ether leżący w puli A jest w stu procentach bezpieczny.
Jasne, ogarnianie trzech osobnych pozycji to drętwota i płacisz więcej za gas. Ale przy pracy z dużą zmiennością to jedyna rozsądna strategia.
Wyrocznie: jak tracisz kasę bez żadnego exploita na smart kontrakcie
Smart kontrakt protokołu pożyczkowego sam z siebie jest ślepy i głuchy. Nie ma pojęcia, ile w tej sekundzie kosztuje ETH — po prostu ślepo wierzy wyroczni (oraclowi). I tu zaczyna się cała jazda.
Oto główne grzechy wyroczni, na których ludzie palą swoje depozyty:
- Stale Prices (Nieświeże ceny). Wyrocznia nie pcha ceny na blockchain w każdej sekundzie (bo opłaty za gas wykończyłyby każdego dostawcę), tylko przy odchyleniu, powiedzmy, o 0.5% albo raz na 24 godziny (tzw. heartbeat). Jeśli rynek zaliczy impulsywny zjazd w dół o 5% w 3 sekundy i od razu odbije, wyrocznia może zaktualizować cenę DOKŁADNIE NA SAMYM DNIE. Kontrakt widzi HF na poziomie 0.99, daje zielone światło likwidatorom i pozycja idzie do piachu. Rynek odskakuje w górę, a Ty zostajesz z ręką w nocniku.
- Illiquid DEX Feeds (Manipulacja na rynku spot). Jeśli protokół korzysta z wyroczni TWAP z Uniswap v3 dla mało płynnego tokena, wieloryb jednym potężnym strzałem sprzedażowym zepchnie cenę w podłogę, wywoła kaskadę likwidacji w protokole pożyczkowym, a potem odkupi aktywo za bezcen. Klasyka gatunku.
Poniższa tabela pokazuje realną matematykę strat przy likwidacji na różnych poziomach spadku zabezpieczenia (przy początkowym LTV 75% i Liquidation Bonus 5%):
| Spadek ceny zabezpieczenia | Health Factor | Stan pozycji | Straty pożyczkobiorcy z początkowego depozytu |
|---|---|---|---|
| -5% | 1.26 | Bezpiecznie | 0% (tylko niezrealizowana strata na wartości zabezpieczenia) |
| -15% | 1.13 | Strefa ryzyka | 0% (wymagane dorzucenie zabezpieczenia) |
| -25% | 0.99 | LIKWIDACJA | ~15-20% (kara dla likwidatora + spread) |
| -40% (Flash Crash) | < 0.80 | Czysty zaor | 100% utraconego zabezpieczenia (zostajesz tylko z pożyczką) |
Checklist przetrwania: jak brać pożyczki i nie dać się zaorać
Jeśli pakujesz się w zdecentralizowane pożyczki, trzymaj się twardych zasad higieny. Sam przetestowałem je na własnej skórze po kilku bolesnych revertach i przymusowych likwidacjach.
- Trzymaj HF nie niżej niż 1.5 - 1.8. Zapomnij o igraniu z ogniem przy HF 1.05. Byle cień szpilki na wykresie i jesteś trup.
- Zautomatyzuj dopłacanie (Self-Kicker). Używaj narzędzi typu Gelato Automation albo Chainlink Automation. Napisz prostego bota, który przy spadku HF poniżej 1.2 automatycznie przerzuci stablecoiny z Twojego portfela do kontraktu, żeby spłacić część długu.
- Monitoruj wyrocznie. Bądź świadomy, jaka wyrocznia obsługuje Twój basen. Jeśli to Chainlink typu push — kontroluj heartbeat. Jeśli Pyth — sprawdzaj przedziały ufności (confidence intervals).
- Hedge'uj pozycję przez perpy. Wziąłeś pożyczkę pod ETH? Otwórz shorta na ETH na derywatach z dźwignią 1x na wartość pożyczki. Zamykasz ryzyko w wartości dolarowej i masz pozycję delta-neutral.
Kredyt w crypto to potężna dźwignia finansowa, ale w rękach kogoś nieogarniętego zamienia się w gilotynę z tykającym zegarem. Kontrakt ma głęboko w nosie Twoje przemyślenia co do kierunku rynku.