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

[H-4] Execution Fee is never Refunded during withdrawals

Description

In the protocol when ever a user calls PerpetualVault::deposit or PerpetualVault::withdraw an executionFee is transferred by the user upfront usually much more than the actual fee in order to cover the execution costs and the remaining is refunded to the user at the end of the call.

In Withdraw , _handleReturn observe this segment

if (amount > 0) {
_transferToken(depositId, amount);
}
emit Burned(depositId, depositInfo[depositId].recipient, depositInfo[depositId].shares, amount);
@> _burn(depositId);
console.log("execution Fee before Burn", depositInfo[depositId].executionFee);
if (refundFee) {
uint256 usedFee = callbackGasLimit * tx.gasprice;
if (depositInfo[depositId].executionFee > usedFee) {
try IGmxProxy(gmxProxy).refundExecutionFee(
@> depositInfo[counter].owner, depositInfo[counter].executionFee - usedFee
) {} catch {
}
}
}

The depositId of a user is burned and depositInfo[depositId] of a user is deleted before refunding the execution fee to the user i.e depositInfo[depositId].executionFee will always be 0 and the if statement will never be true , denying users their refunds or any extra upfront they sent for the transaction

Impact

Users can never Refund their Execution Fee during Withdrawals, denying the users of their rightful refunds ultimately affects the reputation and trust in the protocol.

Proof of Concept

The Poc Requires some changes to be made

  1. In the PerpetualVaultTest::depositFixture testing helper function add the following line

vm.deal(user, executionFee * tx.gasprice + 1 ether);

and also add import {console} from "forge-std/console.sol"; in the test file and PerpetualVault.sol

  1. In PerpetualVault::withdraw make the following change

