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

Users Would Receive Less Amount On Withdrawals Due To Improper Swap Check

Summary

If a user is withdrawing and lets say the position opened on GMX got partially ADL/liquidated it might send the perp vault index tokens , to account for these index tokens the contract perform a swap of index token to collateral tokens and hence user's would get the correct amount of collateral when withdrawing , but we will see how the swap condition is improper which would lead to user's not getting profits out of these index tokens.

Vulnerability Details

Consider the following ->

1.) There is an active perp vault position on GMX with leverage > 1x.

2.) A user has requested a withdraw via withdraw() ->

the flow is assigned as WITHDRAW at L255 , and since if (curPositionKey != bytes32(0)) (cause a position is open on GMX with leverage > 1x) ->

if (curPositionKey != bytes32(0)) {
nextAction.selector = NextActionSelector.WITHDRAW_ACTION;
_settle(); // Settles any outstanding fees and updates state before processing withdrawal
}

And , next action is WITHDRAW_ACTION and _settle() is called.

3.) Inside _settle() a settle order is created (routed through GmxProxy.sol) ->

[https://github.com/CodeHawks-Contests/2025-02-gamma/blob/main/contracts/PerpetualVault.sol#L964]

4.) After a successful settle order , afterOrderExecution() would be invoked by GmxProxy.sol and nextAction would be assigned as WITHDRAW_ACTION ->

if (orderResultData.isSettle) {
nextAction.selector = NextActionSelector.WITHDRAW\_ACTION;
emit GmxPositionCallbackCalled(requestKey, true);
return;
}

4.) Now , the position got deleveraged (assume ADL auto deleveraged the position , sending collateral and index token to perp vault , see L249-L257 of GmxProxy.sol).

Lets say this ADL sent back perp vault 0.9 WBTC i.e. ~90k USD (We can also assume 0.9 ETH but WBTC is also accepted token and this makes the attack more impactful , even with WETH the impact is same , also 0.9 is also just for example to depict the issue).

Due to this afterLiquidationExecution() would be triggered ->

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) {
// restart the withdraw flow even though current step is FINALIZE.
nextAction.selector = NextActionSelector.WITHDRAW_ACTION;
}

This will assign nextAction to WITHDRAW_ACTION (flow is WITHDRAW currently).

5.) Then keeper would invoke runNextAction() and since nextAction is WITHDRAW_ACTION , this branch would be invoked (L371-L381)->

else if (_nextAction.selector == NextActionSelector.WITHDRAW_ACTION) {
// swap indexToken that could be generated from settle action or liquidation/ADL into collateralToken
// use only DexSwap
if (
IERC20(indexToken).balanceOf(address(this)) * prices.indexTokenPrice.min >= ONE_USD
) {
(, bytes memory data) = abi.decode(metadata[1], (PROTOCOL, bytes));
_doDexSwap(data, false);
}
uint256 depositId = flowData;
_withdraw(depositId, metadata[0], prices);

We see that if there is index token the function will try to swap it to collateral token BUT the condition is that ->

if (
IERC20(indexToken).balanceOf(address(this)) * prices.indexTokenPrice.min >= ONE_USD
)

This means , there should be atleast one WBTC to make a swap else don't swap (prices is in (30 - tokenDecimals) therefore condition means there should be an entire index token to make a swap , i.e an entire WBTC and not just 1 wei of WBTC).

6.) Since there is not a complete WBTC (we have 0.9 WBTC) we will skip the swap and move to _withdraw() (L1104-L1118) ->

else {
IVaultReader.PositionData memory positionData = vaultReader.getPositionInfo(curPositionKey, prices);
uint256 collateralDeltaAmount = positionData.collateralAmount * shares / totalShares;//
uint256 sizeDeltaInUsd = positionData.sizeInUsd * shares / totalShares;
// we always charge the position fee of negative price impact case.
uint256 feeAmount = vaultReader.getPositionFeeUsd(market, sizeDeltaInUsd, false) / prices.shortTokenPrice.max;
int256 pnl = vaultReader.getPnl(curPositionKey, prices, sizeDeltaInUsd);
if (pnl < 0) {
collateralDeltaAmount = collateralDeltaAmount - feeAmount - uint256(-pnl) / prices.shortTokenPrice.max;
} else {
collateralDeltaAmount = collateralDeltaAmount - feeAmount;
}
uint256 acceptablePrice = abi.decode(metadata, (uint256));
_createDecreasePosition(collateralDeltaAmount, sizeDeltaInUsd, beenLong, acceptablePrice, prices);
}

We calculate based on user's shares how much collateral to remove and then account for fee and pass it to _createDecreasePosition() which would create a Market Decrease order at L936 , and when executed calls afterOrderExecution() where ->

else 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);
}

We would store the prevCollateralBalance (just to track how much collateral was received after decrease market order) and then nextAction is Assigned as FINALIZED , keeper runs runNextAction() and this branch would trigger (L390-L397) ->

else if (_nextAction.selector == NextActionSelector.FINALIZE) {
if (
IERC20(indexToken).balanceOf(address(this)) * prices.indexTokenPrice.min >= ONE_USD
) {
(, bytes memory data) = abi.decode(metadata[1], (PROTOCOL, bytes));
_doDexSwap(data, false);
}
_finalize(_nextAction.data);

7.) We would again skip the swap condition since there is just 0.9 WBTC and call finalize with the data (remember in the data there is prevCollateralAmount which is just collateral amount in perp vault before the collateral from decrease market order was received) ->

function _finalize(bytes memory data) internal {
if (flow == FLOW.WITHDRAW) {
(uint256 prevCollateralBalance, bool positionClosed, bool refundFee) = abi.decode(data, (uint256, bool, bool));
uint256 withdrawn = collateralToken.balanceOf(address(this)) - prevCollateralBalance;
_handleReturn(withdrawn, positionClosed, refundFee);
}

withdrawn amount is what we received from market decrease and then we call _handleReturn() ->

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);
}

8.) The amount sent to the user is the amount got back from the market decrease and if there is collateral token in the vault he will receive the collateral token amount based on the shares , but as we can see we did not account for the 0.9 WBTC received , this 0.9 WBTC will not be accounted for any further withdrawal (unless additional 0.1 WBTC is received in future) and this amount is , for that span of time , stuck in the perp vault.

The impact would be have been similar for partial deleverages by ADL , in that case say 0.5 WBTC was received by the perp vault even then the withdraw action would not account for the 0.5 WBTC with the reasoning similar to the above , with just one difference that in partial ADL afterLiquidationExecution() would not be invoked by GmxProxy.sol (L256-L257)

Impact

The output amount does not account for the 0.9 WBTC (or even if it was WETH) sent to the perp vault contract , this amount due to partial liquidation or partial ADL should be accounted for since it was sent after partially closing the same position we withdrew from , this is direct loss of funds for the withdrawers. The reason is we require that a minimum of 1 entire index token should be present which is a very strict check and as explained leads to loss of funds for users and those 0.9 WBTC stuck in the vault.

Tools Used

Manual analysis

Recommendations

Make the swap condition less strict or refactor the swap logic.

Updates

Lead Judging Commences

n0kto Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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