En résumé : Cet article décortique le fonctionnement des prêts crypto et des liquidations dans les protocoles Web3 (Aave v3, Morpho Blue). On y passe en revue les métriques clés (Health Factor, LTV, Liquidation Threshold), les scénarios de slippage liés aux ratés des oracles Chainlink, sans oublier un script Python de monitoring taillé sur mesure pour liquider automatiquement des positions.
Comment fonctionnent le lending crypto et les risques de liquidation
Un prêt crypto, c'est pas la banque à papa où un conseiller en costume épluche tes fiches de paie pour valider ton crédit immo contre l'appartement de ta grand-mère. En Web3, tout le monde se contrefout de ton nom, de tes antécédents bancaires ou de tes projets de vie. Ici, seule compte la rigueur mathématique des smart contracts : tu déposes du collateral (collatéral/garantie), tu chopes un overcollateralized loan (prêt surcollatéralisé), et si tu lâches ton Health Factor des yeux une fraction de seconde, les bots de liquidation vont bouffer ton collateral jusqu'au dernier centime en un clin d'œil.
Prenons le cas d'école sur Aave v3 ou Morpho Blue. Tu balances 10 000 $ en WBTC pour emprunter 6 000 $ en USDC. Pourquoi ? Pour ne pas vendre tes Bitcoins, éviter de douiller aux impôts sur la plus-value, et récupérer du cash frais pour to the moon sur le dernier shitcoin à la mode ou t'acheter du nouveau matos. Sur le papier, c'est le Graal.
Et puis, paf : le marché se prend un bon gros dump de 18 % en 15 minutes chrono. La liquidité s'évapore de l'order book. L'oracle Chainlink accuse un retard de quelques blocks à cause de la saturation du réseau, et ta position part droit dans l'enfer de la liquidation.
Anatomie d'une liquidation : pourquoi ton collateral s'envole instantanément
La métrique ultime que tu dois surveiller H24, c'est le Health Factor (HF). La formule est toute bête :
HF = ∑ (Collaterali × LTVi) / Total Borrowed
Si HF > 1, tu es serein. Si HF ≤ 1, ta position devient une cible facile à liquider pour n'importe quel acteur du réseau qui a fait tourner le script adéquat.
| Paramètre | Explication | Exemple concret (Pool ETH) |
|---|---|---|
| Max LTV | Pourcentage maximal d'emprunt par rapport à la valeur du collateral | 80 % (800 $ empruntés pour 1000 $ en ETH) |
| Liquidation Threshold (LT) | Seuil à partir duquel la position devient liquidable | 82.5 % |
| Liquidation Bonus | Décote sur le collateral empochée par le liquidateur | 5 % |
| Reserve Factor | Part des intérêts prélevée par le protocole | 15 % |
Attends, minute. Sur le papier, 82,5 % ça semble laisser une petite marge, mais en pratique il faut compter avec le slippage fantôme et le gas du mainnet. Dès qu'un oracle de type push envoie le prix actualisé dans le contract, les bots MEV font déjà la queue dans un bundle Flashbots. Ils rachètent ta position avec 5 % de discount, s'empressent de rembourser la dette via un flash loan sur Uniswap v3, et mettent la différence directement dans leur poche.
Résultat des courses ? Il te reste tes USDC (que tu as probablement déjà cramés ailleurs), mais ton WBTC s'est volatilisé. Le protocole s'est pris sa pénalité, le bot a empoché son spread, et toi tu restes sur le carreau.
Le script du liquidateur : comment les bots repèrent tes faiblesses
Il y a six mois, j'ai vu de mes propres yeux sur Arbitrum un oracle bancal envoyer au tapis 200 k$ de dépôts en l'espace de trois blocks. Coder un script de liquidation basique, c'est à la portée de n'importe qui, mais ce qui fait la différence, ce sont les millisecondes et un accès direct aux relais MEV.
Voici un prototype fonctionnel en Python avec web3.py. Il surveille l'emprunteur sur le LendingPool et prépare une transaction à la vitesse de la lumière si le HF tombe sous la barre des 1.
import os
import time
from web3 import Web3
from web3.exceptions import ContractLogicError, TransactionNotFound
# ------------------------------------------------------------------------------
# 1. Config & Connexion RPC
# ------------------------------------------------------------------------------
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("Impossible de se connecter au RPC. Vérifie ton RPC_URL, frérot.")
ACCOUNT = w3.eth.account.from_key(PRIVATE_KEY)
# Contrats Aave v3 sur 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: Type de retour corrigé en 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. Helpers pour la gestion blindée du Gas et du Nonce
# ------------------------------------------------------------------------------
def get_fee_parameters():
"""Récupère les paramètres EIP-1559 avec un fallback propre pour les réseaux sans 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 pour les réseaux Legacy ou les L2 exotiques
return {'gasPrice': w3.eth.gas_price}
def ensure_allowance(required_amount: int) -> bool:
"""Vérifie l'allowance et envoie le tx d'approve avec confirmation explicite du receipt."""
current_allowance = debt_token.functions.allowance(ACCOUNT.address, POOL_ADDRESS).call()
if current_allowance >= required_amount:
return True
print("[*] Allowance insuffisante. On envoie un tx d'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 validé avec succès dans le bloc {receipt['blockNumber']}")
return True
else:
print(f"[!] ERREUR : La transaction d'approve a revert !")
return False
# ------------------------------------------------------------------------------
# 4. Cœur de la logique de Liquidation
# ------------------------------------------------------------------------------
def execute_liquidation(user_address: str):
global tx_in_flight
if tx_in_flight:
print("[!] Processus local verrouillé (une transaction est déjà dans le pipe).")
return
# FIX #9: Double-check du Health Factor en vitesse avant de lancer les gros calculs
account_data = pool.functions.getUserAccountData(user_address).call()
health_factor = account_data[5] / 10**18
if health_factor >= 1.0:
print(f"[-] Fausse alerte : le HF de la position est repassé à {health_factor:.6f} (>= 1.0)")
return
# FIX #11: Calcul de la dette réelle directement en tokens 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("[!] Aucune dette active sur ce token.")
return
debt_to_cover = actual_debt_tokens // 2
# Vérification du solde USDC dans notre wallet
our_balance = debt_token.functions.balanceOf(ACCOUNT.address).call()
if our_balance < debt_to_cover:
print(f"[!] Pas assez de liquide en USDC. Requis : {debt_to_cover}, En poche : {our_balance}")
debt_to_cover = our_balance
if debt_to_cover == 0:
return
# FIX #8: On s'assure que l'approve est bien passé avant d'enchaîner
if not ensure_allowance(debt_to_cover):
return
# Nonce 'pending' pour éviter de se marcher sur les pieds en interne
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: Simulation dry-run de la transaction juste avant de build
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"[!] Le smart contract a rejeté la simulation de liquidationCall : {e}")
return
except Exception as e:
print(f"[!] Erreur lors du calcul du gas : {e}")
return
# Build & Broadcast
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"[+] Transaction envoyée plein gaz ! Tx Hash: {tx_hash.hex()}")
# FIX #10: Inspection du receipt pour valider le résultat du rekt
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=30)
if receipt['status'] == 1:
print(f"[SUCCÈS] Position liquidée avec succès dans le bloc {receipt['blockNumber']} !")
else:
print(f"[ÉCHEC] Tx minée mais s'est mangé un REVERT (Status 0). Gas brûlé pour rien !")
except Exception as e:
print(f"[!] Crash critique lors du broadcast : {e}")
finally:
tx_in_flight = False
# ------------------------------------------------------------------------------
# 5. Boucle de Monitoring
# ------------------------------------------------------------------------------
def monitor_user(user_address: str):
account_data = pool.functions.getUserAccountData(user_address).call()
health_factor = account_data[5] / 10**18
print(f"Cible : {user_address} | Health Factor : {health_factor:.6f}")
if health_factor < 1.0:
print("[!] CIBLE LIQUIDABLE DÉTECTÉE !")
execute_liquidation(user_address)
if __name__ == "__main__":
while True:
try:
monitor_user(TARGET_USER)
time.sleep(1)
except KeyboardInterrupt:
print("\nBot coupé par l'utilisateur. À la prochaine !")
break
except Exception as e:
print(f"ERREUR : {e}")
time.sleep(3)Sur les L2, pas de mempool. Que couic. Tout tourne en mode FCFS (First-Come, First-Served). Premier arrivé, premier servi.
Et pendant que ton script pédale dans la semoule avec son time.sleep(1), les bots HFT spécialisés sont directement connectés en socket dans la même baie serveur que le validateur. Tes chances de gratter un margin call à la main ? STRICTEMENT ZÉRO.
Où se cache la vraie rentabilité (et pourquoi tout le monde continue d'emprunter)
Pourquoi prendre autant de risques ?
- L'arbitrage de taux (Interest Rate Arbitrage). Tu empruntes de l'USDT à 4 % d'APY sur la plateforme A et tu le déposes dans un Vault à 12 % d'APY sur la plateforme B. Tu empoches les 8 % de delta. Sauf qu'il y a un risque de smart contract bien planqué : si la plateforme B se fait depeg ou hack par reentrancy, tu dois toujours du vrai cash à la plateforme A.
- Délégation de liquidité et tokenisation du collateral. Regarde du côté de Pendle ou Morpho. Tu déposes du stETH, tu récupères des stables, et tu t'en sers pour acheter des PT (Principal Tokens) à rendement fixe. Compliqué ? Oui. Rentable ? Carrément... jusqu'au premier pépin sur l'oracle des tokens LST.
- L'optimisation fiscale. Prendre ses profits = payer des impôts. Emprunter contre un actif qui a pris de la valeur = zéro vente enregistrée. Pas de vente, pas d'impôt. Pragmatique ? Totalement.
Sauf que beaucoup oublient les liquidations en cascade. En mai 2021 ou lors de l'effondrement d'FTX, des protocoles entiers se sont retrouvés insolvables (bad debt) parce que la valeur des collatéraux s'écroulait plus vite que le temps mis par les bots pour liquider les dettes. Au final, ce sont les simples lenders, venus poser leurs stables pour gratter un petit intérêt, qui ont pris cher.
Pools de marge isolée vs Cross-Margin : où se cache le piège
Plein de débutants se prennent un mur magistral tout simplement parce qu'ils ne capte rien à l'architecture de gestion des risques. En DeFi, il existe deux approches fondamentalement différentes : le Cross-Margin (le grand pot commun) et les Isolated Markets (les marchés isolés du style Morpho ou Euler v2).
En Cross-Margin (le mode par défaut sur Aave), l'ensemble de ton portefeuille de collateral sert de bouclier unique. Tu déposes du WBTC, de l'ETH et du DAI — puis tu empruntes du USDC. Si seul le WBTC se casse la gueule, le matelas en DAI vient te sauver la mise. Pratique ? Clairement. Mais si l'un de tes actifs en collateral plonge dans le néant (rappelez-vous le depeg du stETH en 2022 ou le rug d'un token bridgé quelconque), il entraîne TOUTE ta position dans sa chute. C'est la liquidation totale, retour à la case départ.
Dans les pools isolés, tu confines le risque à une seule paire — par exemple un vault sur-mesure wstETH / USDC.
[Ton dépôt] ---> [Pool isolé A (ETH/USDC)] ---> Risque limité au pool A
---> [Pool isolé B (PEPE/USDC)] ---> Scam sur PEPE = perte uniquement sur le pool BSi un shitcoin du pool B fait un -99% éclair, tu ne perds que ce que tu as injecté dans le pool B. Ton précieux Ether qui dort gentiment dans le pool A reste totalement intouchable.
Alors oui, jongler avec trois positions différentes, c'est l'enfer au quotidien et ça rince en frais de gas. Mais quand ça secoue méchamment sur le marché, c'est la seule stratégie à peu près saine.
Oracles : comment se faire liquider proprement sans aucun hack
Le smart contract d'un protocole de lending est complètement aveugle et sourd. Il n'a aucune idée du prix de l'ETH à la seconde près : il fait une confiance aveugle à l'oracle. Et c'est exactement là que le cauchemar commence.
Voici les pires angles morts des oracles qui crament des portefeuilles à la chaîne :
- Stale Prices (Prix périmés). L'oracle ne push pas le prix chaque seconde (ça ruinerait n'importe quel provider en gas), mais seulement en cas de variation, disons de 0.5%, ou à intervalle régulier (le heartbeat, genre toutes les 24h). Si le marché se mange une mèche baissière de -5% en 3 secondes puis repart aussitôt, l'oracle peut rafraîchir le prix PILE AU PLUS BAS. Le contract lit un Health Factor à 0.99, donne le feu vert aux liquidateurs, et tu te fais décapiter. Le marché rebondit dans la foulée, mais toi, tu te retrouves le bec dans l'eau.
- Illiquid DEX Feeds (Manipulation du spot). Si le protocole s'appuie sur un oracle TWAP basé sur Uniswap v3 pour un token illiquide, une baleine n'a qu'à balancer un gros sell order pour écraser le prix, déclencher une cascade de liquidations sur le lending, puis racheter le jeton pour une bouchée de pain. Un grand classique.
Le tableau ci-dessous montre la réalité mathématique des pertes subies lors d'une liquidation selon la chute du collateral (avec un LTV initial de 75% et un Liquidation Bonus de 5%) :
| Chute du prix du collateral | Health Factor | Statut de la position | Pertes de l'emprunteur sur le dépôt initial |
|---|---|---|---|
| -5% | 1.26 | En sécurité | 0% (uniquement perte latente sur la valeur du collateral) |
| -15% | 1.13 | Zone orange | 0% (top-up de collateral nécessaire) |
| -25% | 0.99 | LIQUIDATION | ~15-20% (pénalité de liquidation + spread) |
| -40% (Flash Crash) | < 0.80 | Annihilation totale | 100% du collateral perdu (il ne te reste que le prêt) |
Checklist de survie : emprunter sans finir à poil
Si tu t'aventures sur le terrain du prêt décentralisé, applique une hygiène irréprochable. Ces règles, je me les suis gravées au fer rouge après quelques reverts bien douloureux et des fermetures forcées.
- Maintiens ton HF entre 1.5 et 1.8 minimum. Oublie les jeux dangereux avec un HF à 1.05. La moindre mèche rouge sur le chart et t'es mort.
- Automatise le rechargement (Self-Kicker). Utilise des outils comme Gelato Automation ou Chainlink Automation. Code un bot basique qui, dès que le HF passe sous 1.2, transfère automatiquement des stablecoins de ton wallet vers le contract pour rembourser une partie de la dette.
- Surveille les oracles. Sache exactement quel oracle tourne sur ton pool. Si c'est du Chainlink push-type, surveille le heartbeat. Si c'est du Pyth, vérifie les intervalles de confiance (confidence intervals).
- Hedge ta position via des perps. Tu as emprunté avec de l'ETH en collateral ? Ouvre un short ETH sur du dérivé avec un levier 1x équivalent au montant du prêt. Ça verrouille ton risque en valeur dollar (position delta-neutre).
Le crédit crypto est un levier monstrueux pour optimiser son capital, mais entre de mauvaises mains, c'est une guillotine à retardement. Et spoiler alert : le smart contract n'en a strictement rien à faire de tes prédictions de marché.