Small borrow and repay loop: liquidation math

A supply-only position has no debt to liquidate. A borrow adds a repayment obligation and a collateral threshold I need to understand before opening it.

This walkthrough uses 0.1 ETH valued at $350 and a $100 USDC loan. The prices, rates, and risk parameters are illustrative. I use them to calculate a liquidation boundary and plan the repayment, rather than report a live experiment.

Setting up the borrow

ETH collateral against USDC debt makes the price dependency visible. If ETH falls while the USDC debt remains near $100, health factor falls too. Interest can increase the debt even without a price move.

The numbers going in

Assumed parameters for the worked example, not the current Arbitrum reserve settings:

ParameterValue
LTV80%
Liquidation threshold82.5%
Liquidation penalty5%
Variable borrow rate~2.5%

With 0.1 ETH worth ~$350 as collateral:

  • Max borrow (at 80% LTV): ~$280 USDC
  • Liquidation starts at: ~$288.75 debt relative to collateral value

The example borrows $100, or about 28.6% of collateral value. That is the position LTV, not the reserve utilization rate.

Health Factor = (350 × 0.825) / 100 = 2.8875

With debt held fixed, the liquidation boundary is about $1,212 per ETH: $100 / (0.1 × 0.825). That is roughly 65% below the example entry price.

Debt repayment in exchange for discounted collateral

Liquidation is not the protocol seizing all your collateral. It is a third party (the liquidator) repaying part of your debt in exchange for your collateral at a discount. The liquidator profits from the penalty, and the protocol stays solvent.

The liquidation sequence

flowchart TD
    A["Health factor drops below 1.0"] --> B["Position becomes liquidatable"]
    B --> C["Liquidator calls liquidationCall()"]
    C --> D["Liquidator repays debt within the active close factor"]
    D --> E["Liquidator receives collateral + 5% bonus"]
    E --> F["Borrower's debt decreases"]
    F --> G["Recompute remaining debt and health factor"]

Three details to check in the deployed version:

  1. Close factor: The version, health factor, and position size can affect how much debt a liquidation can close. The calculation below assumes a 50% close factor; do not rely on that assumption for a small live position. Check the deployed liquidation logic. Aave liquidation implementation

  2. The penalty comes from your collateral: If a liquidator repays $50 of your USDC debt, they get $52.50 worth of your ETH (the $50 + 5% penalty). You keep the borrowed USDC but lose more collateral than the debt reduction.

  3. Liquidation is permissionless: Anyone can call liquidationCall on the Aave Pool contract. In practice, it is MEV bots and dedicated liquidation services racing to be first. Your position does not sit in danger waiting for Aave to act — bots are watching every block.

A liquidation calculation with fixed assumptions

Say ETH drops from $3,500 to $2,400 while I have 0.1 ETH collateral and $100 USDC debt:

Collateral value: 0.1 × $2,400 = $240
Health factor: (240 × 0.825) / 100 = 1.98

Still safe. Now ETH drops to $1,250:

Collateral value: 0.1 × $1,250 = $125
Health factor: (125 × 0.825) / 100 = 1.03

Getting tight. At $1,212:

Collateral value: 0.1 × $1,212 = $121.20
Health factor: (121.20 × 0.825) / 100 = 0.999

Liquidation is now possible. A liquidator repays $50 of debt (50% close factor) and receives:

Collateral claimed: $50 × 1.05 = $52.50 worth of ETH
ETH claimed: $52.50 / $1,212 = 0.0433 ETH

After liquidation:

Remaining collateral: 0.1 - 0.0433 = 0.0567 ETH ($68.72)
Remaining debt: $100 - $50 = $50
New health factor: (68.72 × 0.825) / 50 = 1.134

The position is back above 1.0. You lost 0.0433 ETH (~$52.50 at that price) to eliminate $50 of debt. That $2.50 difference is the liquidation penalty — money that went to the liquidator, not back to you.

Oracle dependencies and why they matter

Aave does not check the actual market price of ETH to decide your health factor. It uses the price reported by a Chainlink oracle. The difference matters when the market moves between feed updates.

Chainlink price feeds update when:

  1. Deviation threshold: The price has moved by a certain percentage (feed-specific) since the last on-chain update.
  2. Heartbeat: A maximum time between updates regardless of price movement (also feed-specific). Chainlink data feeds docs

This means there can be a gap between the real market price and the on-chain oracle price. During a fast crash, the oracle might update every few seconds as the deviation threshold keeps getting hit. During sideways markets, it might only update once an hour.

Reading one ETH/USD feed

This reads an ETH/USD feed on Arbitrum. Verify the actual source used by the Aave market; a standalone feed read is not proof that the Pool uses it without an adapter:

from web3 import Web3

rpc = Web3(Web3.HTTPProvider("https://arb1.arbitrum.io/rpc"))

# Chainlink ETH/USD on Arbitrum
ETH_USD_FEED = "0x639Fe6ab55C921f74e7fac1ee960C0B6293ba612"

