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

PerpetualVault Can Become Permanently Locked If GMX Fails to Respond

Brief

The PerpetualVault contract’s deposit flow management code can enter a permanently locked state if a GMX position operation stalls or never issues its callback. When a user deposits funds while a position is open, the vault sets its internal flow state to FLOW.DEPOSIT and waits for GMX to complete an “increase position” call. If GMX fails to respond, the contract remains stuck in FLOW.DEPOSIT with its lock set, preventing any further deposits, withdrawals, or flow cancellations. Only an unsafe manual intervention by the contract owner can reset this state, leaving user funds trapped indefinitely.

Details

The vulnerability comes from the interplay between the contract’s rigid flow states, the GMX callback mechanism, and the locking modifier that forbids state cancellation while waiting on GMX. A closer look at the relevant sections:

  1. In the deposit function, the vault transitions into FLOW.DEPOSIT:

    function deposit(uint256 amount) external nonReentrant payable {
    _noneFlow();
    // ...
    flow = FLOW.DEPOSIT;
    collateralToken.safeTransferFrom(msg.sender, address(this), amount);
    // ...
    if (!positionIsClosed) {
    _payExecutionFee(counter, true);
    nextAction.selector = NextActionSelector.INCREASE_ACTION;
    nextAction.data = abi.encode(beenLong);
    }
    }
    • The code sets flow = FLOW.DEPOSIT, blocking other user actions until the deposit flow finishes.

    • If a GMX position is already open (positionIsClosed == false), the vault prepares to “increase” that position instead of simply minting shares.

  2. The vault then calls GMX to open or increase a position and sets _gmxLock = true:

    function _createIncreasePosition(...) internal {
    // ...
    _gmxLock = true;
    gmxProxy.createOrder(orderType, orderData);
    }
    While _gmxLock remains true, the vault forces any future deposit/withdraw to revert. A callback from GMX is expected to clear this lock.
  3. If GMX never completes or cancels the order, the vault remains stuck:

    • The cancelFlow() function is guarded by a modifier that reverts if _gmxLock == true.

    • Normal user actions are disallowed because _noneFlow() checks flow == FLOW.NONE.

    • The only built-in fallback (setVaultState) is an extremely privileged owner function with minimal checks, risking more inconsistent states.

This rigid design creates a path for indefinite blocking: once in FLOW.DEPOSIT with _gmxLock == true, no state transitions can occur without a successful GMX callback or direct owner override. Hence, user funds become stuck in the contract, and standard operations cannot proceed.

Specific Impact

The vulnerability can effectively freeze the entire vault, permanently blocking deposits and withdrawals if GMX fails to respond for any reason, resulting in a denial of service and trapping user collateral without a safe, automated recovery path.

Proof Of Concept

