Summary
The settle() function in GmxProxy.sol lacks a validation check for the market decrease order execution feature being enabled, which could lead to a cyclic cancellation flow when a user attempts to withdraw.
Vulnerability Details
In the GmxProxy.sol contract, there is a discrepancy in validation checks between the createOrder() and settle() functions.
The createOrder() function properly validates if the execution feature is enabled for the specific order type:
* @notice Creates an order.
* @dev This function requires the receipient to be the perpetual vault and ensures sufficient ETH balance for the execution fee.
* It handles token approvals, transfers, and constructs the order parameters before creating the order via `gExchangeRouter`.
* @param orderType The type of the order (e.g., MarketIncrease, MarketDecrease, etc.).
* @param orderData The data associated with the order.
* @return The request key of the created order.
*/
function createOrder(
Order.OrderType orderType,
IGmxProxy.OrderData memory orderData
) public returns (bytes32) {
require(msg.sender == perpVault, "invalid caller");
uint256 positionExecutionFee = getExecutionGasLimit(
orderType,
orderData.callbackGasLimit
) * tx.gasprice;
require(
address(this).balance >= positionExecutionFee,
"insufficient eth balance"
);
bytes32 executeOrderFeatureKey = keccak256(
abi.encode(
EXECUTE_ORDER_FEATURE_DISABLED,
orderHandler,
orderType
)
);
require(
dataStore.getBool(executeOrderFeatureKey) == false,
"gmx execution disabled"
);
gExchangeRouter.sendWnt{value: positionExecutionFee}(
orderVault,
positionExecutionFee
);
if (
orderType == Order.OrderType.MarketSwap ||
orderType == Order.OrderType.MarketIncrease
) {
IERC20(orderData.initialCollateralToken).safeApprove(
address(gmxRouter),
orderData.amountIn
);
gExchangeRouter.sendTokens(
orderData.initialCollateralToken,
orderVault,
orderData.amountIn
);
}
CreateOrderParamsAddresses memory paramsAddresses = CreateOrderParamsAddresses({
receiver: perpVault,
cancellationReceiver: address(perpVault),
callbackContract: address(this),
uiFeeReceiver: address(0),
market: orderData.market,
initialCollateralToken: orderData.initialCollateralToken,
swapPath: orderData.swapPath
});
CreateOrderParamsNumbers memory paramsNumber = CreateOrderParamsNumbers({
sizeDeltaUsd: orderData.sizeDeltaUsd,
initialCollateralDeltaAmount: orderData.initialCollateralDeltaAmount,
triggerPrice: 0,
acceptablePrice: orderData.acceptablePrice,
executionFee: positionExecutionFee,
callbackGasLimit: orderData.callbackGasLimit,
minOutputAmount: orderData.minOutputAmount,
validFromTime: 0
});
CreateOrderParams memory params = CreateOrderParams({
addresses: paramsAddresses,
numbers: paramsNumber,
orderType: orderType,
decreasePositionSwapType: Order
.DecreasePositionSwapType
.SwapPnlTokenToCollateralToken,
isLong: orderData.isLong,
shouldUnwrapNativeToken: false,
autoCancel: false,
referralCode: referralCode
});
bytes32 requestKey = gExchangeRouter.createOrder(params);
queue.requestKey = requestKey;
return requestKey;
}
However, the settle() function lacks this crucial check:
* @notice Settles an order by creating a MarketDecrease order with minimal collateral delta amount.
* @dev This function calculates the execution fee, ensures sufficient ETH balance, sets up the order parameters,
* and creates the order via the `gExchangeRouter`.
* @param orderData The data associated with the order, encapsulated in an `OrderData` struct.
* @return The request key of the created order.
*/
function settle(
IGmxProxy.OrderData memory orderData
) external returns (bytes32) {
require(msg.sender == perpVault, "invalid caller");
uint256 positionExecutionFee = getExecutionGasLimit(
Order.OrderType.MarketDecrease,
orderData.callbackGasLimit
) * tx.gasprice;
require(
address(this).balance >= positionExecutionFee,
"insufficient eth balance"
);
gExchangeRouter.sendWnt{value: positionExecutionFee}(
orderVault,
positionExecutionFee
);
CreateOrderParamsAddresses memory paramsAddresses = CreateOrderParamsAddresses({
receiver: perpVault,
cancellationReceiver: address(perpVault),
callbackContract: address(this),
uiFeeReceiver: address(0),
market: orderData.market,
initialCollateralToken: orderData.initialCollateralToken,
swapPath: new address[](0)
});
CreateOrderParamsNumbers memory paramsNumber = CreateOrderParamsNumbers({
sizeDeltaUsd: 0,
initialCollateralDeltaAmount: 1,
triggerPrice: 0,
acceptablePrice: 0,
executionFee: positionExecutionFee,
callbackGasLimit: orderData.callbackGasLimit,
minOutputAmount: 0,
validFromTime: 0
});
CreateOrderParams memory params = CreateOrderParams({
addresses: paramsAddresses,
numbers: paramsNumber,
orderType: Order.OrderType.MarketDecrease,
decreasePositionSwapType: Order
.DecreasePositionSwapType
.SwapPnlTokenToCollateralToken,
isLong: orderData.isLong,
shouldUnwrapNativeToken: false,
autoCancel: false,
referralCode: referralCode
});
bytes32 requestKey = gExchangeRouter.createOrder(params);
queue.requestKey = requestKey;
queue.isSettle = true;
return requestKey;
}
When the MarketDecrease order type is disabled, this leads to a problematic cycle where:
The settle order gets canceled
PerpetualVault.sol#afterOrderCancellation() triggers another settlement attempt
The cycle repeats
* @notice Callback function triggered when an order execution on GMX is canceled due to an error.
* @param requestKey The request key of the executed order.
* @param orderType The type of order.
* @param orderResultData The result data of the order execution.
*/
function afterOrderCancellation(
bytes32 requestKey,
Order.OrderType orderType,
IGmxProxy.OrderResultData memory orderResultData
) external {
if (msg.sender != address(gmxProxy)) {
revert Error.InvalidCall();
}
_gmxLock = false;
if (orderResultData.isSettle) {
nextAction.selector = NextActionSelector.SETTLE_ACTION;
} else if (orderType == Order.OrderType.MarketSwap) {
nextAction.selector = NextActionSelector.SWAP_ACTION;
nextAction.data = abi.encode(swapProgressData.remaining, swapProgressData.isCollateralToIndex);
} else {
if (flow == FLOW.DEPOSIT) {
nextAction.selector = NextActionSelector.INCREASE_ACTION;
nextAction.data = abi.encode(beenLong);
} else if (flow == FLOW.WITHDRAW) {
nextAction.selector = NextActionSelector.WITHDRAW_ACTION;
} else {
delete flowData;
delete flow;
}
}
emit GmxPositionCallbackCalled(requestKey, false);
}
Impact
Creates an infinite loop of failed settlement attempts
Forces keepers to manually cancel flows to recover
Allows malicious users to cause temporary protocol disruption through strategic withdrawals
Wastes keeper resources and gas
Tools Used
Manual Review
Recommendations
Add the execution feature validation check to the settle() function:
function settle(
IGmxProxy.OrderData memory orderData
) external returns (bytes32) {
require(msg.sender == perpVault, "invalid caller");
+
+ byte32 executeOrderFeatureKey = keccak256(
+ abi.encode(
+ EXECUTE_ORDER_FEATURE_DISABLED,
+ orderHandler,
+ Order.OrderType.MarketDecrease
+ )
+ );
+ require(
+ dataStore.getBool(executeOrderFeatureKey) == false,
+ "gmx execution disabled"
+ );
........................................................
}