- if (depositInfo[depositId].timestamp + lockTime >= block.timestamp) {
+ if (depositInfo[depositId].timestamp + lockTime > block.timestamp) {
revert Error.Locked();
}

we do this as we set lockTime to 0 before withdrawal to simulate locktimepassed and also to make a successful gmxExecute within the same block (ease of use instead of generating new oracle params )

  1. In the scenario below required a deposit and a withdrawal into the PerpetualVault i.e using the
    DEX but generating calladata for the GMX is much easier so we simulate DEX swap but actually do GMX swap (using DEX or GMX doesn't matter as long as we reach the refund logic)

    In PerpetualVault::_runSwap function segment make the following changes
    (add the "+" parts and comment out the " - " parts and read comments provided for a better understanding)

+ add statements with +
- comment out (donot remove) the statements with -
.
..
...
....
......
if (metadata.length != 1) {
revert Error.InvalidData();
}
console.log("ping 1 ");
(PROTOCOL _protocol, bytes memory data) = abi.decode(metadata[0], (PROTOCOL, bytes));
if (_protocol == PROTOCOL.DEX) {
console.log("reached this 1");
// comment out the dex swap part
- // uint256 outputAmount = _doDexSwap(data, isCollateralToIndex);
// add this gmx swap part
+ _doGmxSwap(data, isCollateralToIndex);
// update global state
if (flow == FLOW.DEPOSIT) {
// the outputAmount values from trading in gmx
// The gmx swap actually requires 2 transactions
// where as DEX required only 1 transaction , so we hard code
// Output Amount here and deal the required collateral in the test
// simulating GMX deposit but in DEX style i.e in one transaction
+ uint256 outputAmount = 5868596470015897108;
// last `depositId` equals with `counter` because another deposit is not allowed before previous deposit is completely processed
_mint(counter, outputAmount + swapProgressData.swapped, true, prices);
} else if (flow == FLOW.WITHDRAW) {
+ uint256 outputAmount = 19925273714;
//This is the actual function with the refund Fee part
_handleReturn(outputAmount + swapProgressData.swapped, false, true);
}
.....
....
...
..
.
  1. In the PerpetualVault::_handleReturn function add the following console.log statements

...
..
.
if (amount > 0) {
_transferToken(depositId, amount);
}
+ console.log("execution Fee before Burn", depositInfo[depositId].executionFee);
+ console.log(
"Fee Difference before Burn", depositInfo[depositId].executionFee - (callbackGasLimit * tx.gasprice)
);
emit Burned(depositId, depositInfo[depositId].recipient, depositInfo[depositId].shares, amount);
_burn(depositId);
+ console.log("execution Fee before Burn", depositInfo[depositId].executionFee);
if (refundFee) {
uint256 usedFee = callbackGasLimit * tx.gasprice;
+ console.log("execution Fee before If statement", depositInfo[depositId].executionFee);
+ console.log("usedFee", usedFee);
if (depositInfo[depositId].executionFee > usedFee) {
+ console.log("Refund 2 Owner: ", depositInfo[counter].owner);
+ console.log("Refund 2 : ", depositInfo[counter].executionFee - usedFee);
try IGmxProxy(gmxProxy).refundExecutionFee(
depositInfo[counter].owner, depositInfo[counter].executionFee - usedFee
) {} catch {
}
}
}

Note: Paste the Test Below in PerpetualVault.t.sol , run it with -vv option to view the logs

function testBurnRefundFee() public {
address keeper = PerpetualVault(vault).keeper();
address alice = makeAddr("alice");
depositFixture(alice, 2e10);
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory swapData = new bytes[](1);
// bytes memory paraSwapData = mockData.getParaSwapData(vault);
// swapData[0] = abi.encode(PROTOCOL.DEX, paraSwapData);
address[] memory gmxPath = new address[](1);
gmxPath[0] = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
uint256 minOutputAmount = 2e10 * prices.shortTokenPrice.min / prices.longTokenPrice.min * 95 / 100; // 5% slippage
//gmx swap but changed to dex for testing
// we use protocol.dex but transfer gmx data
swapData[0] = abi.encode(PROTOCOL.DEX, abi.encode(gmxPath, 2e10, minOutputAmount));
vm.prank(keeper);
PerpetualVault(vault).run(true, true, prices, swapData);
assertEq(PerpetualVault(vault).isLock(), true);
GmxOrderExecuted(true);
assertEq(uint8(PerpetualVault(vault).flow()), 0);
assertEq(PerpetualVault(vault).positionIsClosed(), false);
console.log("----------");
console.logBool(PerpetualVault(vault).beenLong());
console.log("----------");
(PerpetualVault.NextActionSelector selector,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector), 0);
console.log("Log Begin :", ERC20(address(PerpetualVault(vault).indexToken())).balanceOf(address(vault)));
console.log(
"Collateral Token begin :", IERC20(PerpetualVault(vault).collateralToken()).balanceOf(address(vault))
);
console.log("total shares_ Begin:", PerpetualVault(vault).totalShares());
// We deal the output amount for testing purpose
deal(address(PerpetualVault(vault).collateralToken()), address(PerpetualVault(vault)), 19925273714);
// // // // // withdraw
// //changing lock time to 0 for testing
vm.prank(PerpetualVault(vault).owner());
PerpetualVault(vault).setLockTime(0);
//
vm.startPrank(alice);
uint256[] memory depositIds = PerpetualVault(vault).getUserDeposits(alice);
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(false);
vm.deal(alice, executionFee * tx.gasprice);
PerpetualVault(vault).withdraw{value: executionFee * tx.gasprice}(alice, depositIds[0]);
(PerpetualVault.NextActionSelector selector1,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector1), 2);
assertEq(uint8(PerpetualVault(vault).flow()), 3);
vm.stopPrank();
prices = mockData.getMarketPrices();
swapData = new bytes[](1);
gmxPath = new address[](1);
gmxPath[0] = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
address indexToken = PerpetualVault(vault).indexToken();
uint256 indexTokenBalance = IERC20(indexToken).balanceOf(vault); //5880242300733750184
//uint256 indexTokenBalance = 5880242300733750184;
minOutputAmount = indexTokenBalance * prices.longTokenPrice.min / prices.shortTokenPrice.min * 95 / 100; // 5% slippage
// gmx swap but change to dex for testing
swapData[0] = abi.encode(PROTOCOL.DEX, abi.encode(gmxPath, indexTokenBalance, minOutputAmount));
vm.prank(PerpetualVault(vault).keeper());
PerpetualVault(vault).runNextAction(prices, swapData);
(PerpetualVault.NextActionSelector selector_,) = PerpetualVault(vault).nextAction();
//gmx order
GmxOrderExecuted(true);
console.log("Alice Balance:", alice.balance);
assertEq(alice.balance,0);
// comment out the burn statement in _handleReturn
// then comment out the above assert statement and uncomment
// the assert statement below to see that if burn doesn't occur
// or occurs later then the refund is actually transferred to alice
// assertEq(alice.balance,79667680000000);
}

Tools Used

Manual Review

Recommendations

call the _burn(depositId) after refunding the fee to the user

Make the following changes in PerpetualVault::_handleReturn function

...
..
.
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 {
}
}
}
+ _burn(depositId);
// update global state
delete swapProgressData;
delete flowData;
delete flow;
}

This is safe as the withdraw and runNextAction both have nonReentrant modifier but it does break the CEI(checks-effects-interactions) pattern , so if you strictly wanna follow CEI then cache the depositInfo[depositId].executionFee and depositInfo[counter].owner before the refund function , this way even if burn is called before the refund , the execution fee can be refunded using the cached memory variables .

Updates

Lead Judging Commences

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

finding_burn_depositId_before_refund

Likelihood: High, every time a user withdraw on 1x vault with paraswap Impact: Medium, fees never claimed to GMX and refund to the owner.

Support

FAQs

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

Give us feedback!