There is a market that opens and closes hundreds of times a day where sellers are forced to sell at whatever price the buyer names. Not because they’re desperate emotionally — because a smart contract leaves them no choice. When a DeFi borrower’s position tips underwater, the protocol auctions off their collateral at a discount to whoever shows up first with the money. The discount on Aave’s Base market right now is 5% on the two biggest collateral types, and on the riskier stuff 7.5–10%.

This is not a metaphor. In a single 24-hour window while researching this article, I pulled 54 liquidation events off Aave v3 on Base — about $92,000 of debt repaid and $99,800 of collateral seized, meaning liquidators pocketed roughly $7,400 in bonuses in one day, from one market, on a quiet day. The busiest single liquidator address did 19 of them. This is a business, and the entry ticket is a script.

The part that surprises people: you don’t need capital to play. The debt you repay can be borrowed for one block-transaction via a flash loan, so your real costs are the flash loan fee (0.05% at Aave), gas (fractions of a cent on Base), and losing races. The hard part is not the money. It’s knowing which wallets are about to tip over before the other twelve bots do — and that part is just data work, which is what this article hands you.

I’ll explain the mechanics, show real verified numbers (every figure below was pulled fresh from the chain and Aave’s docs while writing this), and give you a scanner script that was run live on the day of publication — read-only, no keys, no wallet, no capital at risk — that maps the entire firing range.

How a liquidation actually works

On Aave, you deposit collateral (say ETH), borrow against it (say USDC), and the protocol continuously marks your position against oracle prices. Everything hangs on one number:

Health factor (HF) = (collateral value × liquidation threshold) ÷ debt value

Per Aave’s docs, a health factor below 1 makes the position eligible for liquidation. If you deposit $10,000 of WETH on Aave Base (liquidation threshold: 84%, verified on-chain for this article), the most you can borrow before HF hits 1.0 is $8,400. If ETH drops 3%, your threshold-weighted collateral drops ~$252 and your HF drops proportionally. Nobody calls you. There’s no margin call email. The contract just becomes claimable.

When HF < 1, anyone can call liquidationCall and:

  1. Repay part of your debt (up to the close factor, 50% — you can never wipe a full position in one shot; also, per the docs, if less than $1,000 of debt would remain in the target reserve, you must liquidate all of it).
  2. Seize an equal value of collateral, plus the liquidation bonus. You always choose which debt token you repay and which collateral token you take — so you can, for example, repay USDC and walk away with WETH.

The bonus is the entire business model. Current Aave v3 Base values, read straight from the protocol’s data provider contract during this research:

CollateralLiquidation bonusLiquidation thresholdOutstanding debt
WETH5.00%84%~$169.9M
USDC5.00%78%~$172.4M
cbBTC6.00%84%~$6.8M
wstETH6.00%79%~$0.95M
cbETH / weETH / wrsETH / tBTC7.50%73–79%small
LBTC8.50%73%none currently
AAVE10.00%65%none currently

Bonus of 5% means: repay $1,000 of someone’s debt, receive $1,050 worth of their collateral. The 0.05% flash loan fee and a fraction of a cent of gas come out of that. The margin looks thin until you look at scale: that $169.9M of WETH-backed debt gets marked, moved, and occasionally tipped every hour of every day.

One important note for the near future: Aave’s V4 documentation describes liquidation bonuses as a Dutch auction — the lower the victim’s health factor, the higher the bonus. V3’s flat bonuses are the training wheels version. Everything you learn building on V3 carries over.

Where the edge actually is

If you only react to liquidations after they appear on-chain, you win nothing: there are specialized bots — often run by sophisticated shops — that bundle the liquidation into the same block as the price move that triggers it. The money you see in that 24-hour sample mostly went to them.

The edge available to a normal person is upstream: watchlist maintenance. A liquidation needs three things to line up: a wallet near the edge, a price move, and nobody else prepared. Wallets don’t appear at the edge instantly — they drift there over days as interest accrues and prices slide. The handful of wallets sitting at HF 1.02–1.10 right now are tomorrow’s liquidations, and everyone serious about this game keeps a live list of them, watching which one will tip first when volatility comes.

That’s a data problem, not a speed problem. Which brings us to the code.

The scanner: tested, read-only, zero capital

