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

DOS Deposit Function After Liquidation

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; // <-- Problem #1
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) { // <-- Problem #2
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

  • Vault becomes non-functional for its primary purpose (receiving deposits)

  • DOS

POC

Add this to PerpetualVault.t.sol and run it forge test --match-test test_PermanentDepositLockAfterLiquidation --rpc-url arbitrum -vvvv.

function test_PermanentDepositLockAfterLiquidation() external {
// Initial setup
address alice = makeAddr("alice");
address bob = makeAddr("bob");
uint256 depositAmount = 1e10;
// Setup initial position with Alice
depositFixture(alice, depositAmount);
// Create a position that will be liquidated
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory data = new bytes[](1);
data[0] = abi.encode(3380000000000000);
address keeper = PerpetualVault(vault).keeper();
// First complete any existing flow
vm.prank(keeper);
PerpetualVault(vault).run(true, false, prices, data);
GmxOrderExecuted(true);
// Ensure flow is completed
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, new bytes[](0));
// Verify initial state before liquidation
assertEq(uint8(PerpetualVault(vault).flow()), 0, "Flow should be NONE before liquidation");
assertFalse(PerpetualVault(vault).depositPaused(), "Deposits should not be paused initially");
// Simulate liquidation through GMX callback
address gmxProxy = address(PerpetualVault(vault).gmxProxy());
vm.startPrank(gmxProxy);
PerpetualVault(vault).afterLiquidationExecution();
vm.stopPrank();
// Complete the liquidation flow
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, new bytes[](0));
// Verify vault state after liquidation
assertTrue(PerpetualVault(vault).depositPaused(), "Deposits should be paused after liquidation");
// Attempt deposit from Bob 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);
// Verify deposit is blocked with correct error
vm.expectRevert(Error.Paused.selector);
PerpetualVault(vault).deposit{value: executionFee}(depositAmount);
vm.stopPrank();
// Verify state persists after time
vm.warp(block.timestamp + 30 days);
vm.startPrank(bob);
vm.expectRevert(Error.Paused.selector);
PerpetualVault(vault).deposit{value: executionFee}(depositAmount);
vm.stopPrank();
// Verify only owner can unpause
address randomUser = makeAddr("random");
vm.prank(randomUser);
vm.expectRevert("Ownable: caller is not the owner");
PerpetualVault(vault).setDepositPaused(false);
// Owner unpauses deposits
address owner = PerpetualVault(vault).owner();
vm.prank(owner);
PerpetualVault(vault).setDepositPaused(false);
// Verify deposits work again after unpause
vm.startPrank(bob);
deal(bob, executionFee);
PerpetualVault(vault).deposit{value: executionFee}(depositAmount);
vm.stopPrank();
// Final state verification
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

  • Manual review

  • Foundry

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;
}
// Handle ongoing deposit
if (flow == FLOW.DEPOSIT) {
uint256 depositId = counter;
// Refund the deposit
collateralToken.safeTransfer(
depositInfo[depositId].owner,
depositInfo[depositId].amount
);
totalDepositAmount = totalDepositAmount - depositInfo[depositId].amount;
EnumerableSet.remove(
userDeposits[depositInfo[depositId].owner],
depositId
);
delete depositInfo[depositId];
}
// Reset flow state
delete flowData;
flow = FLOW.LIQUIDATION;
nextAction.selector = NextActionSelector.FINALIZE;
}
Updates

Lead Judging Commences

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

Support

FAQs

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