Summary
In PerpetualVault::withdraw
-> the user is charged an exectuionFee, which is then sent to gmxProxy
.
This fee is used when creating orders on gmx
. But throughout the protocol, is refunded when the user action in certain states do not require any order creation or interaction with the gmx
.
But, this is not followed in withdraw
-> and the user is charged executionFee
and never refunded, even though gmx
is never used. They should be refunded.
Vulnerability Details
Lets go through the code path:
** User calls withdraw
and is charged executionFee
. This fee is charged regardless of the following steps, which are based on the state and current position.
** _payExecutionFee
sends the ETH amount sent in as msg.value
to gmxProxy
function withdraw(address recipient, uint256 depositId) public payable nonReentrant {
_noneFlow();
flow = FLOW.WITHDRAW;
flowData = depositId;
if (recipient == address(0)) {
revert Error.ZeroValue();
}
if (depositInfo[depositId].timestamp + lockTime >= block.timestamp) {
revert Error.Locked();
}
if (EnumerableSet.contains(userDeposits[msg.sender], depositId) == false) {
revert Error.InvalidUser();
}
if (depositInfo[depositId].shares == 0) {
revert Error.ZeroValue();
}
depositInfo[depositId].recipient = recipient;
-> _payExecutionFee(depositId, false);
if (curPositionKey != bytes32(0)) {
nextAction.selector = NextActionSelector.WITHDRAW_ACTION;
_settle();
-> } else {
MarketPrices memory prices;
_withdraw(depositId, hex'', prices);
}
}
** If the position is closed, the curPositionKey
will be 0 -> which the logic has specific instructions for handling that scenario. It directly calls _withdraw
* @notice Handles the internal withdrawal process for a given deposit ID.
* @dev This function calculates the share of the withdrawal, handles the swap if necessary,
* and updates the position accordingly.
* @param depositId The ID of the deposit to withdraw.
*/
function _withdraw(uint256 depositId, bytes memory metadata, MarketPrices memory prices) internal {
uint256 shares = depositInfo[depositId].shares;
if (shares == 0) {
revert Error.ZeroValue();
}
-> if (positionIsClosed) {
_handleReturn(0, true, false);
-> } else if (curPositionKey == bytes32(0)) {
_handleReturn(0, true, false);
** The logic does the same operation for position being closed and curPositionKey being 0. The next step of the code path is calling _handleReturn
.
** It is IMPORTANT to note that in both scenarios, the input passed to _handleReturn
is 0, true, false
** the false
signifies that refundFee
is false
-> and user wont get a refund
-> * @notice this function is an end of withdrawal flow.
* @dev should update all necessary global state variables
*
* @param withdrawn amount of token withdrawn from the position
* @param positionClosed true when position is closed completely by withdrawing all funds, or false
*/
-> function _handleReturn(uint256 withdrawn, bool positionClosed, bool refundFee) internal {
(uint256 depositId) = flowData;
uint256 shares = depositInfo[depositId].shares;
uint256 amount;
if (positionClosed) {
amount = collateralToken.balanceOf(address(this)) * shares / totalShares;
} else {
uint256 balanceBeforeWithdrawal = collateralToken.balanceOf(address(this)) - withdrawn;
amount = withdrawn + balanceBeforeWithdrawal * shares / totalShares;
}
if (amount > 0) {
_transferToken(depositId, amount);
}
emit Burned(depositId, depositInfo[depositId].recipient, depositInfo[depositId].shares, amount);
_burn(depositId);
-> if (refundFee) {
uint256 usedFee = callbackGasLimit * tx.gasprice;
if (depositInfo[depositId].executionFee > usedFee) {
try IGmxProxy(gmxProxy).refundExecutionFee(depositInfo[counter].owner, depositInfo[counter].executionFee - usedFee) {} catch {}
}
}
delete swapProgressData;
delete flowData;
delete flow;
}
** that is the end of the code path and the user successfully withdraws -> BUT they are charged an executionFee
that they should never need to pay for this scenario.
** There is specific logic to refund the fee to the user when gmx
is never used, and this is followed in the rest of the codebase, just not here.
Impact
The user is charged an executionFee
-> WHICH IS DOUBLE the amount of an increase order
It is double because for withdraws, the protocol charges execution fee for 2 interactions with GMX. THis is followed for all withdraws, so the user is charged DOUBLE the execution fee.
Tools Used
Manual Review
Recommendations
in withdraw
-> when calling handleReturn
- do not pass in false
for refundFee
Instead, pass in true
for refundFee
when calling _handleReturn