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

`positionIsClosed` not being set to `true` leads to new position being opened on GMX without a signal from offchain, and users not receiving execution fee refund

Description:

The root cause of this issue can have two similar impacts, which I will explain with 2 different scenarios:

Scenario 1:

A position is open on GMX and a user decices to withdraw, but due to market conditions, the position collateral will not be enough to withdraw the user's position, so the order's sizeDeltaInUsd gets set to the whole position size:

if (
sizeDeltaInUsd == 0 // if size delta is 0
|| vaultReader.willPositionCollateralBeInsufficient( // or if insufficient collateral
prices, curPositionKey, market, _isLong, sizeDeltaInUsd, collateralDeltaAmount)
) {
@> sizeDeltaInUsd = sizeInUsd; // sets size delta to full position size
}

In the code snippet above we can see that if willPositionCollateralBeInsufficient function returns true, sizeDeltaInUsd gets set to the whole position size.

When GMX executes the MarketDecrease order request, PerpetualVault::afterOrderExecution will get called and this part of the function will trigger (the fees have been settled in previous order):

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

In the snippet above we can see that only curPositionKey gets deleted, but positionIsClosed is not set to true, even though the position is closed on GMX.

Due to this, when a new user calls PerpetualVault::deposit, a MarketIncrease order request will be sent to GMX to increase the position, but the position is closed at this time, so GMX will open a new position, and the user, along with the next depositors, will pay the execution fee, when in reality, the position should be closed at this time and when there is no open position, depositors should not pay an execution fee.

Scenario 2, which results in users not receiving execution fee refund when they should:

Users who call withdraw function always pay execution fee when positionIsClosed is false, but they do not get refunded in the scenario where positionIsClosed is false and curPositionKey is 0 at the same time, which can happen when GMX executes a MarketDecrease order request and PerpetualVault::afterOrderExecution gets called, if the order completely closes the GMX position. This scenario happens when the flow is WITHDRAW. Here is the relevant code snippet:

} else if (orderResultData.orderType == Order.OrderType.MarketDecrease) {
uint256 sizeInUsd = vaultReader.getPositionSizeInUsd(curPositionKey);
@> if (sizeInUsd == 0) {
delete curPositionKey;
}

In the code snippet above, only curPositionKey is deleted, but positionIsClosed is not set to true, so if, after this withdrawal gets finalized, another user calls withdraw, he will pay an execution fee, but will not get refunded, when in reality, the GMX position is closed and the keeper did not open a new one yet.

Reminding you that this is how it's posssible for a single user to close the entire GMX position:

if (
sizeDeltaInUsd == 0 // if size delta is 0
|| vaultReader.willPositionCollateralBeInsufficient( // or if insufficient collateral
prices, curPositionKey, market, _isLong, sizeDeltaInUsd, collateralDeltaAmount)
) {
@> sizeDeltaInUsd = sizeInUsd; // sets size delta to full position size
}

As you can see, if VaultReader::willPositionCollateralBeInsufficient returns true, the whole GMX position will be closed in the following order execution. This function can return true for multiple reasons:

  • major negative PnL

  • heavily changed market prices at withdrawal time (decreased collateral value)

  • open interest constraints (increased collateral requirements)

In this scenario, there could be other users that possess shares, and if they call withdraw after the current MarketDecrease order executes, they will pay the execution fee and will not get refunded, even though the GMX position is closed and all remaining funds are in the vault.

Here is a code snippet from withdraw function, which shows that users will pay an execution fee (positionIsClosed is still false at this point) and then _withdraw gets called:

@> _payExecutionFee(depositId, false);
if (curPositionKey != bytes32(0)) {
nextAction.selector = NextActionSelector.WITHDRAW_ACTION;
_settle(); // Settles any outstanding fees and updates state before processing withdrawal
@> } else {
MarketPrices memory prices;
@> _withdraw(depositId, hex'', prices);
}
}

