Summary
In PerpetualVault.sol, when changing position types, a flaw in afterOrderCancellation() allows the creation of new positions without properly closing existing ones. This occurs due to incomplete cleanup of the next action state when an order is cancelled.
Vulnerability Details
The vulnerability manifests in the following sequence:
The keeper initiates a position type change through PerpetualVault.sol#run():
If the order is cancelled, afterOrderCancellation() is triggered:
When runNextAction() is subsequently called:
This creates a scenario where multiple positions can exist simultaneously, which violates the protocol's intended behavior.
Key code sections remain as in original report:
* @notice Off-chain script calls `run` function to change position type (long/short/close).
* @param isOpen If true, open a position; if false, close the current position.
* @param isLong If true, open a long position; if false, open a short position.
* @param prices GMX price data of market tokens from GMX API call.
* @param metadata Swap transaction data generated by off-chain script.
*/
function run(
bool isOpen,
bool isLong,
MarketPrices memory prices,
bytes[] memory metadata
) external nonReentrant {
_noneFlow();
_onlyKeeper();
if (gmxProxy.lowerThanMinEth()) {
revert Error.LowerThanMinEth();
}
flow = FLOW.SIGNAL_CHANGE;
if (isOpen) {
if (positionIsClosed) {
if (_isFundIdle() == false) {
revert Error.InsufficientAmount();
}
if (_isLongOneLeverage(isLong)) {
_runSwap(metadata, true, prices);
} else {
(uint256 acceptablePrice) = abi.decode(metadata[0], (uint256));
_createIncreasePosition(isLong, acceptablePrice, prices);
}
} else {
if (beenLong == isLong) {
revert Error.NoAction();
} else {
-> nextAction.selector = NextActionSelector.INCREASE_ACTION;
-> nextAction.data = abi.encode(isLong);
if (_isLongOneLeverage(beenLong)) {
_runSwap(metadata, false, prices);
} else {
(uint256 acceptablePrice) = abi.decode(metadata[0], (uint256));
-> _createDecreasePosition(0, 0, beenLong, acceptablePrice, prices);
}
}
}
} else {
if (positionIsClosed == false) {
if (_isLongOneLeverage(beenLong)) {
_runSwap(metadata, false, prices);
} else {
(uint256 acceptablePrice) = abi.decode(metadata[0], (uint256));
_createDecreasePosition(0, 0, beenLong, acceptablePrice, prices);
}
} else {
revert Error.NoAction();
}
}
}
* @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);
}
* @notice `flow` is not completed in one tx. So in that case, set up the next action data
* and run the `nextAction`.
* @param prices GMX price data of market tokens from GMX API call
* @param metadata swap tx data. generated by off-chain script
*/
function runNextAction(MarketPrices memory prices, bytes[] memory metadata) external nonReentrant gmxLock {
_onlyKeeper();
Action memory _nextAction = nextAction;
delete nextAction;
if (_nextAction.selector == NextActionSelector.INCREASE_ACTION) {
(bool _isLong) = abi.decode(_nextAction.data, (bool));
if (_isLongOneLeverage(_isLong)) {
_runSwap(metadata, true, prices);
} else {
if (
IERC20(indexToken).balanceOf(address(this)) * prices.indexTokenPrice.min >= ONE_USD
) {
(, bytes memory data) = abi.decode(metadata[1], (PROTOCOL, bytes));
_doDexSwap(data, false);
}
(uint256 acceptablePrice) = abi.decode(metadata[0], (uint256));
_createIncreasePosition(_isLong, acceptablePrice, prices);
}
} else if (_nextAction.selector == NextActionSelector.WITHDRAW_ACTION) {
if (
IERC20(indexToken).balanceOf(address(this)) * prices.indexTokenPrice.min >= ONE_USD
) {
(, bytes memory data) = abi.decode(metadata[1], (PROTOCOL, bytes));
_doDexSwap(data, false);
}
uint256 depositId = flowData;
_withdraw(depositId, metadata[0], prices);
} else if (_nextAction.selector == NextActionSelector.SWAP_ACTION) {
(, bool isCollateralToIndex) = abi.decode(
_nextAction.data,
(uint256, bool)
);
_runSwap(metadata, isCollateralToIndex, prices);
} else if (_nextAction.selector == NextActionSelector.SETTLE_ACTION) {
_settle();
} else if (_nextAction.selector == NextActionSelector.FINALIZE) {
if (
IERC20(indexToken).balanceOf(address(this)) * prices.indexTokenPrice.min >= ONE_USD
) {
(, bytes memory data) = abi.decode(metadata[1], (PROTOCOL, bytes));
_doDexSwap(data, false);
}
_finalize(_nextAction.data);
} else if (positionIsClosed == false && _isFundIdle()) {
flow = FLOW.COMPOUND;
if (_isLongOneLeverage(beenLong)) {
_runSwap(metadata, true, prices);
} else {
(uint256 acceptablePrice) = abi.decode(metadata[0], (uint256));
-> _createIncreasePosition(beenLong, acceptablePrice, prices);
}
} else {
revert Error.InvalidCall();
}
}
Impact
Protocol state consistency is compromised
Multiple positions can exist simultaneously
Potential for unexpected behavior in position management
Risk of financial losses due to unintended position interactions
Tools Used
Manual Code Review
Recommendations
Implement proper cleanup in afterOrderCancellation() by adding:
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;
+ delete nextAction;
}
}
emit GmxPositionCallbackCalled(requestKey, false);
}