Skip to content
Quantix Security Labs
Technical analysis

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.

Illustration: a stack of collateral that has fallen through a red liquidation-threshold plane beside a fixed column of debt, with a falling price line and a liquidator taking collateral.
Contents
  1. A model market
  2. Health factor and the liquidation formulas
  3. A liquidation, step by step
  4. Stress scenarios: B falls 10%, 25% and 40%
  5. When a liquidation leaves bad debt
  6. A gap between oracle updates
  7. Oracle dependencies
  8. Thin liquidity and liquidation cascades
  9. Design controls
  10. How Aave v3 and Compound III document it
  11. Risk matrix and reviewer checklist
  12. Invariants worth fuzzing
  13. Conclusions
  14. Recommendations
  15. 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.

ParameterAsset AAsset BStablecoin
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.87150.715—
Push feed: heartbeat, deviation1 h, 0.5%24 h, 2%24 h, 0.25%
Model market parameters (illustrative)

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.

AccountCollateralDebtCollateral valueBorrowing powerHealth factor
110 A12,00020,00016,0001.383
210 A16,00020,00016,0001.0375 (max LTV)
33,000 B15,00030,00016,5001.300
44,000 B22,00040,00022,0001.182 (max LTV)
55 A + 2,000 B16,00030,00019,0001.331
Five model accounts. Health factors rounded to three decimals; Account 2’s is exact.

Health factor and the liquidation formulas

Formulas used throughouttext
// 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 costs

LTV 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.

One liquidation in the model market, from oracle update to the liquidator’s profitFive lifelines: oracle network, lending market, liquidator, flash lender and DEX pool. Step 1: the oracle network posts a new round with Asset B at 7.50 dollars. Step 2: the liquidator’s bot computes that account 4 has a health factor of 0.886, below 1. Step 3: the liquidator borrows 22,000 stablecoins from a flash lender. Step 4: it calls liquidate on the market for account 4, collateral B, 22,000. Step 5: the market accrues interest, reads checked prices, applies a 100 percent close factor because the health factor is at or below 0.95, and computes the collateral to seize as 22,000 times 1.10 divided by 7.50, which is 3,226.67 B. Step 6: the market pulls 22,000 stablecoins from the liquidator and transfers 3,226.67 B to it. Step 7: the liquidator sells the B on a DEX pool for 23,473.43 stablecoins. Step 8: it repays the flash loan. It keeps 1,473.43 before gas and the flash-loan fee, compared with 2,200 at the oracle price; the difference is slippage.Oracle networkLending marketLiquidatorFlash lenderDEX pool1B = $7.502off-chain: HF = 0.886 < 13borrow 22,0004liquidate(acct 4, B, 22,000)5accrue; read checked pricesHF ≤ 0.95: close factor 100%seize = 22,000 × 1.10 / 7.50= 3,226.67 B6pays 22,000 stablecoinsreceives 3,226.67 B7sell 3,226.67 B for 23,473.438repay 22,000 + feeKept: 23,473.43 − 22,000 = 1,473.43 before gas and the flash-loan fee (2,200 at the oracle price; the difference is slippage).
One liquidation of Account 4 in the model market.
  1. The oracle posts B at $7.50 after the move crosses the deviation threshold.
  2. A liquidator’s bot, tracking positions off-chain, finds Account 4 at 0.886.
  3. The liquidator flash-borrows 22,000 stablecoins (own capital works too).
  4. It calls liquidate(account4, B, 22_000e18).
  5. 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.
  6. It pulls 22,000 stablecoins, clears the debt and sends the B; Account 4 keeps 773.33 B.
  7. The liquidator sells the B for 23,473.43 in the pool described under cascades.
  8. It repays the loan and keeps 1,473.43 before gas and fees: the 2,200 bonus less slippage.
ModelMarket.liquidate (illustrative)solidity
// 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.

AccountStart ($10.00)−10% ($9.00)−25% ($7.50)−40% ($6.00)
31.3001.1700.9750.780
41.1821.0640.8860.709
51.3311.2501.1281.006
Health factors after each fall in B, to three decimals.
ScenarioClose factorRepaidSeizedLiquidator profitBad debtLeft on the account
−25%, account 350%7,5001,100 B ($8,250)75001,900 B, debt 7,500, HF 1.235
−25%, account 4100%22,0003,226.67 B ($24,200)2,2000773.33 B, no debt
−40%, account 3100%15,0002,750 B ($16,500)1,5000250 B, no debt
−40%, account 4100%, capped21,818.184,000 B ($24,000)2,181.82181.82Nothing
Liquidations in stablecoins; profit at the oracle price, before slippage and gas.

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 gapAsset AAsset B
Just liquidatable (HF = 1)12.85%28.50%
At maximum LTV16.00%39.50%
HF = 1.541.90%52.33%
Single-update fall beyond which full liquidation leaves bad debt

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.