In the below code snippet we see that _handleReturn function gets called with the last parameter set to false. This is a problem, because in this case the position on GMX is closed, which means the funds are in the vault, so the execution fee should be refunded.

function _withdraw(uint256 depositId, bytes memory metadata, MarketPrices memory prices) internal {
uint256 shares = depositInfo[depositId].shares;
if (shares == 0) {
revert Error.ZeroValue();
}
if (positionIsClosed) {
_handleReturn(0, true, false);
} else if (_isLongOneLeverage(beenLong)) { // beenLong && leverage == BASIS_POINTS_DIVISOR
uint256 swapAmount = IERC20(indexToken).balanceOf(address(this)) * shares / totalShares;
nextAction.selector = NextActionSelector.SWAP_ACTION;
// abi.encode(swapAmount, swapDirection): if swap direction is true, swap collateralToken to indexToken
nextAction.data = abi.encode(swapAmount, false);
} else if (curPositionKey == bytes32(0)) {
@> _handleReturn(0, true, false);
}
function _handleReturn(uint256 withdrawn, bool positionClosed, bool refundFee) internal {

In the code snippet above, we can see that the third parameter is refundFee, which gets checked at the end of _handleReturn function:

@> if (refundFee) {
uint256 usedFee = callbackGasLimit * tx.gasprice;
if (depositInfo[depositId].executionFee > usedFee) {
try IGmxProxy(gmxProxy).refundExecutionFee(depositInfo[counter].owner, depositInfo[counter].executionFee - usedFee) {} catch {}
}
}

Impact:

Scenario 1:

All depositors, after the issue occurs, have to pay an execution fee, when in reality they should not pay because position should be closed until keeper opens it. Additionally, first depositor after the issue occurs basically creates a new position on GMX when the keeper did not plan it and, by design, all positions should only be opened by a keeper, based on signals.

Scenario 2:

Users who still have shares after a GMX position gets closed will call withdraw and will not receive execution fee refund.

Likelihood:

The issue can happen when a user wants to withdraw, but position collateral is not enough, and this can occur due to various market conditions, such as:

  • major negative PnL

  • heavily changed market prices at withdrawal time (decreased collateral value)

  • open interest constraints (increased collateral requirements)

Proof of Concept:

Scenario 1:

  1. Add the test provided below to PerpetualVault.t.sol

  2. Run the test with this command: forge test --mt test_PositionIsClosed_NotSetToTrue --via-ir --rpc-url <YOUR_RPC_URL_HERE> -vv

  3. In terminal you should see output like this:

Logs:
total amount after alice's withdrawal: 0
curPositionKey: 0
positionIsClosed: false

  1. Read the comments in the provided test to understand what's happening

function test_PositionIsClosed_NotSetToTrue() external {
address keeper = PerpetualVault(vault2x).keeper();
// Alice deposits
address alice = makeAddr("alice");
depositFixtureInto2x(alice, 10e9);
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory data = new bytes[](1);
data[0] = abi.encode(3390000000000000);
vm.prank(keeper);
PerpetualVault(vault2x).run(true, true, prices, data);
PerpetualVault.FLOW flow = PerpetualVault(vault2x).flow();
assertEq(uint8(flow), 2);
assertEq(PerpetualVault(vault2x).positionIsClosed(), true);
(PerpetualVault.NextActionSelector selector,) = PerpetualVault(vault2x).nextAction();
assertEq(uint8(selector), 0);
GmxOrderExecuted2x(true);
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices, new bytes[](0));
// Setting up for withdrawal
uint256[] memory aliceDepositIds = PerpetualVault(vault2x).getUserDeposits(alice);
uint256 executionFee = PerpetualVault(vault2x).getExecutionGasLimit(false);
uint256 lockTime = 1;
PerpetualVault(vault2x).setLockTime(lockTime);
vm.warp(block.timestamp + lockTime + 1);
bytes[] memory swapData = new bytes[](2);
swapData[0] = abi.encode(0);
// Alice withdraws
deal(alice, 1 ether);
vm.prank(alice);
PerpetualVault(vault2x).withdraw{value: executionFee * tx.gasprice}(alice, aliceDepositIds[0]);
GmxOrderExecuted2x(true); // settle order execution
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices, swapData); // creates market decrease order
GmxOrderExecuted2x(true); // withdraw (market decrease) execution
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices, new bytes[](0)); // finalizing
// Making sure position is closed
console.log("total amount after alice's withdrawal:", PerpetualVault(vault2x).totalAmount(prices));
console.log("curPositionKey:", uint256(PerpetualVault(vault2x).curPositionKey()));
console.log("positionIsClosed:", PerpetualVault(vault2x).positionIsClosed());
assertEq(PerpetualVault(vault2x).totalAmount(prices), 0); // totalAmount is 0
assertEq(uint256(PerpetualVault(vault2x).curPositionKey()), 0); // curPositionKey is deleted
assertEq(PerpetualVault(vault2x).positionIsClosed(), false); // positionIsClosed not updated !!!
// Bob deposits
address bob = makeAddr("bob");
deal(bob, 1 ether);
uint256 bobsEthBalanceBeforeDeposit = address(bob).balance;
depositFixtureInto2x(bob, 10e9);
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices, data);
GmxOrderExecuted2x(true); // order execution
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices, new bytes[](0)); // finalizing
uint256 bobsEthBalanceAfterDeposit = address(bob).balance;
assertTrue(bobsEthBalanceAfterDeposit < bobsEthBalanceBeforeDeposit); // Bob pays execution fee, even though he deposited when there was no open position on GMX !!!
}

