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

Incorrect price impact calculation

Summary

Incorrect price impact calculation of the VaultReader::getPriceImpactInCollateral function

Vulnerability Details

If the trade improves the long/short balance or tokens in the GMX pool then there would be a positive price impact, otherwise there would be a negative price impact.

The example opening GMX positions which have two side of price impact:

  1. If opening a long position with a positive price impact, the position's entry price would be lower. A negative price impact would result in a entry price would be lower, the user will get more shares for the same amount of collateral.

  2. If opening a long position with a negative price impact, the position's entry price would be higher, the user will get less shares for the same amount of collateral.

The incorrect is presented in VaultReader::getPriceImpactInCollateral function, which calculate by expectedSizeInTokensDelta.toInt256() - realSizeInTokensDelta.toInt256() instead of realSizeInTokensDelta.toInt256() - expectedSizeInTokensDelta.toInt256(). This make the the flip side of the price impact. However, the PerpetualVault::afterOrderExecution function is calculate in the flip side, which result the increased is correct unintentionally.

function getPriceImpactInCollateral(
bytes32 positionKey,
uint256 sizeDeltaInUsd,
uint256 prevSizeInTokens,
MarketPrices memory prices
) external view returns (int256) {
uint256 expectedSizeInTokensDelta = sizeDeltaInUsd / prices.indexTokenPrice.min;
uint256 curSizeInTokens = getPositionSizeInTokens(positionKey);
uint256 realSizeInTokensDelta = curSizeInTokens - prevSizeInTokens;
@> int256 priceImpactInTokens = expectedSizeInTokensDelta.toInt256() - realSizeInTokensDelta.toInt256();
int256 priceImpactInCollateralTokens = priceImpactInTokens * prices.indexTokenPrice.min.toInt256() / prices.shortTokenPrice.min.toInt256();
return priceImpactInCollateralTokens;
}
function afterOrderExecution(
bytes32 requestKey,
bytes32 positionKey,
IGmxProxy.OrderResultData memory orderResultData,
MarketPrices memory prices
) external nonReentrant {
if (msg.sender != address(gmxProxy)) {
revert Error.InvalidCall();
}
// MarketPrices memory marketPrices = gmxProxy.getMarketPrices(market);
_gmxLock = false;
// If the current action is `settle`
if (orderResultData.isSettle) {
nextAction.selector = NextActionSelector.WITHDRAW_ACTION;
emit GmxPositionCallbackCalled(requestKey, true);
return;
}
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;
} else {
_updateState(false, orderResultData.isLong);
}
...

Impact

The VaultReader::getPriceImpactInCollateral is external function, this may provide the incorrect price impact to frontend and other contracts.

Moreover, although the issue is not directly affecting the security of the protocol, it may lead to confusion and affect the maintainability of the protocol.

PoC

  1. Insert import "@openzeppelin/contracts/utils/math/SafeCast.sol"; to the test/PerpetualVault.t.sol file

  2. Use the SafeCast library in the PerpetualVaultTest contract of test/PerpetualVault.t.sol file

