
Cantina · #87 · May 2025

// Incorrect Debt-Collateral Matching in Liquidation Leads to Bad Debt and Liquidator Loss
When a position's debt exceeds the value of its remaining collateral, liquidate() correctly caps collateralUsed to the user's actual balance. However, it does not proportionally reduce the jUSD repayment amount to match. The full debt amount is burned regardless of how much collateral was actually available, creating a guaranteed loss for the liquidator equal to (debtValue − collateralValue).
function liquidate(..., uint256 _jUsdAmount, ...) external {
// Collateral calculated from full debt
uint256 collateralUsed = (_jUsdAmount / currentPrice) * PRICE_PRECISION;
// @audit Capped to user's actual balance — but _jUsdAmount is NOT reduced
if (collateralUsed > holdingCollateral) {
collateralUsed = holdingCollateral;
// _jUsdAmount remains unchanged — full debt is still burned
}
// Liquidator burns full _jUsdAmount
jUsd.burnFrom(msg.sender, _jUsdAmount);
// But only receives capped collateralUsed
_transferCollateral(msg.sender, collateralUsed);
// Result: liquidator loss = _jUsdAmount - (collateralUsed * currentPrice)
}
// PoC output (10 WETH @ $2000 → $1100):
// Alice debt: 16,000 jUSD
// Alice collateral: 10 WETH ($11,000 at $1,100)
// Bob burns: 16,000 jUSD ← full debt
// Bob receives: 10 WETH ($11,000)
// Bob net loss: $5,000
// Protocol bad debt: $5,000▸ INCORRECT ASSUMPTIONS
▸ AFFECTED CONTRACTS
Alice deposits 10 WETH ($20,000 at $2,000/WETH) and borrows 16,000 jUSD (80% LTV). Position is healthy.
↓WETH price drops to $1,100. Alice's collateral is now worth $11,000 against $16,000 debt — position is liquidatable.
↓Bob calls liquidate() with the full 16,000 jUSD debt amount. liquidate() calculates ~14.54 WETH needed, but Alice only has 10 WETH. collateralUsed is capped to 10 WETH.
↓Bob's full 16,000 jUSD is burned. Bob receives 10 WETH worth $11,000. Bob suffers a guaranteed $5,000 loss. No rational liquidator will call this function — the position sits undercollateralized indefinitely, accruing as unrecoverable protocol bad debt.
Any liquidation where debt exceeds available collateral guarantees a net loss for the liquidator. Rational actors will not liquidate, leaving undercollateralized positions permanently unresolved.
Each such liquidation generates bad debt equal to (jUSD burned) − (collateral value). At scale and in volatile markets, this silently erodes protocol solvency.
Accumulating bad debt means jUSD in circulation exceeds the collateral backing it, threatening the peg under redemption pressure.
After capping collateralUsed to the holding balance, scale the jUSD repayment down to the maximum value that the available collateral can cover. Never burn more jUSD than the collateral is worth.
// Collateral capped, debt unchanged — creates loss
if (collateralUsed > holdingCollateral) {
collateralUsed = holdingCollateral;
// _jUsdAmount NOT updated → liquidator overpays
}
jUsd.burnFrom(msg.sender, _jUsdAmount);// Scale debt down to match available collateral
if (collateralUsed > holdingCollateral) {
collateralUsed = holdingCollateral;
// Recompute max repayable from actual collateral
uint256 maxRepayable = collateralUsed.mulDiv(
currentPrice, PRICE_PRECISION
);
require(_jUsdAmount <= maxRepayable, "Debt > collateral value");
_jUsdAmount = maxRepayable; // burn only what collateral covers
}
jUsd.burnFrom(msg.sender, _jUsdAmount);▸ SECURITY CONSIDERATIONS
I found this by tracing the liquidation math path end-to-end and asking one question: what happens when the collateral cap fires? The cap path modified collateralUsed but left _jUsdAmount untouched. Once I saw that, I wrote the PoC immediately — it's a one-line invariant violation.
The impact is amplified by the liquidation incentive structure. A $5,000 guaranteed loss means no rational liquidator will touch the position. The bug doesn't just create bad debt in theory — it creates a class of positions that will never be liquidated in practice, because the market correctly prices in the loss.
The Forge PoC was straightforward: deposit 10 WETH, borrow at 80% LTV, drop oracle price 45%, liquidate, assert Bob's net loss > 0. The console logs made the discrepancy unmistakable: '16,000 jUSD burned, 11,000 worth of WETH received.' Any passing test that doesn't check liquidator P&L misses this class of bug entirely.
This is a classic asymmetric-update bug: two variables that must move together (debt burned and collateral received) are updated via two separate code paths, and one path forgets to update the other. The fix is always to derive the secondary value from the primary after any cap or floor is applied, not to update them independently.