Vulnerable liquidate and the fix (illustrative)diff
 // 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.

How far each max-LTV position can fall before it is liquidatable, fully liquidatable, or bad debtTwo horizontal bars show the fall in the collateral’s oracle price, from 0 to 45 percent, in the model market. Account 2 holds 10 units of Asset A against 16,000 of debt, at its maximum loan-to-value and a health factor of 1.0375. It becomes liquidatable with a 50 percent close factor after a 3.6 percent fall, fully liquidatable after 8.4 percent, and a full liquidation leaves bad debt after 16.0 percent; the gap scenario of 18 percent lies in the bad-debt zone. Account 4 holds 4,000 units of Asset B against 22,000 of debt, at its maximum loan-to-value and a health factor of 1.182. It becomes liquidatable after a 15.4 percent fall, fully liquidatable after 19.6 percent, and leaves bad debt after 39.5 percent. The stress scenarios of 10, 25 and 40 percent fall in the healthy, 100 percent close factor and bad-debt zones respectively.Account 2: 10 A, debt 16,000at max LTV, HF 1.0375−3.6%−8.4%−16.0%gap −18%Account 4: 4,000 B, debt 22,000at max LTV, HF 1.182−15.4%−19.6%−39.5%−10%−25%−40%0%−10%−20%−30%−40%Fall in oracle priceHealthy50% close factor100% close factorBad debt if fully liquidated
Price falls at which the two maximum-LTV accounts cross each boundary; scenarios marked.

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

MechanicModel marketAave v3Compound III
BorrowableOne stablecoinSeveral reservesOne base asset
TriggerHF < 1HF < 1Negative liquidity at liquidateCollateralFactor (above borrowCollateralFactor)
Collateral goes toLiquidatorLiquidator (liquidationCall)Protocol (absorb), sold via buyCollateral
Per call50%; 100% at HF ≤ 0.9550%; 100% at HF ≤ 0.95 or under 2,000 base units; no leftover under 1,000 (v3.3)Whole account
IncentiveBonus per assetBonus per reserve, less any protocol feeliquidationFactor penalty (0.93 is 7%); buyer discount
Bad debtWritten off in the callReserve deficit (v3.3)Paid from reserves
OracleStaleness and sign checkedSequencer gating removed (v3.7)getPrice rejects only non-positive answers
Model, Aave v3 and Compound III compared

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

RiskTriggerImpactPrimary control
Gap beyond toleranceOne update falls more than 1 − LT(1 + b)/hHighThresholds from gap tolerance; caps
Oracle lag or stalenessWide deviation threshold; missed heartbeatHighThresholds inside the bands; maximum age
Sequencer outageRollup stops ordering transactionsHighGrace-period policy; lower caps
Thin-collateral manipulationReported price moved cheaplyHighCaps from manipulation cost; price bound
Unprofitable liquidationSlippage and gas above the bonusMediumBonus sizing; minimum borrow
CascadeLiquidation sales move the oracle’s marketHighSupply caps against depth; low LT
Unrecognised bad debtCollateral exhausted, debt leftHighSame-call write-off
Risk matrix for the model market. Impact is a qualitative judgement.
  • 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.

LiquidationInvariants.t.sol (illustrative)solidity
// 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

  1. Derive each collateral’s LT and bonus from the single-update move it must survive, keeping LT × (1 + b) well below 1.
  2. Size supply caps and debt ceilings to what liquidators can sell at break-even.
  3. Write off debt left without collateral in the same transaction.

Risk and operations

  1. Recompute gap tolerance, break-even size and the gas floor when liquidity or parameters change.
  2. Document liquidation behaviour on stale prices, during pauses and after sequencer restarts.

Reviewers

  1. Recompute the stress tables with the protocol’s parameters and largest positions, including a gap.
  2. Fuzz with handlers that move prices in single large steps.

Bibliography

Primary sources: standards, official documentation, incident reports and papers.

  1. 1Pool (Aave V3 smart contracts)(opens in a new tab)Aave documentation
  2. 2Aave v3.3 features: bad debt management and liquidation changes(opens in a new tab)aave-dao/aave-v3-origin on GitHub
  3. 3PriceOracleSentinel removal (Aave v3.7)(opens in a new tab)aave-dao/aave-v3-origin on GitHub
  4. 4PoolConfigurator.sol (collateral parameter checks)(opens in a new tab)aave-dao/aave-v3-origin on GitHub
  5. 5Liquidation (Compound III)(opens in a new tab)Compound documentation
  6. 6Helper functions: asset info and getPrice (Compound III)(opens in a new tab)Compound documentation
  7. 7CometWithExtendedAssetList.sol(opens in a new tab)compound-finance/comet on GitHub
  8. 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.

Want this question asked of your code?

Quantix reviewers apply the same reasoning to production systems. Share what you are building and when it ships.