Presiona ESC para cerrar

Préstamos Cripto: ¿Ganancia o Riesgo de Liquidación?

Resumen: En este artículo desglosamos cómo funcionan los préstamos cripto y las liquidaciones en protocolos Web3 (Aave v3, Morpho Blue). Analizamos las métricas clave (Health Factor, LTV, Liquidation Threshold), los escenarios de slippage cuando los oráculos de Chainlink fallan, y presentamos un script en Python para monitorear y liquidar posiciones de forma automática.

Cómo funcionan los préstamos cripto y los riesgos de liquidación

Un préstamo cripto no tiene nada que ver con un banco tradicional, donde un ejecutivo de cuenta revisa tu recibo de sueldo para aprobarte una hipoteca dejando de garantía la casa de tu abuela. En Web3 a nadie le importa tu nombre, tu historial crediticio ni tus planes de vida. Acá solo manda la matemática fría de los smart contracts: dejas un colateral (collateral), pides un préstamo sobregarantizado (overcollateralized loan) y si descuidas tu Health Factor, los bots liquidadores se van a comer tu garantía en una fracción de milisegundo.

Toma el escenario clásico en Aave v3 o Morpho Blue. Depositas $10,000 en WBTC para pedir $6,000 en USDC. ¿Para qué? Para no vender tu Bitcoin, evitar el impuesto a las ganancias de capital y al mismo tiempo conseguir liquidez para mandarle un "to the moon" a la shitcoin del momento o armarte un rig nuevo. En el papel suena como el santo grial.

Pero de repente el mercado se desploma un 18% en 15 minutos. La liquidez de los libros de órdenes desaparece. El oráculo de Chainlink actualiza el precio con un retraso de un par de bloques por el spam en la red, y tu posición se va directo al infierno de las liquidaciones.

Anatomía de una liquidación: por qué tu colateral vuela al instante

La métrica sagrada que tienes que monitorear 24/7 es el Health Factor (HF). La fórmula es híper básica:

HF = ∑ (Collaterali × LTVi) / Total Borrowed

Si tu HF > 1, estás a salvo. Si el HF ≤ 1, tu posición queda servida en bandeja de plata para que la liquide cualquiera en la red que tenga un script corriendo.

ParámetroExplicaciónEjemplo real (Pool de ETH)
Max LTVPorcentaje máximo que puedes pedir prestado según tu colateral80% ($800 prestados por cada $1000 en ETH)
Liquidation Threshold (LT)El límite donde tu posición queda lista para ser liquidada82.5%
Liquidation BonusEl descuento en el colateral que se embolsa el liquidador5%
Reserve FactorLa tajada que se queda el protocolo de los intereses15%

Pero ojo, no te confíes. En el papel, un 82.5% suena como un buen colchón, pero en la realidad tienes que meter en la cuenta el slippage fantasma y el gas de la mainnet. Cuando el oráculo tipo push manda el precio actualizado al contrato, los bots de MEV ya están haciendo fila en el bundle de Flashbots. Compran tu posición con un 5% de descuento, pagan tu deuda al instante con un flash loan en Uniswap v3 y se quedan con el margen limpio.

¿El resultado? Te quedas con los USDC que ya te gastaste por ahí, pero tu WBTC desapareció. El protocolo se cobra la multa, el bot se queda con el spread y tú quedas en números rojos.

Script de liquidación: cómo ven los bots tu debilidad

Hace seis meses vi en vivo cómo en Arbitrum un oráculo descalibrado mandó al carajo depósitos por $200k en apenas tres bloques. Armar un script de liquidación se hace en una tarde, pero la verdadera magia está en los milisegundos y en tener acceso directo a un relay de MEV.

Aquí tienes un prototipo funcional en Python con web3.py que monitorea al prestatario en el LendingPool y arma la transacción de una si el HF cae por debajo de 1.

import os
import time
from web3 import Web3
from web3.exceptions import ContractLogicError, TransactionNotFound

# ------------------------------------------------------------------------------
# 1. Configuración y conexión
# ------------------------------------------------------------------------------
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("Falló la conexión al RPC. Revisa tu RPC_URL.")