feed_abi = [
    {
        "inputs": [],
        "name": "latestRoundData",
        "outputs": [
            {"name": "roundId", "type": "uint80"},
            {"name": "answer", "type": "int256"},
            {"name": "startedAt", "type": "uint256"},
            {"name": "updatedAt", "type": "uint256"},
            {"name": "answeredInRound", "type": "uint80"},
        ],
        "stateMutability": "view",
        "type": "function",
    },
    {
        "inputs": [],
        "name": "decimals",
        "outputs": [{"name": "", "type": "uint8"}],
        "stateMutability": "view",
        "type": "function",
    },
]

feed = rpc.eth.contract(address=ETH_USD_FEED, abi=feed_abi)

decimals = feed.functions.decimals().call()
round_data = feed.functions.latestRoundData().call()

price = round_data[1] / (10 ** decimals)
updated_at = round_data[3]

import time
staleness = int(time.time()) - updated_at

print(f"ETH/USD oracle price: ${price:,.2f}")
print(f"Last updated: {staleness}s ago")
# The one-hour age flag is illustrative; check this feed's heartbeat.
print(f"Older than one hour: {'YES' if staleness > 3600 else 'no'}")

Why staleness matters

If the oracle has not updated in a while and the real price has moved, two things can happen:

  1. Feed above market price: The oracle says ETH is $3,500 but the market is at $3,000. Your health factor looks better than it actually is. You might think you are safe when you are not. Liquidators watching the real price will pounce the moment the oracle updates.

  2. Feed below market price: The oracle says ETH is $3,000 but the market has recovered to $3,500. Your health factor looks worse than reality. You might get liquidated at a price that no longer reflects the market — the liquidator profits from the stale low price.

In practice, the first scenario is more dangerous for borrowers. During crashes, oracle updates can lag just long enough for a position to go deeply underwater, leading to a larger liquidation than necessary.

Aave’s circuit breakers

Aave V3 has a few protections against oracle issues:

  • Price oracle sentinel (on L2s): adds a grace period after sequencer downtime before liquidations can proceed. Check the deployed sentinel and grace-period configuration; the protection depends on its actual rules. Price Oracle Sentinel docs
  • Siloed borrowing: some volatile or low-liquidity assets can only be borrowed in isolation, preventing oracle manipulation on one asset from cascading.
  • Supply and borrow caps: limit how much of any asset can be deposited or borrowed, reducing the impact of an oracle exploit.

These help, but they do not eliminate oracle risk. You are always trusting Chainlink to report accurate prices and Aave governance to set sensible circuit breaker parameters.

Rehearse the repayment before spending the loan

For this $100 example I would leave the borrowed USDC in the wallet until I had completed a repayment. That isolates the lending mechanics from a second trade.

  1. Supply collateral and read getUserAccountData using the helper in the lending deposit post.
  2. Borrow 100 USDC using the current market’s variable-rate mode.
  3. Verify the wallet received USDC and read the updated debt and health factor.
  4. Obtain a fresh debt amount before repaying, including accrued interest.
  5. Approve the Pool for repayment, then repay and confirm that debt is zero.

At a constant 2.5% APR, three days of simple interest on $100 is 100 × 0.025 × 3 / 365, or about $0.0205. The live debt uses protocol accounting and may change before inclusion. Save receipts for the approval, borrow, and repayment instead of assuming a fixed gas budget.

Risk analysis

Liquidation risk

With a $100 borrow against $350 in ETH collateral, ETH would need to drop from ~$3,500 to ~$1,212 (a 65% crash) before liquidation. That calculation assumes the collateral quantity, debt value, and threshold stay fixed. Do not treat the percentage as a guarantee; keep room to repay before the boundary.

Oracle lag during fast moves

During a sharp move, a feed can update when its deviation threshold is reached but the update cadence is not a guarantee that a borrower can act before the next price reaches the protocol. If you are close to the liquidation threshold, even a few seconds of oracle lag can mean the difference between having time to add collateral and getting liquidated.

Interest rate spikes

Variable borrow rates can spike if utilization jumps suddenly. On USDC pools this is less volatile than on exotic assets, but during market stress (when everyone is borrowing stablecoins to avoid liquidation), rates can temporarily hit 50%+ due to the interest rate model’s kink. At a hypothetical 100% APR, $100 of debt accrues roughly $0.27 a day. Small debts still need monitoring if left open for a long time.

Sequencer risk on L2s

Arbitrum’s sequencer is a single point of failure. If it goes down, no transactions process, which means you cannot repay or add collateral to avoid liquidation. Aave’s Price Oracle Sentinel gives a grace period after sequencer recovery, but you are still stuck during the outage. That dependency applies to small positions too. Keep a repayment plan that accounts for downtime instead of assuming monitoring guarantees access.

Repayment requires the borrowed asset

This sounds obvious but it catches people: to repay a USDC borrow, you need USDC. If you swapped the borrowed USDC for another token and that token dropped in value, you might not have enough to repay. Always keep borrowed stablecoins accessible or have a clear path back to the borrowed asset.

The useful result of a small round trip

I want a record showing the borrowed asset arriving, debt accruing, the repayment permission, and debt returning to zero. That proves more about the workflow than a dashboard screenshot alone.

The liquidation calculation tells me where the example becomes eligible. It does not tell me how much time I will have to fix a live position. Practicing the repayment while there is plenty of margin is the part I would carry into a larger experiment.