What follows ran live against Aave v3 Base (chain ID 8453) via the free publicnode RPC on the day this article was published. It needs one Python package (web3), no API key, no wallet, and it sends nothing on-chain. Three stages:

  1. Market inventory — every reserve, its liquidation bonus, its outstanding debt, live oracle price.
  2. The “gun index” — scan recent Transfer events on the variable-debt token to find wallets that just opened or recharged a debt position. Fresh debt is the population that becomes distressed debt.
  3. Live health scoring — pull each candidate wallet’s health factor directly from the Pool contract, rank riskiest-first, and print a PnL estimate for liquidating the top target.
#!/usr/bin/env python3
"""
Aave v3 Liquidation Sniper — market scanner (read-only, zero capital, no API key).

Tested live against Aave v3 on Base (chain 8453) via the free publicnode RPC.
Requires: pip install web3
"""

import sys
from web3 import Web3

# ---------------------------------------------------------------- config ----
RPC_URL = "https://base-rpc.publicnode.com"   # free public RPC, no key needed
MARKET_NAME = "Aave v3 Base"

# Canonical addresses (source: github.com/aave/aave-address-book, AaveV3Base)
POOL_ADDR     = Web3.to_checksum_address("0xA238Dd80C259a72e81d7e4664a9801593F98d1c5")
DATA_PROVIDER = Web3.to_checksum_address("0x0F43731EB8d45A581f4a36DD74F5f358bc90C73A")
ORACLE        = Web3.to_checksum_address("0x2Cc0Fc26eD4563A5ce5e8bdcfe1A2878676Ae156")

DEBT_WINDOW_BLOCKS = 5000   # how far back to scan for fresh debt minting
MIN_DEBT_USD       = 2_000  # ignore wallets below this debt
DANGER_HF          = 1.05   # flag wallets this close to liquidation

FLASH_LOAN_FEE = 0.0005     # Aave flash loan premium: 0.05% of borrowed amount
GAS_UNITS      = 550_000    # rough cost of a liquidationCall

ABI_DP = [
    {"name": "getAllReservesTokens", "type": "function", "stateMutability": "view",
     "outputs": [{"components": [{"name": "symbol", "type": "string"},
                                 {"name": "tokenAddress", "type": "address"}],
                  "name": "", "type": "tuple[]"}], "inputs": []},
    {"name": "getReserveConfigurationData", "type": "function", "stateMutability": "view",
     "outputs": [{"name": "decimals", "type": "uint256"},
                 {"name": "ltv", "type": "uint256"},
                 {"name": "liquidationThreshold", "type": "uint256"},
                 {"name": "liquidationBonus", "type": "uint256"},
                 {"name": "reserveFactor", "type": "uint256"},
                 {"name": "usageAsCollateralEnabled", "type": "bool"},
                 {"name": "borrowingEnabled", "type": "bool"},
                 {"name": "stableBorrowRateEnabled", "type": "bool"},
                 {"name": "isActive", "type": "bool"},
                 {"name": "isFrozen", "type": "bool"}],
     "inputs": [{"name": "asset", "type": "address"}]},
    {"name": "getReserveData", "type": "function", "stateMutability": "view",
     "outputs": [{"name": "unbacked", "type": "uint256"},
                 {"name": "accruedToTreasuryScaled", "type": "uint256"},
                 {"name": "totalAToken", "type": "uint256"},
                 {"name": "totalStableDebt", "type": "uint256"},
                 {"name": "totalVariableDebt", "type": "uint256"},
                 {"name": "liquidityRate", "type": "uint256"},
                 {"name": "variableBorrowRate", "type": "uint256"},
                 {"name": "stableBorrowRate", "type": "uint256"},
                 {"name": "averageStableBorrowRate", "type": "uint256"},
                 {"name": "liquidityIndex", "type": "uint256"},
                 {"name": "variableBorrowIndex", "type": "uint256"},
                 {"name": "lastUpdateTimestamp", "type": "uint40"}],
     "inputs": [{"name": "asset", "type": "address"}]},
]

ABI_POOL_UAD = [
    {"name": "getUserAccountData", "type": "function", "stateMutability": "view",
     "outputs": [{"name": "totalCollateralBase", "type": "uint256"},
                 {"name": "totalDebtBase",       "type": "uint256"},
                 {"name": "availableBorrowsBase","type": "uint256"},
                 {"name": "currentLiquidationThreshold", "type": "uint256"},
                 {"name": "ltv", "type": "uint256"},
                 {"name": "healthFactor", "type": "uint256"}],
     "inputs": [{"name": "user", "type": "address"}]},
]