ACCOUNT = w3.eth.account.from_key(PRIVATE_KEY)

# Contratos de Aave v3 en 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. ABIs
# ------------------------------------------------------------------------------
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: Se corrigió el tipo a 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 seguros para Gas y Nonce
# ------------------------------------------------------------------------------
def get_fee_parameters():
    """Consulta los parámetros EIP-1559 con fallback para redes sin 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 para redes Legacy / L2s customizadas
        return {'gasPrice': w3.eth.gas_price}

def ensure_allowance(required_amount: int) -> bool:
    """Verifica y mete el approve validando sí o sí el estatus del receipt."""
    current_allowance = debt_token.functions.allowance(ACCOUNT.address, POOL_ADDRESS).call()
    if current_allowance >= required_amount:
        return True

    print("[*] Allowance insuficiente. Mandando 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 confirmado exitosamente en el bloque {receipt['blockNumber']}")
        return True
    else:
        print(f"[!] ERROR: Transacción de approve rebotada (reverted)!")
        return False

# ------------------------------------------------------------------------------
# 4. Lógica principal para ejecutar la liquidación
# ------------------------------------------------------------------------------
def execute_liquidation(user_address: str):
    global tx_in_flight
    if tx_in_flight:
        print("[!] Proceso local bloqueado (ya hay una transacción en vuelo).")
        return

    # FIX #9: Doble check rápido del HF antes del setup pesado
    account_data = pool.functions.getUserAccountData(user_address).call()
    health_factor = account_data[5] / 10**18
    if health_factor >= 1.0:
        print(f"[-] Cancelado: El HF de la posición cambió a {health_factor:.6f} (>= 1.0)")
        return

    # FIX #11: Cálculo exacto de la deuda 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("[!] El usuario no tiene deuda en el token seleccionado.")
        return

    debt_to_cover = actual_debt_tokens // 2

    # Verificación de nuestro propio balance de USDC
    our_balance = debt_token.functions.balanceOf(ACCOUNT.address).call()
    if our_balance < debt_to_cover:
        print(f"[!] USDC insuficiente en el balance. Se requiere: {debt_to_cover}, Tienes: {our_balance}")
        debt_to_cover = our_balance
        if debt_to_cover == 0:
            return

    # FIX #8: Validar que el approve se haya ejecutado bien
    if not ensure_allowance(debt_to_cover):
        return

    # Usamos nonce 'pending' para evitar colisiones internas en la cuenta
    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: Simulación justo antes del armado de la tx
    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"[!] El smart contract rebotó la simulación de liquidationCall: {e}")
        return
    except Exception as e:
        print(f"[!] Error al estimar el gas: {e}")
        return

    # Armado y envío
    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"[+] ¡Transacción enviada! Tx Hash: {tx_hash.hex()}")
        
        # FIX #10: Análisis del receipt de la liquidación
        receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=30)
        if receipt['status'] == 1:
            print(f"[ÉXITO] ¡Posición liquidada correctamente en el bloque {receipt['blockNumber']}!")
        else:
            print(f"[FRACASO] La tx se minó pero tiró REVERT (Status 0). Quemaste gas en vano.")

    except Exception as e:
        print(f"[!] Error crítico al enviar: {e}")
    finally:
        tx_in_flight = False

# ------------------------------------------------------------------------------
# 5. Monitoreo
# ------------------------------------------------------------------------------
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("[!] ¡POSICIÓN LIQUIDABLE DETECTADA!")
        execute_liquidation(user_address)

if __name__ == "__main__":
    while True:
        try:
            monitor_user(TARGET_USER)
            time.sleep(1)
        except KeyboardInterrupt:
            print("\n Bot detenido manualmente por el usuario.")
            break
        except Exception as e:
            print(f"ERROR: {e}")
            time.sleep(3)

En L2 no existe el mempool. Para nada. Todo funciona por FCFS (el que primero llega, primero se atiende). El que pega primero, pega dos veces.

Y mientras tu script pierde el tiempo con un time.sleep(1), los bots de HFT están pegados por socket en el mismo rack donde corre el validador. Tus posibilidades de ganarle a un margin call a mano son CERO.

Dónde está el verdadero negocio (y por qué todo el mundo se sigue apalancando)

¿Por qué correr tanto riesgo?

  • Arbitraje de tasas (Interest Rate Arbitrage). Pides USDT al 4% APY en la plataforma A y los metes en un Vault al 12% APY en la plataforma B. Ese 8% de diferencia va directo a tu bolsillo. Pero ojo con el riesgo de smart contract: si la plataforma B pierde el peg o sufre un hackeo por reentrancy, le vas a seguir debiendo plata real a la plataforma A.
  • Delegación de liquidez y tokenización de colateral. Mira lo que hacen en Pendle o Morpho. Depositas stETH, recibes stables y con eso compras PT (Principal Tokens) con rendimiento fijo. ¿Es complejo? Bastante. ¿Es rentable? Un montón... hasta que surgen los primeros problemas con el oráculo de los LSTs.
  • Optimización fiscal. Realizar ganancias = pagar impuestos. Pedir un préstamo usando de colateral un activo que subió de precio = no hay venta. Sin venta no hay impuestos. ¿Pragmático? Totalmente.

El problema es que la gente se olvida del efecto dominó de las liquidaciones en cascada. En mayo de 2021 y durante la caída de FTX, protocolos enteros quedaron insolventes (bad debt) porque el precio del colateral caía más rápido de lo que los bots podían cerrar las deudas. Al final, los que terminaron pagando los platos rotos fueron los lenders comunes y corrientes que solo querían dejar sus stables rindiendo un interés seguro.

Pools de margen aislado vs. Cross-Margin: dónde está la mina enterrada

Muchos novatos terminan reventando su cuenta solo por ignorar lo básico sobre la arquitectura de gestión de riesgos. Existen dos enfoques completamente opuestos: Cross-Margin (la bolsa común o cuenta unificada) e Isolated Markets (mercados aislados como Morpho o Euler v2).

En Cross-Margin (la configuración por defecto en Aave), todo tu portafolio de colateral funciona como un solo escudo. Metes WBTC, ETH y DAI, y sobre eso pides un préstamo en USDC. Si solo se desploma el WBTC, el colchón de DAI te cubre las espaldas. ¿Práctico? Sí. Pero si uno de tus activos colaterales se va directo al infierno (recordemos el depeg de stETH en 2022 o el rug pull de algún bridged token), se arrastra TODA tu posición. Te liquidan hasta el último centavo sin piedad.

En cambio, en los Isolated Pools aíslas el riesgo de cada par de forma independiente. Por ejemplo, un vault customizado de wstETH / USDC.

[Tu depósito] ---> [Pool Ailslado A (ETH/USDC)]  ---> Riesgo limitado solo al Pool A
              ---> [Pool Aislado B (PEPE/USDC)] ---> Scam en PEPE = Pérdida delimitada al Pool B

Si la shitcoin del Pool B se cae un 99% a cero, solo pierdes lo que depositaste en el Pool B. Tu ETH principal en el Pool A se queda completamente a salvo y sin un rasguño.

A ver, gestionar tres posiciones distintas es un dolor de cabeza y gastas más en Gas Fees. Pero es la única estrategia sensata cuando operas con alta volatilidad.

Oráculos: cómo te hacen el rug pull sin explotar el smart contract

El smart contract de una plataforma de lending es ciego y sordo por naturaleza; no tiene la menor idea de cuánto cuesta ETH en este preciso milisegundo. Simplemente confía a ciegas en el oráculo. Y ahí es donde empieza el desastre.

Estos son los principales problemas de los oráculos que terminan quemando tus depósitos:

  • Stale Prices (Precios desactualizados o viejos). El oráculo no manda el precio cada segundo (eso arruinaría a cualquiera en gas fees), sino cuando hay una desviación del 0.5%, por ejemplo, o cada 24 horas (Heartbeat). Si el mercado mete un mecha a la baja (wick) del 5% en 3 segundos y rebota de inmediato, el oráculo puede actualizar el precio ¡JUSTO EN EL FONDO! El contrato lee un «0.99 HF», les da luz verde a los liquidadores y te barren la posición. El mercado reacciona y sube, ¡pero tú ya te quedaste en la calle!
  • Illiquid DEX Feeds (Manipulación en el Spot). Si el protocolo usa un TWAP Oracle de Uniswap v3 para un token con poca liquidez, una ballena puede tirar el precio al piso con una sola venta masiva, provocar una cascada de liquidaciones en el lending protocol y luego recomprar el token a precio de remate. Un clásico de clásicos.

La siguiente tabla muestra la matemática real de pérdidas al ser liquidado según diferentes niveles de caída del colateral (asumiendo un LTV inicial del 75% y un Liquidation Bonus del 5%):

Caída en el precio del colateralHealth FactorEstado de la posiciónPérdida del prestatario respecto al depósito inicial
-5%1.26Seguro0% (solo pérdida no realizada en el colateral)
-15%1.13Zona de riesgo0% (requiere aportar más colateral / Top-up)
-25%0.99LIQUIDACIÓN~15-20% (penalización del liquidador + spread)
-40% (Flash Crash)< 0.80Liquidación total (REKT)100% de colateral perdido (solo te quedas con el préstamo que pediste)

Checklist de supervivencia: cómo pedir un préstamo sin salir trasquilado

Si te vas a meter al juego de los préstamos en DeFi, sigue estas reglas de higiene financiera a rajatabla. Yo mismo las aprendí a golpes después de unos cuantos reverts dolorosos y liquidaciones forzadas.

  • Mantén tu HF siempre por encima de 1.5 - 1.8. Olvídate de andar jugando con fuego con un HF de 1.05. Cualquier vela roja repentina en el gráfico y estás muerto.
  • Automatiza la recarga de colateral (Self-Kicker). Apóyate en herramientas como Gelato Automation o Chainlink Automation. Configura un bot sencillo que, si el HF cae por debajo de 1.2, mande stablecoins automáticamente desde tu wallet al contrato para pagar parte de la deuda.
  • Monitorea los oráculos con lupa. Ten claro qué tipo de oráculo usa tu pool. Si usa un Chainlink de tipo push, revisa el heartbeat. Si es Pyth, verifica los confidence intervals.
  • Haz cobertura (hedge) con derivados (Perps). ¿Pediste un préstamo respaldado en ETH? Abre una posición Short de ETH en derivados a 1x por el mismo monto del préstamo. Con esto congelas tu riesgo en dólares (Delta-Neutral).

Un crédito en crypto es un apalancamiento poderoso para tu capital, pero si lo usas a lo loco se convierte en una guillotina con temporizador. Al smart contract le importa un carajo cuál sea tu proyección del mercado.

Resumir esta publicación de blog con:

FAQ

La liquidación en Aave v3 se activa automáticamente cuando el Factor de Salud (Health Factor) de un usuario cae por debajo de 1,0, indicando que el valor de sus colaterales ajustado por el umbral de liquidación no cubre la deuda acumulada. Un liquidador externo ejecuta la función liquidationCall en el contrato Pool para pagar hasta el 50% de la deuda del usuario a cambio de recibir el equivalente en colateral más una bonificación de liquidación que oscila entre el 1% y el 15%.

El Factor de Salud se calcula multiplicando el valor total del colateral en ETH por el umbral de liquidación ponderado de los activos depositados y dividiendo el resultado entre la deuda total en ETH. Para mantener el indicador por encima de 1,0 de forma segura, el prestatario debe monitorear la volatilidad del mercado, depositar colaterales adicionales o pagar parte de su deuda usando la función repay del contrato Pool antes de que un bot ejecute la liquidación.

La rentabilidad de una liquidación depende del margen entre la bonificación de colateral obtenida, los costos de gas bajo la estructura EIP-1559 y el deslizamiento de precio (slippage) al intercambiar el activo en un DEX. Los desarrolladores ejecutan una simulación estática con la función estimateGas antes de enviar la transacción, garantizando que los parámetros maxFeePerGas y maxPriorityFeePerGas no superen la ganancia neta generada por la incautación del colateral.
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.

...

Escribe una opinión

Tu correo electrónico no será publicado. Los campos obligatorios están marcados *