Scenario 2:

  1. Add the test provided below to PerpetualVault.t.sol

  2. Run the test with this command: forge test --mt test_ExecutionFee_NotRefunded --via-ir --rpc-url <YOUR_RPC_URL_HERE> -vv

  3. In terminal you should see output like this:

Logs:
curPositionKey: 0
positionIsClosed: false

  1. Read the comments in the provided test to understand what's happening

function test_ExecutionFee_NotRefunded() external {
address keeper = PerpetualVault(vault2x).keeper();
// Alice deposits
address alice = makeAddr("alice");
depositFixtureInto2x(alice, 10e9);
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory data = new bytes[](1);
data[0] = abi.encode(3390000000000000);
vm.prank(keeper);
PerpetualVault(vault2x).run(true, true, prices, data);
assertEq(PerpetualVault(vault2x).positionIsClosed(), true);
GmxOrderExecuted2x(true);
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices, new bytes[](0));
// Bob deposits
address bob = makeAddr("bob");
deal(bob, 1 ether);
depositFixtureInto2x(bob, 10e9);
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices, data);
GmxOrderExecuted2x(true); // order execution
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices, new bytes[](0)); // finalizing
// Setting up for withdrawal
uint256[] memory aliceDepositIds = PerpetualVault(vault2x).getUserDeposits(alice);
uint256 executionFee = PerpetualVault(vault2x).getExecutionGasLimit(false);
uint256 lockTime = 1;
PerpetualVault(vault2x).setLockTime(lockTime);
vm.warp(block.timestamp + lockTime + 1);
bytes[] memory swapData = new bytes[](2);
swapData[0] = abi.encode(0);
bytes4 selector = bytes4(
keccak256(
"willPositionCollateralBeInsufficient(((uint256,uint256),(uint256,uint256),(uint256,uint256)),bytes32,address,bool,uint256,uint256)"
)
);
vm.mockCall(address(reader), selector, abi.encode(true)); // mocking scenario where `willPositionCollateralBeInsufficient` returns `true`
// Alice withdraws and GMX position gets closed
deal(alice, 1 ether);
vm.prank(alice);
PerpetualVault(vault2x).withdraw{value: executionFee * tx.gasprice}(alice, aliceDepositIds[0]);
GmxOrderExecuted2x(true); // settle order execution
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices, swapData); // creates market decrease order
GmxOrderExecuted2x(true); // withdraw (market decrease) execution
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices, new bytes[](0)); // finalizing
// Making sure GMX position is closed
console.log("curPositionKey:", uint256(PerpetualVault(vault2x).curPositionKey()));
console.log("positionIsClosed:", PerpetualVault(vault2x).positionIsClosed());
assertEq(uint256(PerpetualVault(vault2x).curPositionKey()), 0); // curPositionKey is deleted
assertEq(PerpetualVault(vault2x).positionIsClosed(), false); // positionIsClosed not updated !!!
uint256[] memory bobDepositIds = PerpetualVault(vault2x).getUserDeposits(bob);
(, uint256 bobShares,,,,) = PerpetualVault(vault2x).depositInfo(bobDepositIds[0]);
assertGt(bobShares, 0); // Bob has shares
IERC20 collateralToken = PerpetualVault(vault2x).collateralToken();
assertGt(collateralToken.balanceOf(vault2x), 0); // vault has USDC
// Bob withdraws
uint256 bobsEthBalanceBeforeWithdraw = address(bob).balance;
vm.prank(bob);
PerpetualVault(vault2x).withdraw{value: executionFee * tx.gasprice}(bob, bobDepositIds[0]);
uint256 bobsEthBalanceAfterWithdraw = address(bob).balance;
assertTrue(bobsEthBalanceAfterWithdraw < bobsEthBalanceBeforeWithdraw); // Bob pays and does not get refunded the execution fee, even though he withdrew when there was no open position on GMX !!!
assertEq(PerpetualVault(vault2x).positionIsClosed(), false); // positionIsClosed still `false`
}