contract PerpetualVaultTest is Test, ArbitrumTest {
+ using SafeCast for uint256;
....
  1. Copy the following test case to the test/PerpetualVault.t.sol file

  2. Run tests with forge test --mt test_Incorrect_PriceImpact_Calculation --rpc-url arbitrum

function test_Incorrect_PriceImpact_Calculation() external {
address keeper = PerpetualVault(vault2x).keeper();
address alice = makeAddr("alice");
depositFixtureInto2x(alice, 1e10);
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);
GmxOrderExecuted2x(true);
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices, data);
deal(alice, 1 ether);
depositFixtureInto2x(alice, 1e10);
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices, data);
bytes32 curPositionKey = PerpetualVault(vault2x).curPositionKey();
uint256 sizeDeltaInUsd = 20000593429804817500000000000000000;
uint256 prevSizeInTokens = 5910828393414802529;
MarketPrices memory marketPrices;
marketPrices.indexTokenPrice = PriceProps({
min: 3386174003773116,
max: 3386174003773116
});
marketPrices.longTokenPrice = PriceProps({
min: 3386174003773116,
max: 3386174003773116
});
marketPrices.shortTokenPrice = PriceProps({
min: 1000019671490240875000000,
max: 1000019671490240875000000
});
address gmxProxy = address(PerpetualVault(vault2x).gmxProxy());
(bytes32 requestKey, ) = GmxProxy(payable(gmxProxy)).queue();
MockData.OracleSetPriceParams memory params = mockData.getOracleParams();
address gmxKeeper = address(0x6A2B3A13be0c723674BCfd722d4e133b3f356e05);
address orderHandler = address(0xe68CAAACdf6439628DFD2fe624847602991A31eB);
// the afterOrderExecution funtion invoke getPriceImpactInCollateral function with the following inputs
vm.expectCall(address(PerpetualVault(vault2x).vaultReader()),
abi.encodeWithSelector(VaultReader.getPriceImpactInCollateral.selector, curPositionKey, sizeDeltaInUsd, prevSizeInTokens, marketPrices));
vm.prank(gmxKeeper);
IOrderHandler(orderHandler).executeOrder(requestKey, params);
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices, data);
// Please note that, the above test statement just ensure the given inputs are actually used in the VaultReader::getPriceImpactInCollateral function, not imagined.
// The following test statement below shown the incorrect of current logic and what the correct logic should be.
/////// actual result from the current logic of getPriceImpactInCollateral function //////
int256 actualPriceImpact = reader.getPriceImpactInCollateral(curPositionKey, sizeDeltaInUsd, prevSizeInTokens, marketPrices);
/////// expected result of the correct price impact //////
uint256 expectedSizeInTokensDelta = sizeDeltaInUsd / marketPrices.indexTokenPrice.min;
uint256 curSizeInTokens = reader.getPositionSizeInTokens(curPositionKey);
uint256 realSizeInTokensDelta = curSizeInTokens - prevSizeInTokens;
// should use realSize - expectedSize which impile the realSize is greater than expectedSize or not
// if realSize >= expectedSize, priceImpact is positive, otherwise, priceImpact is negative
int256 priceImpactInTokens = realSizeInTokensDelta.toInt256() - expectedSizeInTokensDelta.toInt256();
int256 expectedPriceImpact = priceImpactInTokens * marketPrices.indexTokenPrice.min.toInt256() / marketPrices.shortTokenPrice.min.toInt256();
vm.assertTrue(actualPriceImpact < 0); // the actual price impact from getPriceImpactInCollateral function is negative
vm.assertTrue(expectedPriceImpact >= 0); // while the price impact should be positive!
}

Tools Used

Manual Review

Recommendations

Change the logic of VaultReader::getPriceImpactInCollateral function to calculate the correctprice impact by use realSizeInTokensDelta.toInt256() - expectedSizeInTokensDelta.toInt256() instead, and then adjust the condition of the PerpetualVault::afterOrderExecution to align with the change.

function getPriceImpactInCollateral(
bytes32 positionKey,
uint256 sizeDeltaInUsd,
uint256 prevSizeInTokens,
MarketPrices memory prices
) external view returns (int256) {
uint256 expectedSizeInTokensDelta = sizeDeltaInUsd / prices.indexTokenPrice.min;
uint256 curSizeInTokens = getPositionSizeInTokens(positionKey);
uint256 realSizeInTokensDelta = curSizeInTokens - prevSizeInTokens;
- int256 priceImpactInTokens = expectedSizeInTokensDelta.toInt256() - realSizeInTokensDelta.toInt256();
+ int256 priceImpactInTokens = realSizeInTokensDelta.toInt256() - expectedSizeInTokensDelta.toInt256();
int256 priceImpactInCollateralTokens = priceImpactInTokens * prices.indexTokenPrice.min.toInt256() / prices.shortTokenPrice.min.toInt256();
return priceImpactInCollateralTokens;
}
function afterOrderExecution(
bytes32 requestKey,
bytes32 positionKey,
IGmxProxy.OrderResultData memory orderResultData,
MarketPrices memory prices
) external nonReentrant {
if (msg.sender != address(gmxProxy)) {
revert Error.InvalidCall();
}
// MarketPrices memory marketPrices = gmxProxy.getMarketPrices(market);
_gmxLock = false;
// If the current action is `settle`
if (orderResultData.isSettle) {
nextAction.selector = NextActionSelector.WITHDRAW_ACTION;
emit GmxPositionCallbackCalled(requestKey, true);
return;
}
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);
+ uint256 amountWithImpact;
+ if (priceImpact >= 0) {
+ amountWithImpact = amount - feeAmount + uint256(priceImpact) - 1;
+ } else {
+ amountWithImpact = amount - feeAmount - uint256(-priceImpact) - 1;
+ }
+ _mint(counter, amountWithImpact, false, prices);
nextAction.selector = NextActionSelector.FINALIZE;
} else {
_updateState(false, orderResultData.isLong);
}
...
Updates

Lead Judging Commences

n0kto Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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