DeFiFoundry
50,000 USDC
View results
Submission Details
Severity: low
Invalid

Incorrect Debt Distribution During Liquidations in PerpetualVault.sol and Related Contracts

Summary

The flaw in PerpetualVault.sol could lead to incorrect redistribution of bad debt during liquidations. If a liquidation event creates bad debt, but the system fails to properly account for and distribute this debt in subsequent liquidation cycles, the remaining debt may not be correctly reallocated.

As a result, instead of ensuring that bad debt is covered efficiently, subsequent liquidations may only partially account for the debt. This leads to smaller amounts of debt being liquidated per event, while cdp (collateralized debt position) owners may receive a disproportionately larger share of collateral. The issue can result in systemic risk where bad debt accumulates over time, potentially affecting the solvency of the protocol.

Vulnerability Details

Function: afterLiquidationExecution()

Location: PerpetualVault.sol

function afterLiquidationExecution() external {
if (msg.sender != address(gmxProxy)) {
revert Error.InvalidCall();
}
depositPaused = true;
uint256 sizeInTokens = vaultReader.getPositionSizeInTokens(curPositionKey);
if (sizeInTokens == 0) {
delete curPositionKey;
}
if (flow == FLOW.NONE) {
flow = FLOW.LIQUIDATION;
nextAction.selector = NextActionSelector.FINALIZE;
} else if (flow == FLOW.DEPOSIT) {
flowData = sizeInTokens;
} else if (flow == FLOW.WITHDRAW) {
nextAction.selector = NextActionSelector.WITHDRAW_ACTION;
}
}

Problem:

  • This function is called when a liquidation occurs.

  • If a position has bad debt, it is not explicitly handled here.

  • Instead, sizeInTokens is checked, but this does not account for uncovered debt remaining after liquidation.

  • Solution needed: Ensure bad debt is accounted for and redistributed effectively.

Function: _handleReturn()

Location: PerpetualVault.sol

function _handleReturn(uint256 withdrawn, bool positionClosed, bool refundFee) internal {
(uint256 depositId) = flowData;
uint256 shares = depositInfo[depositId].shares;
uint256 amount;
if (positionClosed) {
amount = collateralToken.balanceOf(address(this)) * shares / totalShares;
} else {
uint256 balanceBeforeWithdrawal = collateralToken.balanceOf(address(this)) - withdrawn;
amount = withdrawn + balanceBeforeWithdrawal * shares / totalShares;
}
if (amount > 0) {
_transferToken(depositId, amount);
}
emit Burned(depositId, depositInfo[depositId].recipient, depositInfo[depositId].shares, amount);
_burn(depositId);
if (refundFee) {
uint256 usedFee = callbackGasLimit * tx.gasprice;
if (depositInfo[depositId].executionFee > usedFee) {
try IGmxProxy(gmxProxy).refundExecutionFee(depositInfo[counter].owner, depositInfo[counter].executionFee - usedFee) {} catch {}
}
}
delete swapProgressData;
delete flowData;
delete flow;
}

Problem:

  • This function determines how collateral is returned after liquidation or withdrawal.

  • However, it does not adjust for bad debt properly.

  • If the position had insufficient collateral, the remaining debt is not redistributed to future liquidations or covered by the protocol.

Function: afterOrderExecution()

Location: GmxProxy.sol

function afterOrderExecution(
bytes32 requestKey,
bytes32 positionKey,
IGmxProxy.OrderResultData memory orderResultData,
MarketPrices memory prices
) external nonReentrant {
if (msg.sender != address(gmxProxy)) {
revert Error.InvalidCall();
}
_gmxLock = false;
if (orderResultData.isSettle) {
nextAction.selector = NextActionSelector.WITHDRAW_ACTION;
emit GmxPositionCallbackCalled(requestKey, true);
return;
}
if (orderResultData.orderType == Order.OrderType.MarketDecrease) {
uint256 sizeInUsd = vaultReader.getPositionSizeInUsd(curPositionKey);
if (sizeInUsd == 0) {
delete curPositionKey;
}
if (flow == FLOW.WITHDRAW) {
nextAction.selector = NextActionSelector.FINALIZE;
uint256 prevCollateralBalance = collateralToken.balanceOf(address(this)) - orderResultData.outputAmount;
nextAction.data = abi.encode(prevCollateralBalance, sizeInUsd == 0, false);
} else {
_updateState(true, false);
}
}
}

Problem:

  • This function processes order execution results, including liquidations.

  • However, bad debt is not properly addressed.

  • If liquidation results in a shortfall, the debt is not transferred or compensated by the system.

Scenario:

  • User A deposits 10,000 USDC into PerpetualVault.sol.

  • User A opens a long position with 5x leverage.

  • The market price drops significantly, and User A’s position is liquidated.

  • Due to price slippage and fees, only 7,000 USDC of collateral is recovered, but the total debt was 9,000 USDC.

  • Bad debt of 2,000 USDC remains, but is not redistributed.

  • Subsequent liquidations fail to account for this, leading to:

    • Liquidators receiving excess collateral.

    • The protocol accumulating bad debt, risking insolvency.

Impact

Severity: HIGH
This issue poses a serious risk to the protocol’s financial stability and should be fixed immediately. If left unresolved, the protocol could become undercollateralized, leading to potential insolvency and loss of user trust.

Tools Used

Manual Review

Recommendations

Modify _handleReturn() in PerpetualVault.sol

function _handleReturn(uint256 withdrawn, bool positionClosed, bool refundFee) internal {
(uint256 depositId) = flowData;
uint256 shares = depositInfo[depositId].shares;
uint256 amount;
uint256 totalDebt = vaultReader.getPositionSizeInUsd(curPositionKey); // Get total debt
uint256 recoveredCollateral = collateralToken.balanceOf(address(this));
if (positionClosed) {
amount = recoveredCollateral * shares / totalShares;
} else {
uint256 balanceBeforeWithdrawal = recoveredCollateral - withdrawn;
amount = withdrawn + balanceBeforeWithdrawal * shares / totalShares;
}
// Ensure bad debt is accounted for
if (totalDebt > recoveredCollateral) {
uint256 badDebt = totalDebt - recoveredCollateral;
emit BadDebtAccumulated(depositId, badDebt);
_redistributeDebt(badDebt); // New function to handle bad debt redistribution
}
if (amount > 0) {
_transferToken(depositId, amount);
}
emit Burned(depositId, depositInfo[depositId].recipient, depositInfo[depositId].shares, amount);
_burn(depositId);
delete swapProgressData;
delete flowData;
delete flow;
}

Implement _redistributeDebt()

function _redistributeDebt(uint256 badDebt) internal {
// Distribute bad debt among future liquidations or set aside from protocol reserves
uint256 reserveBalance = collateralToken.balanceOf(address(this));
if (reserveBalance >= badDebt) {
collateralToken.safeTransfer(address(protocolReserve), badDebt);
} else {
// Implement debt tracking mechanism
protocolDebt += badDebt;
}
}
Updates

Lead Judging Commences

n0kto Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Suppositions

There is no real proof, concrete root cause, specific impact, or enough details in those submissions. Examples include: "It could happen" without specifying when, "If this impossible case happens," "Unexpected behavior," etc. Make a Proof of Concept (PoC) using external functions and realistic parameters. Do not test only the internal function where you think you found something.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.