ABI_POOL_RESERVE = [
    {"name": "getReserveData", "type": "function", "stateMutability": "view",
     "outputs": [
         {"name": "configuration", "type": "uint256"},
         {"name": "liquidityIndex", "type": "uint128"},
         {"name": "currentLiquidityRate", "type": "uint128"},
         {"name": "variableBorrowIndex", "type": "uint128"},
         {"name": "currentVariableBorrowRate", "type": "uint128"},
         {"name": "currentStableBorrowRate", "type": "uint128"},
         {"name": "lastUpdateTimestamp", "type": "uint40"},
         {"name": "id", "type": "uint16"},
         {"name": "aToken", "type": "address"},
         {"name": "stableDebtToken", "type": "address"},
         {"name": "variableDebtToken", "type": "address"},
         {"name": "interestRateStrategyAddress", "type": "address"},
         {"name": "accruedToTreasury", "type": "uint128"},
         {"name": "unbacked", "type": "uint128"},
         {"name": "borrowingEnabledInIsolation", "type": "bool"},
     ],
     "inputs": [{"name": "asset", "type": "address"}]},
]

ABI_ORACLE = [
    {"name": "getAssetPrice", "type": "function", "stateMutability": "view",
     "outputs": [{"name": "", "type": "uint256"}],
     "inputs": [{"name": "asset", "type": "address"}]},
]


def log(msg: str) -> None:
    print(msg, flush=True)


def main() -> int:
    w3 = Web3(Web3.HTTPProvider(RPC_URL, request_kwargs={"timeout": 30}))
    if not w3.is_connected():
        print("ERROR: RPC not reachable", file=sys.stderr)
        return 1

    chain_id = w3.eth.chain_id
    block = w3.eth.get_block("latest")["number"]
    gas_price = w3.eth.gas_price
    log(f"[i] connected: chain {chain_id}, block {block:,}, gas "
        f"{gas_price/1e9:.3f} gwei")

    dp     = w3.eth.contract(address=DATA_PROVIDER, abi=ABI_DP)
    pool   = w3.eth.contract(address=POOL_ADDR, abi=ABI_POOL_UAD)
    pool_r = w3.eth.contract(address=POOL_ADDR, abi=ABI_POOL_RESERVE)
    oracle = w3.eth.contract(address=ORACLE, abi=ABI_ORACLE)

    # -- 1. market inventory -------------------------------------------------
    tokens = dp.functions.getAllReservesTokens().call()
    log(f"[i] {len(tokens)} reserves in {MARKET_NAME}")

    reserves = []
    for sym, addr in tokens:
        addr = Web3.to_checksum_address(addr)
        cfg = dp.functions.getReserveConfigurationData(addr).call()
        rd  = dp.functions.getReserveData(addr).call()
        price = oracle.functions.getAssetPrice(addr).call() / 1e8
        debt_usd = rd[4] / 10**cfg[0] * price
        raw_bonus = cfg[3] / 100 - 100 if cfg[3] > 0 else 0.0
        reserves.append({
            "symbol": sym, "addr": addr, "decimals": cfg[0],
            "bonus": raw_bonus,
            "debt_usd": debt_usd,
        })
        log(f"    {sym:<9} bonus={raw_bonus:>6.2f}%  "
            f"debt=${debt_usd:>14,.0f}")

    # -- 2. "gun index": wallets with fresh debt on the biggest market ------
    biggest = max(reserves, key=lambda r: r["debt_usd"])
    rd_pool = pool_r.functions.getReserveData(biggest["addr"]).call()
    vdebt_token = Web3.to_checksum_address(rd_pool[10])

    transfer_topic = Web3.keccak(
        text="Transfer(address,address,uint256)").hex()
    logs = w3.eth.get_logs({
        "fromBlock": block - DEBT_WINDOW_BLOCKS, "toBlock": "latest",
        "address": vdebt_token, "topics": ["0x" + transfer_topic],
    })
    candidates = {Web3.to_checksum_address("0x" + lg["topics"][2].hex()[-40:])
                  for lg in logs}
    log(f"[i] gun index: {len(logs)} debt-mint events on {biggest['symbol']} "
        f"in last {DEBT_WINDOW_BLOCKS} blocks "
        f"(~{DEBT_WINDOW_BLOCKS*2/3600:.1f}h on Base), "
        f"{len(candidates)} wallets")

    # -- 3. score live health factors ----------------------------------------
    scored = []
    for u in candidates:
        try:
            _, td, _, _, _, hf = pool.functions.getUserAccountData(u).call()
        except Exception:
            continue
        if td == 0:
            continue
        debt_usd = td / 1e8
        if debt_usd < MIN_DEBT_USD:
            continue
        scored.append((u, hf / 1e18, debt_usd))
    scored.sort(key=lambda x: x[1])

    log(f"\n[=] WATCHLIST — wallets with ${MIN_DEBT_USD:,}+ debt, "
        f"riskiest first (HF closest to 1.0)")
    log(f"    {'wallet':<44}{'HF':>8}{'debt USD':>14}")
    for u, hf, d in scored[:10]:
        flag = "  << DANGER" if hf < DANGER_HF else ""
        log(f"    {u:<44}{hf:>8.3f}{d:>14,.0f}{flag}")

    # -- 4. PnL estimate on the top target ------------------------------------
    if scored:
        u, hf, d = scored[0]
        closeable = d * 0.5                      # 50% close factor
        biggest_bonus = biggest["bonus"]
        bonus_pct = biggest_bonus / 100
        bonus_usd = closeable * bonus_pct
        flash_fee = closeable * FLASH_LOAN_FEE
        gas_eth = GAS_UNITS * gas_price / 1e18
        gas_usd = gas_eth * 2233.19              # WETH/USD from oracle above
        log(f"\n[=] PnL if {u[:10]}… tips under water:")
        log(f"    closeable debt (50% close factor): ${closeable:,.0f}")
        log(f"    collateral seized (+{biggest_bonus:.2f}% bonus): "
            f"${closeable*(1+bonus_pct):,.0f}")
        log(f"    gross bonus:    ${bonus_usd:>10,.0f}")
        log(f"    flash-loan fee: ${flash_fee:>10,.2f}")
        log(f"    gas:            ${gas_usd:>10,.2f}")
        log(f"    NET:            ${bonus_usd-flash_fee-gas_usd:>10,.0f}")

    if not scored:
        log("\n[!] no wallet above dust threshold this run — normal, rerun later.")
    log("\n[done] read-only scan. Nothing sent on-chain, no capital at risk.")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Here is the actual output from the test run on publication day (addresses are real live positions — handle that fact like an adult):

