Appuyez sur ESC pour fermer

Prêt Crypto: Opportunité ou Risque de Liquidation ?

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ètreExplicationExemple concret (Pool ETH)
Max LTVPourcentage maximal d'emprunt par rapport à la valeur du collateral80 % (800 $ empruntés pour 1000 $ en ETH)
Liquidation Threshold (LT)Seuil à partir duquel la position devient liquidable82.5 %
Liquidation BonusDécote sur le collateral empochée par le liquidateur5 %
Reserve FactorPart des intérêts prélevée par le protocole15 %

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 B

Si 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 collateralHealth FactorStatut de la positionPertes de l'emprunteur sur le dépôt initial
-5%1.26En sécurité0% (uniquement perte latente sur la valeur du collateral)
-15%1.13Zone orange0% (top-up de collateral nécessaire)
-25%0.99LIQUIDATION~15-20% (pénalité de liquidation + spread)
-40% (Flash Crash)< 0.80Annihilation totale100% 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é.

Résumer cet article de blog avec :

FAQ

Une liquidation sur Aave v3 est déclenchée automatiquement dès que le Health Factor d'un emprunteur chute en dessous de 1,0, ce qui indique que la valeur totale du collatéral ajustée par le seuil de liquidation ne couvre plus la dette contractée. Un liquidateur externe appelle la fonction liquidationCall du contrat Pool pour rembourser jusqu'à 50 % de la dette de l'utilisateur, recevant en contrepartie un montant équivalent en collatéral majoré d'un bonus de liquidation allant de 1 % à 15 %.

Le Health Factor se calcule en multipliant la valeur totale du collatéral en ETH par le seuil de liquidation pondéré des actifs (Liquidation Threshold), puis en divisant le résultat par la dette totale en ETH. Pour maintenir un Health Factor supérieur à 1,0, l'emprunteur doit surveiller la volatilité du marché, déposer des actifs de garantie supplémentaires ou rembourser une partie de sa dette via la fonction repay du contrat Pool avant l'exécution d'un bot de liquidation.

La rentabilité d'une liquidation dépend de l'écart entre le bonus de liquidation perçu et le coût total en gaz calculé selon le mécanisme EIP-1559, auquel s'ajoute le slippage lors du swap DEX. Les développeurs de bots effectuent une simulation statique via la méthode estimateGas pour valider l'exécution avant l'envoi de la transaction, s'assurant que les paramètres maxFeePerGas et maxPriorityFeePerGas ne dépassent pas le profit net généré par la saisie du collatéral.
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.

...

Partager votre avis

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués *