Summary
Function _mint()
will be called on a deposit flow to mint shares to users according to their deposits.
If the vault has a SHORT position (or LONG Position with leverage higher than 1x) ongoing, totalAmountBefore
will be computed by subtracting variable amount
to _totalAmount(prices)
.
function _mint(uint256 depositId, uint256 amount, bool refundFee, MarketPrices memory prices) internal {
uint256 _shares;
if (totalShares == 0) {
_shares = depositInfo[depositId].amount * 1e8;
} else {
uint256 totalAmountBefore;
if (positionIsClosed == false && _isLongOneLeverage(beenLong)) {
totalAmountBefore = IERC20(indexToken).balanceOf(address(this)) - amount;
} else {
@> totalAmountBefore = _totalAmount(prices) - amount; <@
}
if (totalAmountBefore == 0) totalAmountBefore = 1;
_shares = amount * totalShares / totalAmountBefore;
}
depositInfo[depositId].shares = _shares;
totalShares = totalShares + _shares;
if (refundFee) {
uint256 usedFee = callbackGasLimit * tx.gasprice;
if (depositInfo[counter].executionFee > usedFee) {
try IGmxProxy(gmxProxy).refundExecutionFee(depositInfo[counter].owner, depositInfo[counter].executionFee - usedFee) {} catch {}
}
}
emit Minted(depositId, depositInfo[depositId].owner, _shares, amount);
}
However, _totalAmount(prices)
is handling indexToken
and collateralToken
balances as if they were realized. Since this is a GMX (SHORT or LONG) position, the vault is holding neither indexToken
nor collateralToken
at this point.
function _totalAmount(MarketPrices memory prices) internal view returns (uint256) {
if (positionIsClosed) {
return collateralToken.balanceOf(address(this));
} else {
IVaultReader.PositionData memory positionData = vaultReader.getPositionInfo(curPositionKey, prices);
@> uint256 total = IERC20(indexToken).balanceOf(address(this)) * prices.indexTokenPrice.min / prices.shortTokenPrice.min
@> + collateralToken.balanceOf(address(this))
+ positionData.netValue / prices.shortTokenPrice.min;
return total;
}
}
Vulnerability Details
If the vault has a SHORT position ongoing (or LONG with leverage > 1x), then mint()
will not be called via function deposit()
but via afterOrderExecution()
instead, which is a callback function. This means that an order has already been executed on GMX, to which the deposited collateral balance was sent.
Therefore, when _totalAmount(prices)
is called within mint()
, it will return variable total
which is the sum of:
IERC20(indexToken).balanceOf(address(this)) * prices.indexTokenPrice.min / prices.shortTokenPrice.min
collateralToken.balanceOf(address(this))
positionData.netValue / prices.shortTokenPrice.min;
Since the vault is holding neither indexToken
nor collateralToken
at this point, this will be:
0 * prices.indexTokenPrice.min / prices.shortTokenPrice.min
0
positionData.netValue / prices.shortTokenPrice.min;
This being, _totalAmount(prices)
returns positionData.netValue / prices.shortTokenPrice.min
.
Impact
Since _shares is computed in function _mint()
as:
_shares = amount * totalShares / totalAmountBefore
And totalAmountBefore = _totalAmount(prices) - amount
which is the same as totalAmountBefore = (positionData.netValue / prices.shortTokenPrice.min) - amount
Then totalAmountBefore
will not depend on the collateral deposited.
function _mint(uint256 depositId, uint256 amount, bool refundFee, MarketPrices memory prices) internal {
uint256 _shares;
if (totalShares == 0) {
_shares = depositInfo[depositId].amount * 1e8;
} else {
uint256 totalAmountBefore;
if (positionIsClosed == false && _isLongOneLeverage(beenLong)) {
totalAmountBefore = IERC20(indexToken).balanceOf(address(this)) - amount;
} else {
totalAmountBefore = _totalAmount(prices) - amount;
}
if (totalAmountBefore == 0) totalAmountBefore = 1;
@> _shares = amount * totalShares / totalAmountBefore; <@
}
depositInfo[depositId].shares = _shares;
totalShares = totalShares + _shares;
if (refundFee) {
uint256 usedFee = callbackGasLimit * tx.gasprice;
if (depositInfo[counter].executionFee > usedFee) {
try IGmxProxy(gmxProxy).refundExecutionFee(depositInfo[counter].owner, depositInfo[counter].executionFee - usedFee) {} catch {}
}
}
emit Minted(depositId, depositInfo[depositId].owner, _shares, amount);
}
The impact is in totalAmountBefore
, which is the denominator in _shares = amount * totalShares / totalAmountBefore
.
So:
shares minted will depend solely on position's net value in USDC, regardless of the position's collateral
the higher is totalShares
, the more shares will the depositor get for the same prices.
These tests show how for equal deposit amounts, at the same price (for simplicity), the _mint()
function will mint a disproportionate amount of shares. In these specific examples, late depositors get more shares for the same deposit (at equal prices). This is totally contrary to the expected as new deposits increase the position value and late deposits should represent a smaller share of the total position.
This is happening because totalShares
(numerator) is growing at a higher rate than totalAmountBefore
(denominator).
SHORT GMX Position
Paste this on PerpetualVault.t.sol:
function test_DepositMintsSharesWrongForShortPosition() external {
address alice = makeAddr("alice");
payable(alice).transfer(1 ether);
address bob = makeAddr("bob");
payable(bob).transfer(1 ether);
depositFixture(alice, 1e10);
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory data = new bytes[](2);
data[0] = abi.encode(3380000000000000);
address keeper = PerpetualVault(vault).keeper();
vm.prank(keeper);
PerpetualVault(vault).run(true, false, prices, data);
GmxOrderExecuted(true);
bytes[] memory metadata = new bytes[](0);
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, metadata);
IERC20 collateralToken = PerpetualVault(vault).collateralToken();
uint256 amount = 1e10;
deal(address(collateralToken), alice, amount);
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(true);
vm.startPrank(alice);
collateralToken.approve(vault, amount);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(amount);
vm.stopPrank();
uint8 flow = uint8(PerpetualVault(vault).flow());
assertEq(flow, 1);
(PerpetualVault.NextActionSelector selector, ) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector), 1);
address indexToken = PerpetualVault(vault).indexToken();
uint256 indexBal = IERC20(indexToken).balanceOf(vault);
uint256 collBal = IERC20(collateralToken).balanceOf(vault);
console.log("indexBal: ", indexBal);
console.log("collBal: ", collBal);
data[0] = abi.encode(3380000000000000);
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, data);
uint256 collBal2 = IERC20(collateralToken).balanceOf(vault);
uint256 indexBal2 = IERC20(indexToken).balanceOf(vault);
console.log("indexBal2: ", indexBal2);
console.log("collBal2: ", collBal2);
GmxOrderExecuted(true);
uint256 collBal3 = IERC20(collateralToken).balanceOf(vault);
uint256 indexBal3 = IERC20(indexToken).balanceOf(vault);
console.log("indexBal3: ", indexBal3);
console.log("collBal3: ", collBal3);
uint256[] memory depositIdsAlice = PerpetualVault(vault).getUserDeposits(alice);
assertEq(depositIdsAlice.length, 2);
(,uint256 sharesAlice1,,,,) = PerpetualVault(vault).depositInfo(depositIdsAlice[0]);
(,uint256 sharesAlice2,,,,) = PerpetualVault(vault).depositInfo(depositIdsAlice[1]);
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, metadata);
deal(address(collateralToken), bob, amount);
vm.startPrank(bob);
collateralToken.approve(vault, amount);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(amount);
vm.stopPrank();
uint8 flow2 = uint8(PerpetualVault(vault).flow());
assertEq(flow2, 1);
(PerpetualVault.NextActionSelector selector2, ) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector2), 1);
data[0] = abi.encode(3380000000000000);
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, data);
GmxOrderExecuted(true);
uint256[] memory depositIdsBob = PerpetualVault(vault).getUserDeposits(bob);
assertEq(depositIdsBob.length, 1);
(,uint256 sharesBob,,,,) = PerpetualVault(vault).depositInfo(depositIdsBob[0]);
console.log("sharesAlice1: ", sharesAlice1);
console.log("sharesAlice2: ", sharesAlice2);
console.log("sharesBob: ", sharesBob);
assert(sharesAlice2 > sharesAlice1);
assert(sharesBob > sharesAlice2);
}
The same applies to LONG GMX positions with leverage greater than 1x. The below example is for a 2x leverage LONG.
LONG 2x Position
Paste it on PerpetualVault.t.sol
:
function test_DepositMintsSharesWrongForLong2xPosition() external {
address keeper = PerpetualVault(vault2x).keeper();
address alice = makeAddr("alice");
address bob = makeAddr("bob");
depositFixtureInto2x(alice, 1e10);
depositFixtureInto2x(bob, 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);
bytes32 curPositionKey = PerpetualVault(vault2x).curPositionKey();
assertTrue(curPositionKey != bytes32(0));
assertEq(PerpetualVault(vault2x).beenLong(), true);
PerpetualVault.FLOW flow = PerpetualVault(vault2x).flow();
assertEq(uint8(flow), 2);
(PerpetualVault.NextActionSelector selector, ) = PerpetualVault(vault2x).nextAction();
assertEq(uint8(selector), 6);
MarketPrices memory prices2 = mockData.getMarketPrices();
bytes[] memory swapData = new bytes[](2);
bytes memory paraSwapData = mockData.getParaSwapData(vault2x);
swapData[0] = abi.encode(PROTOCOL.DEX, paraSwapData);
address[] memory gmxPath = new address[](1);
gmxPath[0] = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
uint256 minOutputAmount = 1e10 * prices2.shortTokenPrice.min / prices2.longTokenPrice.min * 95 / 100;
swapData[1] = abi.encode(PROTOCOL.GMX, abi.encode(gmxPath, 1e10, minOutputAmount));
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices2, swapData);
IERC20 collateralToken = PerpetualVault(vault2x).collateralToken();
uint256 amount = 1e10;
deal(address(collateralToken), alice, amount);
deal(alice, 1e18);
uint256 executionFee = PerpetualVault(vault2x).getExecutionGasLimit(true);
vm.startPrank(alice);
collateralToken.approve(vault2x, amount);
PerpetualVault(vault2x).deposit{value: executionFee * tx.gasprice}(amount);
vm.stopPrank();
address indexToken = PerpetualVault(vault2x).indexToken();
uint256 indexBal = IERC20(indexToken).balanceOf(vault2x);
uint256 collBal = IERC20(collateralToken).balanceOf(vault2x);
console.log("indexBal: ", indexBal);
console.log("collBal: ", collBal);
data[0] = abi.encode(3390000000000000);
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices2, data);
uint256 collBal2 = IERC20(collateralToken).balanceOf(vault2x);
uint256 indexBal2 = IERC20(indexToken).balanceOf(vault2x);
console.log("indexBal2: ", indexBal2);
console.log("collBal2: ", collBal2);
GmxOrderExecuted2x(true);
PerpetualVault.FLOW flow2 = PerpetualVault(vault2x).flow();
assertEq(uint8(flow), 2);
(PerpetualVault.NextActionSelector selector2, ) = PerpetualVault(vault2x).nextAction();
assertEq(uint8(selector2), 6);
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices2, data);
uint256 collBal3 = IERC20(collateralToken).balanceOf(vault2x);
uint256 indexBal3 = IERC20(indexToken).balanceOf(vault2x);
console.log("indexBal3: ", indexBal3);
console.log("collBal3: ", collBal3);
deal(address(collateralToken), bob, amount);
deal(bob, 1e18);
executionFee = PerpetualVault(vault2x).getExecutionGasLimit(true);
vm.startPrank(bob);
collateralToken.approve(vault2x, amount);
PerpetualVault(vault2x).deposit{value: executionFee * tx.gasprice}(amount);
vm.stopPrank();
data[0] = abi.encode(3390000000000000);
vm.prank(keeper);
PerpetualVault(vault2x).runNextAction(prices2, data);
GmxOrderExecuted2x(true);
uint256[] memory depositIdsAlice = PerpetualVault(vault2x).getUserDeposits(alice);
uint256[] memory depositIdsBob = PerpetualVault(vault2x).getUserDeposits(bob);
(,uint256 sharesAlice1,,,,) = PerpetualVault(vault2x).depositInfo(depositIdsAlice[0]);
(,uint256 sharesAlice2,,,,) = PerpetualVault(vault2x).depositInfo(depositIdsAlice[1]);
(,uint256 sharesBob1,,,,) = PerpetualVault(vault2x).depositInfo(depositIdsBob[0]);
(,uint256 sharesBob2,,,,) = PerpetualVault(vault2x).depositInfo(depositIdsBob[1]);
console.log("1st Deposit (Alice) -> sharesAlice: ", sharesAlice1);
console.log("2nd Deposit (Bob) -> sharesBob: ", sharesBob1);
console.log("3rd Deposit (Alice) -> sharesAlice2: ", sharesAlice2);
console.log("4th Deposit (Bob) -> sharesBob2: ", sharesBob2);
assert(sharesBob2 > sharesAlice2);
assert(sharesAlice2 > sharesBob1);
assert(sharesBob1 == sharesAlice1);
}
Tools Used
Manual Review
Foundry testing
Recommendations
Ensure that for the case of SHORT and LONG (>1x leverage) GMX positions, function _totalAmount(prices)
accounts for PositionData
values rather than current balances in vault.