Run with forge test --match-test test_Y1_PermanentLock_DueToMissingGmxCallbacks

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.4;
import {Test, console} from "forge-std/Test.sol";
import "forge-std/StdCheats.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ArbitrumTest} from "./utils/ArbitrumTest.sol";
import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import { ProxyAdmin } from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import { GmxProxy } from "../contracts/GmxProxy.sol";
import { KeeperProxy } from "../contracts/KeeperProxy.sol";
import { PerpetualVault } from "../contracts/PerpetualVault.sol";
import { VaultReader } from "../contracts/VaultReader.sol";
import { MarketPrices, PriceProps } from "../contracts/libraries/StructData.sol";
import { MockData } from "./mock/MockData.sol";
import { Error } from "../contracts/libraries/Error.sol";
interface IExchangeRouter {
struct SimulatePricesParams {
address[] primaryTokens;
PriceProps[] primaryPrices;
uint256 minTimestamp;
uint256 maxTimestamp;
}
function simulateExecuteOrder(bytes32 key, SimulatePricesParams memory oracleParams) external;
}
interface IOrderHandler {
function executeOrder(
bytes32 key,
MockData.OracleSetPriceParams calldata oracleParams
) external;
}
contract PermanentLockPOC is Test, ArbitrumTest {
address payable vault;
address keeper;
MockData mockData;
address alice;
address gmxProxy;
event GmxPositionCallbackCalled(bytes32 requestKey, bool success);
function setUp() public {
address ethUsdcMarket = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
address orderHandler = address(0xe68CAAACdf6439628DFD2fe624847602991A31eB);
address liquidationHandler = address(0xdAb9bA9e3a301CCb353f18B4C8542BA2149E4010);
address adlHandler = address(0x9242FbED25700e82aE26ae319BCf68E9C508451c);
address gExchangeRouter = address(0x900173A66dbD345006C51fA35fA3aB760FcD843b);
address gmxRouter = address(0x7452c558d45f8afC8c83dAe62C3f8A5BE19c71f6);
address dataStore = address(0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8);
address orderVault = address(0x31eF83a530Fde1B38EE9A18093A333D8Bbbc40D5);
address gmxReader = address(0x5Ca84c34a381434786738735265b9f3FD814b824);
address referralStorage= address(0xe6fab3F0c7199b0d34d7FbE83394fc0e0D06e99d);
ProxyAdmin proxyAdmin = new ProxyAdmin();
GmxProxy gmxUtilsLogic = new GmxProxy();
bytes memory data = abi.encodeWithSelector(
GmxProxy.initialize.selector,
orderHandler,
liquidationHandler,
adlHandler,
gExchangeRouter,
gmxRouter,
dataStore,
orderVault,
gmxReader,
referralStorage
);
gmxProxy = address(
new TransparentUpgradeableProxy(
address(gmxUtilsLogic),
address(proxyAdmin),
data
)
);
payable(gmxProxy).transfer(1 ether);
KeeperProxy keeperLogic = new KeeperProxy();
data = abi.encodeWithSelector(
KeeperProxy.initialize.selector
);
keeper = address(
new TransparentUpgradeableProxy(
address(keeperLogic),
address(proxyAdmin),
data
)
);
address owner = KeeperProxy(keeper).owner();
KeeperProxy(keeper).setKeeper(owner, true);
KeeperProxy(keeper).setDataFeed(0xaf88d065e77c8cC2239327C5EDb3A432268e5831, 0x50834F3163758fcC1Df9973b6e91f0F0F0434aD3, 86400, 500);
KeeperProxy(keeper).setDataFeed(0x82aF49447D8a07e3bd95BD0d56f35241523fBab1, 0x639Fe6ab55C921f74e7fac1ee960C0B6293ba612, 86400, 500);
VaultReader reader = new VaultReader(
orderHandler,
dataStore,
orderVault,
gmxReader,
referralStorage
);
PerpetualVault perpetualVault = new PerpetualVault();
data = abi.encodeWithSelector(
PerpetualVault.initialize.selector,
address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336),
keeper,
makeAddr("treasury"),
gmxProxy,
reader,
1e8,
1e28,
10_000
);
vm.prank(address(this), address(this));
vault = payable(
new TransparentUpgradeableProxy(
address(perpetualVault),
address(proxyAdmin),
data
)
);
mockData = new MockData();
alice = makeAddr("alice");
payable(alice).transfer(1 ether);
}
function test_Y1_PermanentLock_DueToMissingGmxCallbacks() external {
console.log("\n=== INITIAL SETUP ===");
console.log("Step 1: Make a deposit from Alice");
depositFromUser(alice, 1e10);
console.log("\nInitial state checks:");
console.log("- Vault flow state:", uint8(PerpetualVault(vault).flow()));
console.log("- Is locked:", PerpetualVault(vault).isLock());
console.log("- User collateral:", IERC20(PerpetualVault(vault).collateralToken()).balanceOf(alice));
console.log("- Vault collateral:", IERC20(PerpetualVault(vault).collateralToken()).balanceOf(vault));
console.log("\n=== DEMONSTRATE VULNERABILITY ===");
console.log("Step 2: Initiate a position change through the keeper");
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory data = new bytes[](1);
data[0] = abi.encode(3380000000000000);
vm.prank(keeper);
PerpetualVault(vault).run(true, false, prices, data);
console.log("\nPost-run state checks:");
console.log("- Vault flow state:", uint8(PerpetualVault(vault).flow()));
console.log("- Is locked:", PerpetualVault(vault).isLock());
console.log("\nStep 3: Verify GMX order was created but callback is pending");
(bytes32 requestKey, ) = GmxProxy(payable(gmxProxy)).queue();
console.log("- GMX order key:", vm.toString(requestKey));
console.log("\nStep 4: Simulate scenario where GMX executes order but callback fails");
console.log("(e.g., due to network issues, GMX upgrade, or other edge case)");
// In this scenario, we don't execute the callback at all to simulate a failed callbacks
console.log("\nStep 5: Wait for a significant period (1 day)");
vm.warp(block.timestamp + 1 days);
console.log("\n=== DEMONSTRATE PERMANENT LOCK ===");
console.log("Step 6: Check vault state after time has passed");
console.log("- Vault flow state:", uint8(PerpetualVault(vault).flow()));
console.log("- Is locked:", PerpetualVault(vault).isLock());
assertEq(uint8(PerpetualVault(vault).flow()), 2); // Still in SIGNAL_CHANGE
assertTrue(PerpetualVault(vault).isLock()); // Still locked
console.log("\nStep 7: Try to make a new deposit (should fail)");
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);
vm.expectRevert();
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(amount);
vm.stopPrank();
console.log("- Deposit failed as expected due to lock");
console.log("\nStep 8: Try to withdraw existing funds (should fail)");
uint256[] memory depositIds = PerpetualVault(vault).getUserDeposits(alice);
executionFee = PerpetualVault(vault).getExecutionGasLimit(false);
vm.startPrank(alice);
vm.expectRevert();
PerpetualVault(vault).withdraw{value: executionFee * tx.gasprice}(alice, depositIds[0]);
vm.stopPrank();
console.log("- Withdrawal failed as expected due to lock");
console.log("\nStep 9: Try to use keeper operations to recover");
vm.startPrank(keeper);
vm.expectRevert();
PerpetualVault(vault).cancelFlow();
vm.stopPrank();
console.log("- Keeper operations failed due to lock");
console.log("\nStep 10: Wait an extremely long time (30 days)");
vm.warp(block.timestamp + 30 days);
console.log("\nStep 11: Verify vault is still locked even after 30 days");
console.log("- Vault flow state:", uint8(PerpetualVault(vault).flow()));
console.log("- Is locked:", PerpetualVault(vault).isLock());
assertTrue(PerpetualVault(vault).isLock());
console.log("\n=== ATTEMPT TO RECOVER ===");
address owner = PerpetualVault(vault).owner();
console.log("Step 12: Try owner functions to recover");
vm.startPrank(owner);
// Try various owner functions that might help recover
try PerpetualVault(vault).cancelFlow() {
console.log("- Owner cancelFlow succeeded (unexpected)");
} catch {
console.log("- Owner cancelFlow failed (expected)");
}
vm.stopPrank();
console.log("\n=== CONCLUSION ===");
console.log("Vault is PERMANENTLY LOCKED without recovery mechanism");
}
/// @notice Helper function to make a deposit from a user
function depositFromUser(address user, uint256 amount) internal {
IERC20 collateralToken = PerpetualVault(vault).collateralToken();
vm.startPrank(user);
deal(address(collateralToken), user, amount);
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(true);
collateralToken.approve(vault, amount);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(amount);
vm.stopPrank();
}
}

