Jigsaw Protocol

Cantina · #87 · May 2025

Jigsaw Protocol

MEDIUMACCEPTEDCantina

// Incorrect Debt-Collateral Matching in Liquidation Leads to Bad Debt and Liquidator Loss

01 /

EXECUTIVE SUMMARY

EXECUTIVE_SUMMARY.log
ISSUE OVERVIEWThe liquidate() function calculates the collateral to seize based on the full jUSD debt amount, then caps that collateral to the user's actual balance — but never reduces the jUSD burned to match. Liquidators are forced to burn more jUSD than the seized collateral is worth, generating protocol-level bad debt and destroying any rational incentive to liquidate undercollateralized positions.
AFFECTED COMPONENTLiquidationManager.sol — liquidate() L348
RISK CLASSIFICATIONBad Debt Generation / Liquidator Disincentive
SEVERITYMEDIUM
STATUSACCEPTED
02 /

ROOT CAUSE ANALYSIS

ROOT_CAUSE_ANALYSIS.log

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

LiquidationManager.sol — L348 (collateral capped, debt not scaled)
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

  • Capping collateralUsed to the holding balance is sufficient — the jUSD burn amount does not also need to be scaled
  • Liquidators will remain incentivized even when they burn more jUSD than the seized collateral is worth
  • Protocol solvency is maintained as long as positions are cleared, regardless of the debt/collateral ratio at liquidation time

AFFECTED CONTRACTS

  • LiquidationManager.sol — liquidate() (L348)
03 /

ATTACK SCENARIO

ATTACK_VECTOR — Guaranteed Liquidator Loss on Any Undercollateralized Position
01

Alice deposits 10 WETH ($20,000 at $2,000/WETH) and borrows 16,000 jUSD (80% LTV). Position is healthy.

02

WETH price drops to $1,100. Alice's collateral is now worth $11,000 against $16,000 debt — position is liquidatable.

03

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.

04

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.

04 /

IMPACT ASSESSMENT

HIGH

Liquidator Disincentive

Any liquidation where debt exceeds available collateral guarantees a net loss for the liquidator. Rational actors will not liquidate, leaving undercollateralized positions permanently unresolved.

HIGH

Protocol Bad Debt

Each such liquidation generates bad debt equal to (jUSD burned) − (collateral value). At scale and in volatile markets, this silently erodes protocol solvency.

MEDIUM

Stablecoin Peg Risk

Accumulating bad debt means jUSD in circulation exceeds the collateral backing it, threatening the peg under redemption pressure.

05 /

MITIGATION

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.

— VULNERABLEimplementation
// Collateral capped, debt unchanged — creates loss
if (collateralUsed > holdingCollateral) {
    collateralUsed = holdingCollateral;
    // _jUsdAmount NOT updated → liquidator overpays
}
jUsd.burnFrom(msg.sender, _jUsdAmount);
+ SECUREimplementation
// 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

  • 01.Alternatively, compute maxRepayable before the cap check and use it as the authoritative burn amount — avoids two branches
  • 02.Add a Forge invariant: after any liquidation, (jUSD burned * PRICE_PRECISION) must be <= (collateralSeized * currentPrice)
  • 03.Consider a bad-debt socialization mechanism (e.g. protocol insurance fund) to cover residual shortfalls that exceed collateral value
06 /

RESEARCHER NOTES

RESEARCHER_NOTES.md
@preetsinghmakkar— personal analysis

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.

07 /

RESOURCES

08 /

RELATED FINDINGS