[i] connected: chain 8453, block 53,302,906, gas 0.006 gwei
[i] 15 reserves in Aave v3 Base
    WETH      bonus=  5.00%  debt=$   169,985,533
    cbETH     bonus=  7.50%  debt=$       502,431
    USDbC     bonus=  5.00%  debt=$       109,282
    wstETH    bonus=  6.00%  debt=$       952,885
    USDC      bonus=  5.00%  debt=$   172,376,442
    weETH     bonus=  7.50%  debt=$         6,475
    cbBTC     bonus=  6.00%  debt=$   6,768,909
    ezETH     bonus=  7.50%  debt=$             0
    GHO       bonus=  0.00%  debt=$   1,193,879
    wrsETH    bonus=  7.50%  debt=$             0
    LBTC      bonus=  8.50%  debt=$             0
    EURC      bonus=  5.00%  debt=$   4,765,412
    AAVE      bonus= 10.00%  debt=$             0
    tBTC      bonus=  7.50%  debt=$        37,650
    syrupUSDC bonus=  0.00%  debt=$             0
[i] gun index: 202 debt-mint events on USDC in last 5000 blocks (~2.8h on Base), 113 wallets

[=] WATCHLIST — wallets with $2,000+ debt, riskiest first (HF closest to 1.0)
    wallet                                            HF      debt USD
    0x2E8fBA7125cFad75B8Bc750832B06aCae9729B16     1.064         6,615
    0xABe2ac9aC2b0e414d8c07951b0E8973FdeCb08c9     1.154         6,870
    0x7d2fEE35496322CD547B1A4058a839E46C53c88f     1.161         2,906
    0x1887a8E232Fc5426BF8942bce79eC7aF29B197a1     1.215        51,502
    0x7af0dCDee682595cD8Be32fE5197c005A57f5F89     1.272        39,450
    0x375c4B1d343EB08D8AE9Fed8f55888aF8DB240B4     1.307         4,971
    0xD6E888BCe22946736afb4d5DE291323596dA56bB     1.357        11,877
    0xBf30baAF628E7a8Ed3474a82e4bc45E87a253cd4     1.400        21,609
    0x3072B9B2cbAad1719240b8D4eeBE5D70a12ee550     1.414         3,443
    0x3D2C30d919A50DaD777d38A2b72c27C116E8724f     1.416        14,349

