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

Under An Edge Case A Withdrawing User Would Receive More Collateral Than He Is Entitled To

Summary

Under an edge case , a withdrawing user would receive more collateral than he is entitled to and due to this other depositors when withdrawing will receive lesser shares leading to a permanent loss of funds due to this.

Vulnerability Details

Consider the following ->

1.) A perp vault position is open on GMX with leverage > 1x.

2.) UserA has requested a withdraw using the withdraw() function , let's assume that there is 100,000 USDC collateral in the position and there are just 2 depositors currently i.e UserA and UserB. Therefore, upon withdrawal UserA must receive 50,000 USDC (not accounting for fee and other things).

And , next action is WITHDRAW_ACTION and _settle() is called ->

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

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 and nextAction would be assigned as WITHDRAW_ACTION ->

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

5.) Now since nextAction is WITHDRAW_ACTION , keeper would invoke runNextAction() where this branch would be invoked ->

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

Therefore , _withdraw() is invoked and in _withdraw() this branch would be invoked ->

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

6.) collateralDeltaAmount is calculated by collateral per user according to the shares of the user , in our case userA had 50% of
the shares therefore it would be 50,000 USDC (not accounting for fee , remember these numbers are just for example and to depict the issue).

Then _createDecreasePosition is called with the collateralDeltaAmount and sizeDeltaInUsd which is calculated similarly , and in
_createDecreasePosition() a market decrease order is created at L936.

7.) When this order runs successfully this branch would be invoked in afterOrderExecution() ->

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

where prevCollateralBalance is the balance before the output amount was received (output amount is 50,000 USDC in our example) ,
and nextAction is FINALIZE now.

8.) Now , let's assume ADL kicks in and closes the half of the position meaning 25,000 USDC is in the position and 25,000 worth is getting closed, this will happen through afterOrderExecution() in GmxProxy.sol and there this branch would be invoked (L248-L258 in GmxProxy.sol)->

else if (msg.sender == address(adlHandler)) {
uint256 sizeInUsd = dataStore.getUint(keccak256(abi.encode(positionKey, SIZE_IN_USD)));
if (eventData.uintItems.items[0].value > 0) {
IERC20(eventData.addressItems.items[0].value).safeTransfer(perpVault, eventData.uintItems.items[0].value);
}
if (eventData.uintItems.items[1].value > 0) {
IERC20(eventData.addressItems.items[1].value).safeTransfer(perpVault, eventData.uintItems.items[1].value);
}
if (sizeInUsd == 0) {
IPerpetualVault(perpVault).afterLiquidationExecution();
}

The above code would send 25,000 USDC to the perp vault (according to our example) and our position in GMX is worth 25,000 USDC now(notice
that afterLiquidationExecution would not be called in partial close).

9.) Now , since nextAction is FINALIZE , keeper calls runNextAction() ->

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

, _finalize() is invoked where ->

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

10.) This is where the issue arises , since in partial close due to ADL perp vault received 25,000 USDC , the withdrawn value i.e.
uint256 withdrawn = collateralToken.balanceOf(address(this)) - prevCollateralBalance; would be 25,000 USDC higher , therefore _handleReturn
is called with withdrawn amount 25,000 USDC more than it should be and hence ->

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;

amount to be transferred would be 75,000 + more_profit_due_to_extra_25k (there can be more collateral in the contract due to funding fees etc to make up for the more_profit_due_to_extra_25k)

11.) Therefore , due to this edge case the userA made an excessive profit and all other depositors will incur huge losses now in withdrawals. The root cause was that in _finalize we calculate the withdrawn amount as balanceOf - prevCollateralBalance which increased due to the scenario described.

Impact

UserA got excessive amounts due to not accounting for the received amount correctly , and further withdraws by other depositors will incur losses now which will be permanent loss of funds.

Tools Used

Manual analysis

Recommendations

Only account for the amount that was received when the position was closed , any further amount received by perp vault during when the nextAction was set as FINALIZE and actual finalization would be sent to the user withdrawing as a whole.

Updates

Lead Judging Commences

n0kto Lead Judge 9 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_ADL_before_a_finalize_withdraw_profit_to_one_user

Likelihood: Low, when ADL with profit happen just before a nextAction.FINALIZE and FLOW.WITHDRAW Impact: High, the withdrawing user receives all the delivery with the tokens.

Support

FAQs

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

Give us feedback!