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

Settlement Flow Can Be Disrupted When Market Decrease Order is Disabled

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"
);
// check if execution feature is enabled
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, // this param is used when swapping. is not used in opening position even though swap involved.
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, // this param is used when swapping. is not used in opening position even though swap involved.
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:

  1. The settle order gets canceled

  2. PerpetualVault.sol#afterOrderCancellation() triggers another settlement attempt

  3. 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) {
// Retry settle action.
nextAction.selector = NextActionSelector.SETTLE_ACTION;
} else if (orderType == Order.OrderType.MarketSwap) {
// If GMX swap fails, retry in the next action.
nextAction.selector = NextActionSelector.SWAP_ACTION;
// abi.encode(swapAmount, swapDirection): if swap direction is true, swap collateralToken to indexToken
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 {
// If signal change fails, the offchain script starts again from the current status.
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");
+ // check if execution feature is enabled.
+ byte32 executeOrderFeatureKey = keccak256(
+ abi.encode(
+ EXECUTE_ORDER_FEATURE_DISABLED,
+ orderHandler,
+ Order.OrderType.MarketDecrease
+ )
+ );
+ require(
+ dataStore.getBool(executeOrderFeatureKey) == false,
+ "gmx execution disabled"
+ );
........................................................
}
Updates

Lead Judging Commences

n0kto Lead Judge 9 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_execution_feature_not_checked

Likelihood: Low, when the execution is disabled on GMX. Impact: Low/Medium, cyclic settlement/cancelOrder loop.

Support

FAQs

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

Give us feedback!