[=] PnL if 0x2E8fBA71… tips under water:
    closeable debt (50% close factor): $3,307
    collateral seized (+5.00% bonus): $3,473
    gross bonus:    $       165
    flash-loan fee: $      1.65
    gas:            $      0.01
    NET:            $       164

[done] read-only scan. Nothing sent on-chain, no capital at risk.

Read that watchlist like a predator reads a herd. Eleven wallets with six figures of combined debt, the riskiest sitting at HF 1.064 — a 6% adverse move from getting its collateral bought at a 5% discount by whoever is watching when it happens. In a normal week, several wallets on that list will tip. The scanner is the difference between knowing that and being the one positioned.

From watchlist to money

The scanner is the surveillance. Turning it into income has three levels:

Level 1 — be there when it tips. Wrap the scanner in a loop, and when a watchlist wallet crosses HF < 1, fire a liquidationCall. You’ll lose races to MEV bots on the obvious liquidations (everything gets sniped in the same block as the price move). You’ll win the messy ones: multi-collateral positions, small-but-not-dust debts the big bots skip, liquidations that become possible only after a cascade of other events. That $7,395 bonus day on Base? It was split across just 12 unique liquidator addresses — this is not a crowded trade yet, and most of those winners were not fancy. On Base, gas for the whole operation rounds to a cent, and the 0.05% flash loan fee on a $10,000 liquidation is $5 against a $500 bonus.

Level 2 — sell the surveillance. Every fund, treasury, and heavy borrower with a position on any Aave market needs to know their own distance-to-liquidation before the market does — and some will pay for a feed that tells them which other wallets are about to become the day’s liquidation cascade. A watchlist service — a Discord/Telegram alert bot fed by this exact script, extended to track arbitrary self-selected wallets — is a product you can run from the same VPS, on the same public RPC, with the same zero-key setup. The code in this article is 80% of that product.

Level 3 — trade the fear itself. When a big position appears on watchlists (including ones leaked when watchers start chattering), the position owner sometimes self-liquidates in a panic at spot prices rather than pay the 5% bonus to a sniper — and sometimes closes the position by dumping collateral on DEXs, crashing the price further. Watching who is near the edge tells you where forced selling will come from. That’s a positioning signal most traders never see.

The honest risk table

This playbook is legal (the protocol offers the bonus to anyone; liquidators are load-bearing infrastructure that keeps lending markets solvent — Aave’s own docs frame it as a service) and non-custodial (you never hold anyone’s funds except in the same atomic transaction where you repay them). But it has real ways to lose money, and you should know them before you graduate from read-only:

  • Race losses. If you send a liquidation transaction and someone else’s lands first, yours reverts — you paid gas for the privilege of losing (a few cents on Base, a real fee on mainnet). Bots handle this with tighter simulation; beginners should expect to lose most races on obvious targets and only profit consistently on the messy ones.
  • Gas spikes. Liquidations cluster exactly when the network is congested (big price moves). On Base this is trivial. On Ethereum mainnet it can eat several percent of a small bonus. The docs’ own note is blunt: only liquidate when the bonus exceeds gas costs.
  • Price-quality risk in seized collateral. The bonus is computed at oracle prices, but you may receive an illiquid collateral token (a small LST, a wrapped oddity) that trades worse than the oracle says by the time you swap it. Stick to deep markets (WETH, USDC, cbBTC) until you know the long tail.
  • Flash loan ≠ free money. The 0.05% fee is cheap, but if your transaction reverts after borrowing, you still pay gas plus the reverting loan costs nothing but the attempt. The fee is only charged on success — the loss is always just gas.
  • Parameters change. Bonuses, thresholds, and close factors are governance-tunable. The numbers in this article were live at publication; re-verify with the scanner’s own output before sizing anything.

Start with the scanner exactly as written — it costs nothing, touches nothing, and gives you the same live picture the pros run daily. Watch the watchlist for a week. When you understand how the herd moves, then decide whether you want to be a sniper, a surveillance vendor, or just a better-informed trader. All three are upgrades over paying retail for information that is, quite literally, on the open blockchain for free.

Educational content, not financial advice. Liquidation participation can violate nothing but can still lose gas money, and DeFi positions can behave worse than documented under extreme market conditions. You are responsible for your own transactions.