Summary
The PerpetualVault contract contains a critical issue where the deposit function will always fail after a liquidation event due to improper state management in the afterLiquidationExecution function.
Vulnerability Details
-
-
function afterLiquidationExecution() external {
if (msg.sender != address(gmxProxy)) {
revert Error.InvalidCall();
}
depositPaused = true;
uint256 sizeInTokens = vaultReader.getPositionSizeInTokens(curPositionKey);
if (sizeInTokens == 0) {
delete curPositionKey;
}
if (flow == FLOW.NONE) {
flow = FLOW.LIQUIDATION;
nextAction.selector = NextActionSelector.FINALIZE;
} else if (flow == FLOW.DEPOSIT) {
flowData = sizeInTokens;
} else if (flow == FLOW.WITHDRAW) {
nextAction.selector = NextActionSelector.WITHDRAW_ACTION;
}
}
function deposit(uint256 amount) external nonReentrant payable {
_noneFlow();
if (depositPaused == true) {
revert Error.Paused();
}
}
The root of the problem is that when a liquidation occurs, afterLiquidationExecution is called and sets depositPaused = true without any logic ever setting depositPaused back to false after the liquidation is complete. The only way to change depositPaused is via the setDepositPaused function which can only be called by the owner.
As a result, once a liquidation occurs all new deposits will always fail due to depositPaused=true with no automatic mechanism to return the vault to its normal state and the vault is effectively permanently locked to new deposits.
Even after the liquidation is complete and the position is closed (positionIsClosed = true), the vault still cannot accept new deposits because depositPaused remains true.
Impact
POC
Add this to PerpetualVault.t.sol and run it forge test --match-test test_PermanentDepositLockAfterLiquidation --rpc-url arbitrum -vvvv.
function test_PermanentDepositLockAfterLiquidation() external {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
uint256 depositAmount = 1e10;
depositFixture(alice, depositAmount);
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory data = new bytes[](1);
data[0] = abi.encode(3380000000000000);
address keeper = PerpetualVault(vault).keeper();
vm.prank(keeper);
PerpetualVault(vault).run(true, false, prices, data);
GmxOrderExecuted(true);
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, new bytes[](0));
assertEq(uint8(PerpetualVault(vault).flow()), 0, "Flow should be NONE before liquidation");
assertFalse(PerpetualVault(vault).depositPaused(), "Deposits should not be paused initially");
address gmxProxy = address(PerpetualVault(vault).gmxProxy());
vm.startPrank(gmxProxy);
PerpetualVault(vault).afterLiquidationExecution();
vm.stopPrank();
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, new bytes[](0));
assertTrue(PerpetualVault(vault).depositPaused(), "Deposits should be paused after liquidation");
IERC20 collateralToken = PerpetualVault(vault).collateralToken();
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(true) * tx.gasprice;
vm.startPrank(bob);
deal(address(collateralToken), bob, depositAmount);
deal(bob, executionFee);
collateralToken.approve(vault, depositAmount);
vm.expectRevert(Error.Paused.selector);
PerpetualVault(vault).deposit{value: executionFee}(depositAmount);
vm.stopPrank();
vm.warp(block.timestamp + 30 days);
vm.startPrank(bob);
vm.expectRevert(Error.Paused.selector);
PerpetualVault(vault).deposit{value: executionFee}(depositAmount);
vm.stopPrank();
address randomUser = makeAddr("random");
vm.prank(randomUser);
vm.expectRevert("Ownable: caller is not the owner");
PerpetualVault(vault).setDepositPaused(false);
address owner = PerpetualVault(vault).owner();
vm.prank(owner);
PerpetualVault(vault).setDepositPaused(false);
vm.startPrank(bob);
deal(bob, executionFee);
PerpetualVault(vault).deposit{value: executionFee}(depositAmount);
vm.stopPrank();
uint256[] memory bobDeposits = PerpetualVault(vault).getUserDeposits(bob);
assertEq(bobDeposits.length, 1, "Bob should have one deposit after unpausing");
assertFalse(PerpetualVault(vault).depositPaused(), "Deposits should remain unpaused");
}
Tools Used
Recommendations
Implement proper state cleanup and deposit handling during liquidation.
function afterLiquidationExecution() external {
if (msg.sender != address(gmxProxy)) {
revert Error.InvalidCall();
}
depositPaused = true;
uint256 sizeInTokens = vaultReader.getPositionSizeInTokens(curPositionKey);
if (sizeInTokens == 0) {
delete curPositionKey;
}
if (flow == FLOW.DEPOSIT) {
uint256 depositId = counter;
collateralToken.safeTransfer(
depositInfo[depositId].owner,
depositInfo[depositId].amount
);
totalDepositAmount = totalDepositAmount - depositInfo[depositId].amount;
EnumerableSet.remove(
userDeposits[depositInfo[depositId].owner],
depositId
);
delete depositInfo[depositId];
}
delete flowData;
flow = FLOW.LIQUIDATION;
nextAction.selector = NextActionSelector.FINALIZE;
}