Recommended Mitigation:

Set positionIsClosed to true if after a MarketDecrease order execution, current position size is 0.

} else if (orderResultData.orderType == Order.OrderType.MarketDecrease) {
uint256 sizeInUsd = vaultReader.getPositionSizeInUsd(curPositionKey);
if (sizeInUsd == 0) {
delete curPositionKey;
+ positionIsClosed = true;
}
if (flow == FLOW.WITHDRAW) {
nextAction.selector = NextActionSelector.FINALIZE;
uint256 prevCollateralBalance = collateralToken.balanceOf(address(this)) - orderResultData.outputAmount;
nextAction.data = abi.encode(prevCollateralBalance, sizeInUsd == 0, false);

Additionally, consider setting the last parameter to true when calling _handleReturn in this situation:

function _withdraw(uint256 depositId, bytes memory metadata, MarketPrices memory prices) internal {
uint256 shares = depositInfo[depositId].shares;
if (shares == 0) {
revert Error.ZeroValue();
}
if (positionIsClosed) {
_handleReturn(0, true, false);
} else if (_isLongOneLeverage(beenLong)) { // beenLong && leverage == BASIS_POINTS_DIVISOR
uint256 swapAmount = IERC20(indexToken).balanceOf(address(this)) * shares / totalShares;
nextAction.selector = NextActionSelector.SWAP_ACTION;
// abi.encode(swapAmount, swapDirection): if swap direction is true, swap collateralToken to indexToken
nextAction.data = abi.encode(swapAmount, false);
} else if (curPositionKey == bytes32(0)) {
- _handleReturn(0, true, false);
+ _handleReturn(0, true, true);
}
Updates

Lead Judging Commences

n0kto Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

invalid_liquidation_do_not_reset_positionIsClosed

"// keep the positionIsClosed value so that let the keeper be able to create an order again with the liquidated fund" Liquidation can send some remaining tokens. No real impact here.

Appeal created

0xl33 Submitter
8 months ago
n0kto Lead Judge
8 months ago
n0kto Lead Judge 8 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_positionIsClosed_false_when_sizeInUsd_is_empty_lead_executionFee_overpayment_for_withdrawing

Likelihood: Low, if the user withdraws just after all the positions are retrieved to the vault and before any new `run` of the keeper. Impact: Low/Medium, no refund of the execution fee, although there was no need for it.

Support

FAQs

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