Small borrow and repay loop: liquidation math
Why borrowing is different from supplying
In the previous post, I deposited 200 USDC into Aave V3 on Arbitrum. That was risk-free in the sense that a supply-only position cannot be liquidated. Borrowing changes everything. The moment you take a loan, you create a position that the protocol can partially close if your collateral value drops too far relative to your debt.
This post walks through a small borrow-and-repay loop using real parameters from Aave V3 on Arbitrum. The goal is to understand liquidation math from the borrower’s perspective and see how Chainlink oracle behavior affects when and how liquidations happen.
Setting up the borrow
For this experiment I needed collateral that can actually fluctuate in price. USDC against USDC borrowing does not teach you anything about liquidation. So I supplied 0.1 ETH (roughly $350 at the time) and borrowed a small amount of USDC against it.
The numbers going in
Aave V3 ETH parameters on Arbitrum at the time of this experiment:
| Parameter | Value |
|---|---|
| LTV | 80% |
| Liquidation threshold | 82.5% |
| Liquidation penalty | 5% |
| 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
I borrowed $100 USDC. That is intentionally conservative — about 28.5% utilization against a 80% max LTV.
Health Factor = (350 × 0.825) / 100 = 2.8875
Comfortable margin. ETH would need to drop roughly 65% before liquidation. The point is not to get close to liquidation — it is to understand the math so you recognize danger before it arrives.
How liquidation actually works
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 up to 50% of debt"]
D --> E["Liquidator receives collateral + 5% bonus"]
E --> F["Borrower's debt decreases"]
F --> G["Health factor recovers above 1.0"]
Key details that are not obvious from the docs:
Partial liquidation: On Aave V3, a liquidator can repay up to 50% of the borrower’s debt in a single call (this becomes 100% if health factor drops below a certain threshold, called the “close factor”). You do not lose everything at once.
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.
Liquidation is permissionless: Anyone can call
liquidationCallon 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.
Doing the math on a real scenario
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. This distinction matters more than most people realize.
How Chainlink feeds work on Aave
Chainlink price feeds update when:
- Deviation threshold: The price has moved by a certain percentage (typically 0.5% for ETH/USD) since the last on-chain update.
- Heartbeat: A maximum time between updates regardless of price movement (typically 1 hour for ETH/USD on Arbitrum). 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.
Checking the oracle on-chain
You can read the Chainlink feed directly to see what price Aave is using for your collateral:
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")
print(f"Stale: {'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:
Stale high 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.
Stale low 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. If the Arbitrum sequencer goes down and comes back, borrowers get time to adjust positions before liquidators can act. 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.
The borrow-and-repay loop
Here is what the actual borrow and repayment looked like.
Borrowing
- With 0.1 ETH already supplied, I went to the Aave dashboard and selected USDC to borrow.
- Entered $100 USDC, variable rate.
- Confirmed the transaction. Gas was ~$0.04 on Arbitrum.
- USDC appeared in my wallet immediately. Health factor showed 2.89 on the dashboard.
Verifying on-chain
Using the same getUserAccountData call from the previous post:
data = pool.functions.getUserAccountData(wallet).call()
collateral = data[0] / 1e8
debt = data[1] / 1e8
health_factor = data[5] / 1e18
print(f"Collateral: ${collateral:.2f}")
print(f"Debt: ${debt:.2f}")
print(f"Health factor: {health_factor:.4f}")
Collateral: $351.24
Debt: $100.00
Health factor: 2.8977
The debt was $100 exactly. Over the next few days, it ticked up slightly as variable interest accrued. After 3 days, debt was $100.02 — about $0.007/day in interest at 2.5% APR on $100. Negligible at this scale but the mechanism is clear.
Repaying
To close the loop, I repaid the full $100 USDC plus the tiny accrued interest.
- Went to the Aave dashboard, selected USDC under my borrows, clicked Repay.
- Selected “Max” to repay the full amount including interest.
- Approved USDC spend (the repayment goes to the Aave Pool, not back to your wallet — you are paying off debt, not withdrawing collateral).
- Confirmed the repay transaction. Gas: ~$0.04.
- Health factor went back to the max uint value — effectively infinite with no debt.
Total cost of the experiment: ~$0.10 in gas for the borrow and repay transactions, plus ~$0.02 in interest. Under 15 cents to learn how the entire borrow/repay cycle works.
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 is extreme but not impossible — ETH dropped about 80% in the 2022 bear market. The mitigation is simple: do not borrow at high utilization, and keep the monitoring script running.
Oracle lag during fast moves
During a sharp crash, Chainlink updates frequently (every 0.5% deviation for ETH/USD) but there are still moments between updates where your real risk is higher than what the protocol sees. 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. For a $100 position this is irrelevant — even 100% APR on $100 is $0.27/day. For larger positions, consider this.
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. For small positions this is a theoretical risk. For large positions, it is a reason to run lending positions on L1 or diversify across L2s.
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.
What I learned
Liquidation math is straightforward once you break it down. Health factor is the ratio between your weighted collateral and your debt. The liquidation penalty is a fixed percentage on top. There is no mystery — just arithmetic you should run before borrowing.
The oracle is not the market. Your health factor is calculated from the Chainlink price, not the live market price. During fast moves, these can diverge. Monitoring both the oracle price and the market price gives you a more accurate picture of your real risk.
A borrow-repay round trip on Arbitrum costs almost nothing. Under 15 cents total. There is no excuse for not testing the full loop with real money on a small scale before sizing up.
Variable interest at small scale is negligible. $0.007/day on a $100 borrow. The gas to execute the borrow cost more than a week of interest. Rate mode decisions only matter once your position is large enough for the interest to be material.
Repaying is not the reverse of borrowing. Borrowing sends you tokens. Repaying sends tokens to the protocol — you need a separate approval for that. The flow is different enough that practicing it before you need to do it under pressure is worth the 15 cents.