DeFi liquidation risk: health factors, oracle gaps and bad debt
How a lending market decides when a position can be liquidated, how much a liquidator repays and seizes, and when a price gap leaves bad debt. Worked numbers on a model market, compared with Aave v3 and Compound III.
- Written by
- Quantix Research, DeFi Security
- Published
- Reading time
- 11 minutes
- Category
- DeFi Security
Technical analysis. Built from the public sources in the bibliography. Incident details are as reported there; no new discovery is claimed.

Contents
- A model market
- Health factor and the liquidation formulas
- A liquidation, step by step
- Stress scenarios: B falls 10%, 25% and 40%
- When a liquidation leaves bad debt
- A gap between oracle updates
- Oracle dependencies
- Thin liquidity and liquidation cascades
- Design controls
- How Aave v3 and Compound III document it
- Risk matrix and reviewer checklist
- Invariants worth fuzzing
- Conclusions
- Recommendations
- Bibliography
An over-collateralised lending market stays solvent because liquidators repay failing borrowers’ debt and take their collateral, at a discount, before it is worth less than what is owed. That works while the oracle price is close to the price the collateral can be sold at, prices move gradually enough for liquidations to happen early, and liquidators can sell what they seize for more than they repaid.
This analysis tests each condition on a model market, derives when a liquidation leaves bad debt, and compares the model with Aave v3 and Compound III. Oracle manipulation itself is covered in our analysis of oracle designs.
A model market
Consider a lending market with the parameters below: a model built for the arithmetic, not any deployed protocol. Asset A is liquid, Asset B volatile and thinly traded; the borrowable stablecoin is priced at $1.
| Parameter | Asset A | Asset B | Stablecoin |
|---|---|---|---|
| Oracle price at the start | $2,000 | $10.00 | $1.00 |
| Loan-to-value (LTV) | 80% | 55% | — |
| Liquidation threshold (LT) | 83% | 65% | — |
| Liquidation bonus (b) | 5% | 10% | — |
| LT × (1 + b) | 0.8715 | 0.715 | — |
| Push feed: heartbeat, deviation | 1 h, 0.5% | 24 h, 2% | 24 h, 0.25% |
One call may repay 50% of an account’s debt while its health factor is above 0.95, and all of it at or below. Oracle reads revert on stale or non-positive answers.
| Account | Collateral | Debt | Collateral value | Borrowing power | Health factor |
|---|---|---|---|---|---|
| 1 | 10 A | 12,000 | 20,000 | 16,000 | 1.383 |
| 2 | 10 A | 16,000 | 20,000 | 16,000 | 1.0375 (max LTV) |
| 3 | 3,000 B | 15,000 | 30,000 | 16,500 | 1.300 |
| 4 | 4,000 B | 22,000 | 40,000 | 22,000 | 1.182 (max LTV) |
| 5 | 5 A + 2,000 B | 16,000 | 30,000 | 19,000 | 1.331 |
Health factor and the liquidation formulas
// Oracle prices in USD. q = units held, p = price, D = debt.borrowing power BP = Σ q_i · p_i · LTV_i new borrows only while D ≤ BPhealth factor HF = Σ q_i · p_i · LT_i / D liquidatable when HF < 1close factor Rmax = 0.5 · D if HF > 0.95, else Dcollateral seized s = R · (1 + b_j) / p_j capped at q_j; then R = q_j · p_j / (1 + b_j)liquidator's gain s · p_j − R = b_j · R at the oracle price, before costsLTV limits borrowing; the liquidation threshold triggers liquidation. A single-collateral position opened at maximum LTV becomes liquidatable after a fall of 1 − LTV / LT: 3.61% for A, 15.38% for B. Account 5’s health factor is (10,000 × 0.83 + 20,000 × 0.65) / 16,000 = 1.331.
A liquidation, step by step
Take Account 4 after B falls 25% to $7.50: its health factor is 4,000 × 7.50 × 0.65 / 22,000 = 0.886.
- The oracle posts B at $7.50 after the move crosses the deviation threshold.
- A liquidator’s bot, tracking positions off-chain, finds Account 4 at 0.886.
- The liquidator flash-borrows 22,000 stablecoins (own capital works too).
- It calls
liquidate(account4, B, 22_000e18). - The market accrues interest, reads checked prices, finds HF ≤ 0.95 (close factor 100%) and seizes 22,000 × 1.10 / 7.50 = 3,226.67 B.
- It pulls 22,000 stablecoins, clears the debt and sends the B; Account 4 keeps 773.33 B.
- The liquidator sells the B for 23,473.43 in the pool described under cascades.
- It repays the loan and keeps 1,473.43 before gas and fees: the 2,200 bonus less slippage.
// Simplified, illustrative. Not production code.// 18-decimal tokens and USD prices; factors in basis points (BPS = 10_000).function liquidate(address borrower, address asset, uint256 repayAmount) external nonReentrant { _accrueInterest(); // debt must include interest first uint256 hf = healthFactor(borrower); // Σ q·p·LT / D, from checked prices if (hf >= 1e18) revert AccountHealthy(); uint256 debt = debtOf[borrower]; uint256 maxRepay = hf <= 0.95e18 ? debt : debt * CLOSE_FACTOR_BPS / BPS; uint256 repay = repayAmount < maxRepay ? repayAmount : maxRepay; uint256 price = _checkedPrice(asset); // reverts if stale or not positive uint256 bonus = BPS + bonusBps[asset]; // 10_500 for A, 11_000 for B uint256 seize = Math.mulDiv(repay, bonus * 1e18, BPS * price); // R·(1+b)/p, rounded down uint256 balance = collateralOf[borrower][asset]; if (seize > balance) { // collateral cannot cover R·(1+b): seize = balance; // take all of it, accept only its worth repay = Math.mulDiv(balance, price * BPS, bonus * 1e18, Math.Rounding.Ceil); } stablecoin.safeTransferFrom(msg.sender, address(this), repay); debtOf[borrower] = debt - repay; totalDebt -= repay; collateralOf[borrower][asset] = balance - seize; IERC20(asset).safeTransfer(msg.sender, seize); if (collateralValue(borrower) == 0 && debtOf[borrower] != 0) { uint256 loss = debtOf[borrower]; // nothing left to seize: write the rest off debtOf[borrower] = 0; // now, so it stops accruing interest, totalDebt -= loss; badDebt += loss; // and cover it from reserves or a backstop } emit Liquidation(borrower, msg.sender, asset, repay, seize);}Stress scenarios: B falls 10%, 25% and 40%
B’s oracle price now falls to $9.00, $7.50 or $6.00 in one update; Accounts 1 and 2 are unaffected. Each liquidatable account is liquidated once, for the most its close factor allows.
| Account | Start ($10.00) | −10% ($9.00) | −25% ($7.50) | −40% ($6.00) |
|---|---|---|---|---|
| 3 | 1.300 | 1.170 | 0.975 | 0.780 |
| 4 | 1.182 | 1.064 | 0.886 | 0.709 |
| 5 | 1.331 | 1.250 | 1.128 | 1.006 |
| Scenario | Close factor | Repaid | Seized | Liquidator profit | Bad debt | Left on the account |
|---|---|---|---|---|---|---|
| −25%, account 3 | 50% | 7,500 | 1,100 B ($8,250) | 750 | 0 | 1,900 B, debt 7,500, HF 1.235 |
| −25%, account 4 | 100% | 22,000 | 3,226.67 B ($24,200) | 2,200 | 0 | 773.33 B, no debt |
| −40%, account 3 | 100% | 15,000 | 2,750 B ($16,500) | 1,500 | 0 | 250 B, no debt |
| −40%, account 4 | 100%, capped | 21,818.18 | 4,000 B ($24,000) | 2,181.82 | 181.82 | Nothing |
At −10% nothing is liquidatable. At −25%, Account 3 is between 0.95 and 1, so only half its debt can be repaid, lifting it to 1.235. Account 5 survives −40% because its Asset A does not move. At −40%, clearing Account 4 would need 22,000 × 1.10 / 6.00 = 4,033.33 B; the liquidator takes all 4,000 B, repays 24,000 / 1.10 = 21,818.18 and leaves 181.82 unbacked.
When a liquidation leaves bad debt
For one account with collateral value V, debt D and bonus b, repaying R hands over (1 + b)R of collateral, so equity falls by bR each time. The debt can be cleared only if V ≥ D(1 + b); otherwise the collateral runs out after V / (1 + b) is repaid, and bad debt = max(0, D − V / (1 + b)).
With one collateral asset V / D = HF / LT, so bad debt appears exactly when HF < LT × (1 + b): 0.8715 for A, 0.715 for B; with several assets, when D > Σ V_i / (1 + b_i). A partial liquidation raises the health factor, (V − (1 + b)R) × LT / (D − R), if and only if V > D(1 + b). Below that it lowers it: repaying half of Account 4’s debt at −40% would take it from 0.709 to 0.703.
If one update multiplies the price by g, a single-collateral position at health factor h moves to g·h, so full liquidation leaves bad debt once the fall exceeds 1 − LT(1 + b) / h.
| Position before the gap | Asset A | Asset B |
|---|---|---|
| Just liquidatable (HF = 1) | 12.85% | 28.50% |
| At maximum LTV | 16.00% | 39.50% |
| HF = 1.5 | 41.90% | 52.33% |
At maximum LTV this is 1 − LTV(1 + b). Aave v3’s configurator rejects collateral whose threshold times bonus exceeds 100%, the same guard: with LT × (1 + b) ≤ 1, collateral at HF = 1 covers the debt plus the bonus. Code can hide the loss too: without a cap, full liquidation beyond the boundary reverts; without a write-off, leftover debt keeps accruing interest.
// Simplified, illustrative. Not production code. The same function without three checks. function liquidate(address borrower, address asset, uint256 repayAmount) external nonReentrant {- uint256 hf = healthFactor(borrower); // interest not accrued: HF overstated+ _accrueInterest();+ uint256 hf = healthFactor(borrower); ...- collateralOf[borrower][asset] -= seize; // reverts once V < R·(1+b); bots must retry smaller+ if (seize > balance) { seize = balance; repay = _worthOf(balance, price, bonus); }+ collateralOf[borrower][asset] = balance - seize; ...- // the last collateral is gone; the remaining debt stays and keeps accruing interest+ if (collateralValue(borrower) == 0 && debtOf[borrower] != 0) _writeOffBadDebt(borrower); }A gap between oracle updates
Now let A move. On a rollup, A’s feed last read $2,000; the market falls 18% while updates cannot land, and the next round reads $1,640. Accounts 1 and 5 stay healthy (1.134, 1.238). Account 2 drops to 16,400 × 0.83 / 16,000 = 0.851, and its 16,400 of collateral is below 16,000 × 1.05 = 16,800: the liquidator takes all 10 A, repays 16,400 / 1.05 = 15,619.05, earns 780.95, and 380.95 is bad debt. The gap exceeds A’s 16% tolerance at maximum LTV.
Had the feed printed $1,920 first, a liquidator would have repaid 8,000 for 4.375 A (HF 0.996 to 1.1205), and at $1,640 (0.957) another 4,000 for 2.561 A, leaving 3.064 A against 4,000 of debt at 1.043. The loss comes from the gap, not the fall.
Oracle dependencies
Deviation and lag. Chainlink’s documentation says its feeds do not stream: they update when the value deviates beyond a threshold or the heartbeat passes. The on-chain price can trail the market by that threshold plus latency, 2% for B here, which must be small against the figure’s distances: 3.61% (A) and 15.38% (B) to liquidation, and 50% close-factor bands of 4.82 and 4.23 percentage points.
Staleness. The documentation tells consumers to check updatedAt and pause or switch mode when an answer is too old. A per-feed maximum age catches a stopped feed, not lag; and if every read reverts on staleness, liquidations stop when most needed.
Sequencer downtime. An outage turns a gradual fall into a gap while borrowers cannot add collateral. Aave v3’s PriceOracleSentinel blocked borrows and liquidations during downtime and a grace period, except below HF 0.95; v3.7 removed it, citing false positives and blocked liquidations that let positions deteriorate.
Thin collateral. With B at $8.70, Account 4 sits at 1.028; a 3.4% push to $8.40 lets a liquidator repay 11,000 for 1,440.48 B, worth 12,532.14 at the true price. Whether that pays is the inequality in our primer on the cost of manipulation.
Thin liquidity and liquidation cascades
The liquidator must sell what it seizes. With slippage s and gas G, repaying R earns (1 + b)R(1 − s) − R − G, so before gas it pays only while s < b / (1 + b): 9.09% for B, 4.76% for A.
Give B a constant-product pool with a 0.3% fee that held 100,000 B and 1,000,000 stablecoins; arbitraged to $7.50, it holds 115,470.05 B and 866,025.40. Selling Account 4’s 3,226.67 B returns 23,473.43 (3.00% slippage) and moves the price 5.36% to $7.098. The largest break-even sale, x((1 − f)(1 + b) − 1) / (1 − f) ≈ b·x for reserve x and fee f, is 11,199.55 B (about $84,000), and it moves the price 16.88%.
If B’s feed follows this market, each sale lowers the next print and pushes more positions below 1, until none cross or slippage exceeds the bonus and liquidations stop, leaving positions to decay into bad debt.
Gas sets a floor: the smallest profitable repayment, G / (b − s(1 + b)), is $1,012.66 for A at 1% slippage and $597.01 for B at 3%, with an assumed G of $40. Aave’s v3.3 notes reason the same way: a $2,000 threshold at a 1% bonus leaves $20 for gas.
Design controls
- Keep the gap tolerance,
1 − LTV(1 + b), above the largest plausible single-update move, outages included. - Size supply caps to b·x across liquidators’ venues, not to market capitalisation.
- Give thin assets isolation-style debt ceilings. Aave’s documentation marks its Isolation Mode debt ceiling deprecated as of v3.7, in favour of isolated E-Mode.
- Set the bonus above expected slippage at the cap, with a minimum borrow size and full liquidation of small positions.
- Write off debt without collateral in the same call, against reserves or a backstop.
- Set per-feed maximum ages, deviation thresholds inside the close-factor bands, a secondary price bound and a stale-price rule.
How Aave v3 and Compound III document it
| Mechanic | Model market | Aave v3 | Compound III |
|---|---|---|---|
| Borrowable | One stablecoin | Several reserves | One base asset |
| Trigger | HF < 1 | HF < 1 | Negative liquidity at liquidateCollateralFactor (above borrowCollateralFactor) |
| Collateral goes to | Liquidator | Liquidator (liquidationCall) | Protocol (absorb), sold via buyCollateral |
| Per call | 50%; 100% at HF ≤ 0.95 | 50%; 100% at HF ≤ 0.95 or under 2,000 base units; no leftover under 1,000 (v3.3) | Whole account |
| Incentive | Bonus per asset | Bonus per reserve, less any protocol fee | liquidationFactor penalty (0.93 is 7%); buyer discount |
| Bad debt | Written off in the call | Reserve deficit (v3.3) | Paid from reserves |
| Oracle | Staleness and sign checked | Sequencer gating removed (v3.7) | getPrice rejects only non-positive answers |
Aave leaves execution risk with the liquidator, as the model does, and since v3.3 records unbacked debt as a reserve deficit, because such debt is unlikely to be repaid and would keep accruing interest. Compound III makes the protocol the counterparty: absorb credits the account with collateral value times liquidationFactor and charges any shortfall to reserves, and the collateral is then sold at a discount of storeFrontPriceFactor × (1 − liquidationFactor) while reserves are below targetReserves. At that price the protocol loses when D > V × (1 − storeFrontPriceFactor × (1 − liquidationFactor)), the counterpart of the model’s D > V / (1 + b).
Risk matrix and reviewer checklist
| Risk | Trigger | Impact | Primary control |
|---|---|---|---|
| Gap beyond tolerance | One update falls more than 1 − LT(1 + b)/h | High | Thresholds from gap tolerance; caps |
| Oracle lag or staleness | Wide deviation threshold; missed heartbeat | High | Thresholds inside the bands; maximum age |
| Sequencer outage | Rollup stops ordering transactions | High | Grace-period policy; lower caps |
| Thin-collateral manipulation | Reported price moved cheaply | High | Caps from manipulation cost; price bound |
| Unprofitable liquidation | Slippage and gas above the bonus | Medium | Bonus sizing; minimum borrow |
| Cascade | Liquidation sales move the oracle’s market | High | Supply caps against depth; low LT |
| Unrecognised bad debt | Collateral exhausted, debt left | High | Same-call write-off |
- One checked price path for every health-factor computation.
- Interest accrued before any health-factor check.
- Liquidation reverts at HF ≥ 1, computed with LT, not LTV.
- Seizure capped at the balance, repayment reduced to match, rounding in the protocol’s favour.
- Same-call write-off of debt without collateral.
- LTV ≤ LT and LT × (1 + b) < 1 enforced; caps derived from liquidity.
- Pause, stale-price and sequencer-restart behaviour specified and tested.
The Fenwick Lending research assessment writes several of these items as formal properties. A smart contract audit of a lending market should cover parameters as well as code; the Quantix Risk Index scores this stress under economic security.
Invariants worth fuzzing
The algebra yields properties precise enough to fail, if the handler can move prices in large single steps; writing invariants that find bugs covers handlers in general.
// Simplified, illustrative. Not production code. Setup, deposit and borrow actions omitted.contract LiquidationHandler is Test { ModelMarket internal market; MockFeed[2] internal feeds; // Asset A, Asset B address[] public borrowers; // the five model accounts bool public settled; function movePrice(uint256 which, uint256 bps) external { // one update: -60% to +30% MockFeed feed = feeds[which % 2]; feed.setPrice(feed.price() * bound(bps, 4_000, 13_000) / 10_000); settled = false; } function liquidate(uint256 seed, uint256 which, uint256 amount) external { address who = borrowers[seed % borrowers.length]; uint256 hfBefore = market.healthFactor(who); try market.liquidate(who, market.collateralAssets(which % 2), amount) { assertLt(hfBefore, 1e18, "liquidated an account with HF >= 1"); } catch {} settled = false; } function settle() external { // liquidate until healthy or empty for (uint256 i; i < borrowers.length; i++) _liquidateFully(borrowers[i]); settled = true; }} contract LiquidationInvariants is Test { function invariant_noDebtWithoutCollateral() public view { for (uint256 i; i < 5; i++) { address who = handler.borrowers(i); if (market.collateralValue(who) == 0) assertEq(market.debtOf(who), 0); } } function invariant_debtBackedAfterSettlement() public view { if (handler.settled()) assertLe(market.totalDebt(), market.totalCollateralValue()); }}Also check conservation (cash plus outstanding and written-off debt equals net deposits plus interest), that a partial liquidation with V ≥ D(1 + b) never lowers the health factor, and that a liquidator never receives more than (1 + b) times what it repaid.
Conclusions
A liquidation is a sale of collateral at a discount, timed by an oracle. It protects a market while prices fall gradually, the oracle keeps up and the collateral sells for more than the debt repaid. The boundary is exact: a single-collateral position leaves bad debt when HF < LT(1 + b), and one gap larger than 1 − LT(1 + b)/h takes a position at health factor h past it.
Losses come from parameters and policy as much as from code. Aave v3 and Compound III resolve these trade-offs differently, and both have revised their answers.
Recommendations
Protocol designers
- Derive each collateral’s LT and bonus from the single-update move it must survive, keeping
LT × (1 + b)well below 1. - Size supply caps and debt ceilings to what liquidators can sell at break-even.
- Write off debt left without collateral in the same transaction.
Risk and operations
- Recompute gap tolerance, break-even size and the gas floor when liquidity or parameters change.
- Document liquidation behaviour on stale prices, during pauses and after sequencer restarts.
Reviewers
- Recompute the stress tables with the protocol’s parameters and largest positions, including a gap.
- Fuzz with handlers that move prices in single large steps.
Bibliography
Primary sources: standards, official documentation, incident reports and papers.
- 1Pool (Aave V3 smart contracts)(opens in a new tab)Aave documentation
- 2Aave v3.3 features: bad debt management and liquidation changes(opens in a new tab)aave-dao/aave-v3-origin on GitHub
- 3PriceOracleSentinel removal (Aave v3.7)(opens in a new tab)aave-dao/aave-v3-origin on GitHub
- 4PoolConfigurator.sol (collateral parameter checks)(opens in a new tab)aave-dao/aave-v3-origin on GitHub
- 5Liquidation (Compound III)(opens in a new tab)Compound documentation
- 6Helper functions: asset info and getPrice (Compound III)(opens in a new tab)Compound documentation
- 7CometWithExtendedAssetList.sol(opens in a new tab)compound-finance/comet on GitHub
- 8What are Chainlink Data Feeds?(opens in a new tab)Chainlink documentation
Applied on this site
Assessments, case studies and tools that apply this publication.







