Summary
The GmxProxy contract allows the owner to withdraw all ETH through withdrawEth(), which can prevent two critical
operations: settling existing positions. This breaks key protocol functionality the operation require ETH for execution fees.
Also in the protocol we have a strong invariant in which afterOrderExecution which must not break which also requires some gas fees.
If there is an issue or if the admin likely went ahead to withdrawEth on gmxProxy before afterOrderExecution is called this could affect data flow in the perpetualvault and that requires some gas fees to process and as u know according to the dev on afterOrderExecution on perpetualVault that function must never break
The PerpetualVault
* @notice Callback function that is called when an order on GMX is executed successfully.
* This function handles the post-execution logic based on the type of order that was executed.
* @dev This callback function must never be reverted. It should wrap revertible external calls with `try-catch`.
* @param requestKey The request key of the executed order.
* @param positionKey The position key.
* @param orderResultData Data from the order execution.
*/
function afterOrderExecution(
bytes32 requestKey,
bytes32 positionKey,
IGmxProxy.OrderResultData memory orderResultData,
MarketPrices memory prices
) external nonReentrant {
if (msg.sender != address(gmxProxy)) {
revert Error.InvalidCall();
}
The GmxProxy
function afterOrderExecution(bytes32 requestKey, Order.Props memory order, EventLogData memory eventData)
external
override
validCallback(requestKey, order)
{
bytes32 positionKey = keccak256(
abi.encode(
address(this), order.addresses.market, order.addresses.initialCollateralToken, order.flags.isLong
)
)
.......
// rest of code
IPerpetualVault(perpVault).afterOrderExecution(requestKey, positionKey, orderResultData, prices);
delete queue;
Vulnerability Details
Two critical functions require ETH balance for execution fees:
Creating new orders: This is where the fees is added by the user creating the order
function createOrder(Order.OrderType orderType, IGmxProxy.OrderData memory orderData) public returns (bytes32) {
uint256 positionExecutionFee = getExecutionGasLimit(orderType, orderData.callbackGasLimit) * tx.gasprice;
require(address(this).balance >= positionExecutionFee, "insufficient eth balance");
gExchangeRouter.sendWnt{value: positionExecutionFee}(orderVault, positionExecutionFee);
}
However, the owner can drain all ETH before settle is called or afterOrderExecution
function withdrawEth() external onlyOwner returns (uint256) {
uint256 balance = address(this).balance;
payable(msg.sender).transfer(balance);
return balance;
}
Settlement of existing positions:
function settle(IGmxProxy.OrderData memory orderData) external returns (bytes32) {
uint256 positionExecutionFee = getExecutionGasLimit(Order.OrderType.MarketDecrease, orderData.callbackGasLimit) * tx.gasprice;
require(address(this).balance >= positionExecutionFee, "insufficient eth balance");
gExchangeRouter.sendWnt{value: positionExecutionFee}(orderVault, positionExecutionFee);
}
Impact
HIGH - If owner withdraws all ETH:
Existing positions cannot be settled due to insufficient ETH for execution fees
AfterOrderExicution could break because it requires eth for transaction fees and would affect variables flow
Tools Used
Proof Of Concept
This test shows how the owner withdraw all eth which prevent creating position for jsut this test scenario but could also affect the afterOrderExecution after an order has been created
function test_withdrawEthCausesOrderCreationToFail() public {
address gmxProxyAddress = address(PerpetualVault(vault).gmxProxy());
GmxProxy gmxProxy = GmxProxy(payable(gmxProxyAddress));
address alice = makeAddr("alice");
payable(alice).transfer(1 ether);
IERC20 collateralToken = PerpetualVault(vault).collateralToken();
address whale = 0x489ee077994B6658eAfA855C308275EAd8097C4A;
vm.startPrank(whale);
uint256 amount = 1e10;
collateralToken.transfer(alice, amount);
vm.stopPrank();
vm.startPrank(alice);
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(true);
collateralToken.approve(vault, amount);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(amount);
vm.stopPrank();
console.log("The GmxProxy Contract balance before withdraw",address(gmxProxy).balance);
address owner = gmxProxy.owner();
vm.startPrank(owner);
uint256 withdrawn = gmxProxy.withdrawEth();
vm.stopPrank();
console.log("The GmxProxy Contract balance after withdraw",address(gmxProxy).balance);
console.log("The GmxProxy Contract minEth", gmxProxy.minEth());
assertEq(address(gmxProxy).balance, 0, "Should have withdrawn all ETH");
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory data = new bytes[](2);
data[0] = abi.encode(3380000000000000);
address keeperOwner = KeeperProxy(keeper).owner();
vm.startPrank(keeperOwner);
vm.expectRevert();
KeeperProxy(keeper).run(vault, true, false, prices, data);
vm.stopPrank();
assertEq(address(gmxProxy).balance, 0, "Balance should still be zero");
}
The Log Gotten From Foundry Response
➜ 2025-02-gamma git:(main) ✗ forge test --mt test_withdrawEthCausesOrderCreationToFail -vvvv --via-ir --rpc-url arbitrum
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/KeeperProxy.t.sol:KeeperProxyTest
[PASS] test_withdrawEthCausesOrderCreationToFail() (gas: 721357)
Traces:
........
│ │ │ │ │ └─ ← [Return] true
│ │ │ │ └─ ← [Revert] LowerThanMinEth()
│ │ │ └─ ← [Revert] LowerThanMinEth()
│ │ └─ ← [Revert] LowerThanMinEth()
│ └─ ← [Revert] LowerThanMinEth()
├─ [0] VM::stopPrank()
│ └─ ← [Return]
└─ ← [Return]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 11.38ms (3.92ms CPU time)
Ran 1 test suite in 4.82s (11.38ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
➜ 2025-02-gamma git:(main) ✗
Recommendations
Add ETH reserve requirement in withdrawEth():
This way if there is an active order owner shouldnt be able to withdraw all ethereum from the gmxProxy
function withdrawEth() external onlyOwner returns (uint256) {
uint256 balance = address(this).balance;
uint256 requiredEth = 0;
if (queue.requestKey != bytes32(0)) {
requiredEth += getExecutionGasLimit(
Order.OrderType.MarketDecrease,
2_000_000
) * tx.gasprice;
}
requiredEth += minEth;
require(balance > requiredEth, "Must maintain ETH reserve");
uint256 withdrawable = balance - requiredEth;
payable(msg.sender).transfer(withdrawable);
return withdrawable;
}
This ensures sufficient ETH remains for both settlement and new order operations.