LOGS:

Ran 1 test for test/PermanentLockPOC.t.sol:PermanentLockPOC
[PASS] test_Y1_PermanentLock_DueToMissingGmxCallbacks() (gas: 1667783)
Logs:
=== INITIAL SETUP ===
Step 1: Make a deposit from Alice
Initial state checks:
- Vault flow state: 0
- Is locked: false
- User collateral: 0
- Vault collateral: 10000000000
=== DEMONSTRATE VULNERABILITY ===
Step 2: Initiate a position change through the keeper
Post-run state checks:
- Vault flow state: 2
- Is locked: true
Step 3: Verify GMX order was created but callback is pending
- GMX order key: 0x2e9b56aff7ad76a388482089a48c1ad1e043cb606088141ae8d535ca504b10cb
Step 4: Simulate scenario where GMX executes order but callback fails
(e.g., due to network issues, GMX upgrade, or other edge case)
Step 5: Wait for a significant period (1 day)
=== DEMONSTRATE PERMANENT LOCK ===
Step 6: Check vault state after time has passed
- Vault flow state: 2
- Is locked: true
Step 7: Try to make a new deposit (should fail)
- Deposit failed as expected due to lock
Step 8: Try to withdraw existing funds (should fail)
- Withdrawal failed as expected due to lock
Step 9: Try to use keeper operations to recover
- Keeper operations failed due to lock
Step 10: Wait an extremely long time (30 days)
Step 11: Verify vault is still locked even after 30 days
- Vault flow state: 2
- Is locked: true
=== ATTEMPT TO RECOVER ===
Step 12: Try owner functions to recover
- Owner cancelFlow failed (expected)
=== CONCLUSION ===
Vault is PERMANENTLY LOCKED without recovery mechanism
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 14.89ms (7.37ms CPU time)
Ran 1 test suite in 444.65ms (14.89ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Updates

Lead Judging Commences

n0kto Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Informational or Gas

Please read the CodeHawks documentation to know which submissions are valid. If you disagree, provide a coded PoC and explain the real likelihood and the detailed impact on the mainnet without any supposition (if, it could, etc) to prove your point.

n0kto Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Informational or Gas

Please read the CodeHawks documentation to know which submissions are valid. If you disagree, provide a coded PoC and explain the real likelihood and the detailed impact on the mainnet without any supposition (if, it could, etc) to prove your point.

Appeal created

itsgreg Submitter
9 months ago
n0kto Lead Judge
9 months ago
n0kto Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Informational or Gas

Please read the CodeHawks documentation to know which submissions are valid. If you disagree, provide a coded PoC and explain the real likelihood and the detailed impact on the mainnet without any supposition (if, it could, etc) to prove your point.

Support

FAQs

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

Give us feedback!