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

Incorrect calculation logic in `PerpetualVault::afterOrderExecution` leads to users receiving wrong number of shares

Description:

After a user calls PerpetualVault::deposit function while the vault has an open GMX position, a new MarketIncrease order request will be sent to GMX, then later executed by a GMX keeper. When the GMX keeper calls OrderHandler::executeOrder function, the execution process begins. At the end of the execution process, the GMX order handler performs a callback and calls GmxProxy::afterOrderExecution, which then calls PerpetualVault::afterOrderExecution, and this part of the function gets triggered:

if (orderResultData.orderType == Order.OrderType.MarketIncrease) {
curPositionKey = positionKey;
if (flow == FLOW.DEPOSIT) {
uint256 amount = depositInfo[counter].amount;
uint256 feeAmount = vaultReader.getPositionFeeUsd(market, orderResultData.sizeDeltaUsd, false) / prices.shortTokenPrice.min;
uint256 prevSizeInTokens = flowData;
int256 priceImpact = vaultReader.getPriceImpactInCollateral(curPositionKey, orderResultData.sizeDeltaUsd, prevSizeInTokens, prices);
uint256 increased;
if (priceImpact > 0) {
@> increased = amount - feeAmount - uint256(priceImpact) - 1;
} else {
@> increased = amount - feeAmount + uint256(-priceImpact) - 1;
}
_mint(counter, increased, false, prices);
nextAction.selector = NextActionSelector.FINALIZE;

The issue with the part of the code that I marked, is that increased is being adjusted wrongly in both cases, because when priceImpact is positive, it means the order executed at a better price (beneficial to Gamma's whole position), but in this case, Gamma subtracts the position fee AND price impact, punishing the user.

When priceImpact is negative, it means the order executed at a worse price (detrimental to Gamma's whole position), but in this case Gamma subtracts the position fee, but adds the price impact, meaning the user gets rewarded with more shares as they cause harm to Gamma's position.

This incorrect logic should be reversed, because in the current implementation users get rewarded when their deposited funds get added to the position at a worse price, and they get punished when their deposited funds get added to the position at a better price.

Statement from GMX github regarding entry/exit price being adjusted due to price impact:

For the price impact on position increases / decreases, if negative price impact is deducted as collateral from the position, this could lead to the position having a different leverage from what the user intended, so instead of deducting collateral the position's entry / exit price is adjusted based on the price impact

Source: https://github.com/gmx-io/gmx-synthetics/blob/main/README.md#price-impact

Impact:

Incorrect increased value gets passed to the _mint function and users receive a number of shares that does not reflect the current market situation. These minor share inaccuracies will accumulate over time, leading to larger consequences for the protocol.

Likelihood:

This issue will always happen when a user deposits into the vault when there is an open GMX position.

Proof of Concept:

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

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

  3. In terminal you should see output like this:

Logs:
total amount before alice's position: 10000000000
total amount after alice's position: 9979999682
total amount after bob's position: 19959999363
total amount after charlie's position: 29939999044
Alice shares: 1000000000000000000
Bob shares: 1004507125020617174
Charlie shares: 1005628666727134279
charlie's USDC balance after withdraw: 10002260843
total amount after charlie's withdrawal: 19928571215
bob's USDC balance after withdraw: 9986690498
total amount after bob's withdrawal: 9932755498
alice's USDC balance after withdraw: 9923584145
total amount after alice's withdrawal: 0

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

  2. Switch price impact signs in PerpetualVault::AfterOrderExecution function and run the test again to get the correct results

function test_PriceImpact_IncorrectSigns() external {
address keeper = PerpetualVault(vault2x).keeper();
address alice = makeAddr("alice");
depositFixtureInto2x(alice, 10e9); // 10 000 USDC
MarketPrices memory prices = mockData.getMarketPrices();
console.log("total amount before alice's position:", PerpetualVault(vault2x).totalAmount(prices)); // 10000000000 (10k)
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)); // finalizing
console.log("total amount after alice's position:", PerpetualVault(vault2x).totalAmount(prices)); // 9979999682 (9979 e6) - in total lost 20 USDC to price impact
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
console.log("total amount after bob's position:", PerpetualVault(vault2x).totalAmount(prices)); // 19959999363 (19 959 e6) - in total lost 40 USDC to price impact
address charlie = makeAddr("charlie");
deal(charlie, 1 ether);
depositFixtureInto2x(charlie, 10e9);
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices, data);
GmxOrderExecuted2x(true); // order execution
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices, new bytes[](0)); // finalizing
console.log("total amount after charlie's position:", PerpetualVault(vault2x).totalAmount(prices)); // 29939999044 (29 939 e6) - in total lost 60 USDC to price impact
uint256[] memory aliceDepositIds = PerpetualVault(vault2x).getUserDeposits(alice);
(, uint256 aliceShares,,,,) = PerpetualVault(vault2x).depositInfo(aliceDepositIds[0]);
console.log("Alice shares:", aliceShares);
// 1000000000000000000 (10 000 e14)
uint256[] memory bobDepositIds = PerpetualVault(vault2x).getUserDeposits(bob);
(, uint256 bobShares,,,,) = PerpetualVault(vault2x).depositInfo(bobDepositIds[0]);
console.log("Bob shares:", bobShares);
// expected (correct) shares: 998710285799566844 (9987 e14), this is after switching price impact signs in `PerpetualVault::AfterOrderExecution`
// actual shares: 1004507125020617174 (10 045 e14) - BOB RECEIVES MORE SHARES THAN ALICE, EVEN THOUGH THEY BOTH INCURRED THE SAME PRICE IMPACT
uint256[] memory charlieDepositIds = PerpetualVault(vault2x).getUserDeposits(charlie);
(, uint256 charlieShares,,,,) = PerpetualVault(vault2x).depositInfo(charlieDepositIds[0]);
console.log("Charlie shares:", charlieShares);
// expected (correct) shares: 998397161280661937 (9983 e14), this is after switching price impact signs in `PerpetualVault::AfterOrderExecution`
// actual shares: 1005628666727134279 (10 056 e14) - CHARLIE RECEIVES MORE SHARES THAN ALICE, EVEN THOUGH THEY BOTH INCURRED THE SAME PRICE IMPACT
uint256 executionFee = PerpetualVault(vault2x).getExecutionGasLimit(false);
uint256 lockTime = 1;
PerpetualVault(vault2x).setLockTime(lockTime);
vm.warp(block.timestamp + lockTime + 1);
vm.prank(charlie);
PerpetualVault(vault2x).withdraw{value: executionFee * tx.gasprice}(charlie, charlieDepositIds[0]);
GmxOrderExecuted2x(true); // settle order execution
bytes[] memory swapData = new bytes[](2);
swapData[0] = abi.encode(0);
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
IERC20 collateralToken = PerpetualVault(vault2x).collateralToken();
console.log("charlie's USDC balance after withdraw: ", collateralToken.balanceOf(charlie));
// expected (correct) balance: 9973619832 (9973 e6), this is after switching price impact signs in `PerpetualVault::AfterOrderExecution`
// actual balance: 10002260843 (10 002 e6), 2 USDC more than started with, even though incurred price impact of 20 (deposit) + 12 (withdraw) = ~32 USDC
console.log("total amount after charlie's withdrawal:", PerpetualVault(vault2x).totalAmount(prices));
// expected (correct) amount: 19957357358 (19 957 e6), this is after switching price impact signs in `PerpetualVault::AfterOrderExecution`
// actual amount: 19928571215 (19 928 e6) - in total lost 72 USDC to price impact (from which 32 USDC was lost by charlie)
vm.prank(bob);
PerpetualVault(vault2x).withdraw{value: executionFee * tx.gasprice}(bob, bobDepositIds[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
console.log("bob's USDC balance after withdraw: ", collateralToken.balanceOf(bob));
// expected (correct) balance: 9972239944 (9972 e6), this is after switching price impact signs in `PerpetualVault::AfterOrderExecution`
// actual balance: 9986690498 (9986 e6), only 14 USDC less than started with, even though incurred price impact of 20 (deposit) - 4 (withdraw) = ~16 USDC
console.log("total amount after bob's withdrawal:", PerpetualVault(vault2x).totalAmount(prices));
// expected (correct) amount: 9976005732 (9976 e6), this is after switching price impact signs in `PerpetualVault::AfterOrderExecution`
// actual amount: 9932755498 (9932 e6) - in total lost 68 USDC to price impact
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
console.log("alice's USDC balance after withdraw: ", collateralToken.balanceOf(alice));
// expected (correct) balance: 9966794701 (9966 e6), this is after switching price impact signs in `PerpetualVault::AfterOrderExecution`
// actual balance: 9923584145 (9923 e6), 77 USDC less than started with, even though incurred price impact of 20 (deposit) + 9 (withdraw) = ~29 USDC
console.log("total amount after alice's withdrawal:", PerpetualVault(vault2x).totalAmount(prices)); // 0
/// @notice Alice ended up with 77 USDC less than started with, even though she incurred price impact (+ fees) of only 29 USDC, which means Bob and Charlie received more USDC than they should have, due to incorrect price impact signs when adjusting `increased`
/// @notice After switching price impact signs in `PerpetualVault::AfterOrderExecution`, all 3 users receive very similar amounts of USDC, as they should: Alice - 9966e6, Bob - 9972e6, Charlie - 9973e6
}

Recommended Mitigation:

Simply switch the signs in the lines where increased gets calculated:

if (orderResultData.orderType == Order.OrderType.MarketIncrease) {
curPositionKey = positionKey;
if (flow == FLOW.DEPOSIT) {
uint256 amount = depositInfo[counter].amount;
uint256 feeAmount = vaultReader.getPositionFeeUsd(market, orderResultData.sizeDeltaUsd, false) / prices.shortTokenPrice.min;
uint256 prevSizeInTokens = flowData;
int256 priceImpact = vaultReader.getPriceImpactInCollateral(curPositionKey, orderResultData.sizeDeltaUsd, prevSizeInTokens, prices);
uint256 increased;
if (priceImpact > 0) {
- increased = amount - feeAmount - uint256(priceImpact) - 1;
+ increased = amount - feeAmount + uint256(priceImpact) - 1;
} else {
- increased = amount - feeAmount + uint256(-priceImpact) - 1;
+ increased = amount - feeAmount - uint256(-priceImpact) - 1;
}
_mint(counter, increased, false, prices);
nextAction.selector = NextActionSelector.FINALIZE;
Updates

Lead Judging Commences

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

Appeal created

0xl33 Submitter
9 months ago
n0kto Lead Judge
8 months ago
n0kto Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!