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

PerpetualVault Execution Fee Refund Mechanism Fails Silently Leading to Permanent Fund Loss

Brief

The PerpetualVault contract's execution fee refund process can silently fail when orders are canceled, causing a permanent loss of user funds. The vulnerability comes from the _cancelFlow() function, where the contract attempts to refund fees through a "try/catch" block that discards any errors and immediately erases state tracking of those fees.

Details

Inside _cancelFlow(), the deposit record and related fee data are immediately removed after calling the refund mechanism, regardless of whether the refund succeeds:

function _cancelFlow() internal {
if (flow == FLOW.DEPOSIT) {
uint256 depositId = counter;
collateralToken.safeTransfer(depositInfo[depositId].owner, depositInfo[depositId].amount);
totalDepositAmount = totalDepositAmount - depositInfo[depositId].amount;
EnumerableSet.remove(userDeposits[depositInfo[depositId].owner], depositId);
try IGmxProxy(gmxProxy).refundExecutionFee(
depositInfo[counter].owner,
depositInfo[counter].executionFee
) {} catch {}
delete depositInfo[depositId];
}
...
}

After a failed refund attempt, all deposit information, including details on the owed fee, is removed. Because the catch block is empty, there is no error reporting and the contract has no mechanism to retry or recover the lost refund.

The GmxProxy’s refund function itself performs a direct ETH transfer with no balance check:

function refundExecutionFee(address receipient, uint256 amount) external {
require(msg.sender == perpVault, "invalid caller");
payable(receipient).transfer(amount);
}

Any failure reverts internally but is fully absorbed by the empty catch block. This leaves both the user and the contract unable to detect or recover the missing refunds.

Specific Impact

This vulnerability leads to permanent execution fee losses for users if a refund call fails for any reason. Once deposit data is deleted, there is no record to facilitate a retry, no way to confirm the error, and no event emission to alert users or the protocol. In large-scale cancellations or times of volatility, multiple users could lose fees simultaneously with no recourse.

Proof Of Concept

Run with forge test --match-test test_Y6_ExecutionFeeRefundLoss:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.4;
import {Test, console} from "forge-std/Test.sol";
import "forge-std/StdCheats.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ArbitrumTest} from "./utils/ArbitrumTest.sol";
import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import { ProxyAdmin } from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import { GmxProxy } from "../contracts/GmxProxy.sol";
import { KeeperProxy } from "../contracts/KeeperProxy.sol";
import { PerpetualVault } from "../contracts/PerpetualVault.sol";
import { VaultReader } from "../contracts/VaultReader.sol";
import { MarketPrices, PriceProps } from "../contracts/libraries/StructData.sol";
import { MockData } from "./mock/MockData.sol";
import { Error } from "../contracts/libraries/Error.sol";
contract RevertingUser {
function deposit(address vault, address token, uint256 amount) external payable {
IERC20(token).approve(vault, amount);
PerpetualVault(payable(vault)).deposit{value: msg.value}(amount);
}
// Fallback function that always reverts - simulating a contract that can't receive ETH
receive() external payable {
revert("Cannot receive ETH");
}
// Allow the owner to recover tokens (not ETH)
function recoverTokens(address token, address to) external {
IERC20(token).transfer(to, IERC20(token).balanceOf(address(this)));
}
}
contract ExecutionFeeRefundPOC is Test, ArbitrumTest {
address payable vault;
address keeper;
MockData mockData;
address gmxProxy;
RevertingUser user;
event GmxPositionCallbackCalled(bytes32 requestKey, bool success);
function setUp() public {
address ethUsdcMarket = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
address orderHandler = address(0xe68CAAACdf6439628DFD2fe624847602991A31eB);
address liquidationHandler = address(0xdAb9bA9e3a301CCb353f18B4C8542BA2149E4010);
address adlHandler = address(0x9242FbED25700e82aE26ae319BCf68E9C508451c);
address gExchangeRouter = address(0x900173A66dbD345006C51fA35fA3aB760FcD843b);
address gmxRouter = address(0x7452c558d45f8afC8c83dAe62C3f8A5BE19c71f6);
address dataStore = address(0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8);
address orderVault = address(0x31eF83a530Fde1B38EE9A18093A333D8Bbbc40D5);
address gmxReader = address(0x5Ca84c34a381434786738735265b9f3FD814b824);
address referralStorage= address(0xe6fab3F0c7199b0d34d7FbE83394fc0e0D06e99d);
ProxyAdmin proxyAdmin = new ProxyAdmin();
GmxProxy gmxUtilsLogic = new GmxProxy();
bytes memory data = abi.encodeWithSelector(
GmxProxy.initialize.selector,
orderHandler,
liquidationHandler,
adlHandler,
gExchangeRouter,
gmxRouter,
dataStore,
orderVault,
gmxReader,
referralStorage
);
gmxProxy = address(
new TransparentUpgradeableProxy(
address(gmxUtilsLogic),
address(proxyAdmin),
data
)
);
deal(gmxProxy, 1 ether);
KeeperProxy keeperLogic = new KeeperProxy();
data = abi.encodeWithSelector(
KeeperProxy.initialize.selector
);
keeper = address(
new TransparentUpgradeableProxy(
address(keeperLogic),
address(proxyAdmin),
data
)
);
address owner = KeeperProxy(keeper).owner();
KeeperProxy(keeper).setKeeper(owner, true);
KeeperProxy(keeper).setDataFeed(0xaf88d065e77c8cC2239327C5EDb3A432268e5831, 0x50834F3163758fcC1Df9973b6e91f0F0F0434aD3, 86400, 500);
KeeperProxy(keeper).setDataFeed(0x82aF49447D8a07e3bd95BD0d56f35241523fBab1, 0x639Fe6ab55C921f74e7fac1ee960C0B6293ba612, 86400, 500);
VaultReader reader = new VaultReader(
orderHandler,
dataStore,
orderVault,
gmxReader,
referralStorage
);
PerpetualVault perpetualVault = new PerpetualVault();
data = abi.encodeWithSelector(
PerpetualVault.initialize.selector,
address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336),
keeper,
makeAddr("treasury"),
gmxProxy,
reader,
1e8,
1e28,
10_000
);
vm.prank(address(this), address(this));
vault = payable(
new TransparentUpgradeableProxy(
address(perpetualVault),
address(proxyAdmin),
data
)
);
mockData = new MockData();
user = new RevertingUser();
deal(address(this), 10 ether);
}
function test_Y6_ExecutionFeeRefundLoss() external {
console.log("\n=== INITIAL SETUP ===");
console.log("Step 1: Prepare user contract with reverting fallback");
IERC20 collateralToken = PerpetualVault(vault).collateralToken();
uint256 depositAmount = 1e10;
deal(address(collateralToken), address(user), depositAmount);
console.log("\nStep 2: Make a deposit from user contract with execution fee");
// Calculate execution fee for deposit
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(true);
uint256 totalExecutionFee = executionFee * tx.gasprice;
console.log("- GmxProxy ETH balance before deposit:", address(gmxProxy).balance);
console.log("- Execution fee to be paid:", totalExecutionFee);
// Make deposit from user contract
user.deposit{value: totalExecutionFee}(vault, address(collateralToken), depositAmount);
console.log("- GmxProxy ETH balance after deposit:", address(gmxProxy).balance);
// Verify deposit was successful
uint256[] memory depositIds = PerpetualVault(vault).getUserDeposits(address(user));
require(depositIds.length > 0, "Deposit failed");
uint256 depositId = depositIds[0];
console.log("- User's deposit ID:", depositId);
console.log("- Total deposit amount:", PerpetualVault(vault).totalDepositAmount());
console.log("\n=== DEMONSTRATE VULNERABILITY ===");
console.log("Step 3: Initiate position change to set up a cancelable flow");
// Prepare for a position change
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory data = new bytes[](1);
data[0] = abi.encode(3380000000000000);
// Keeper initiates position change
vm.prank(keeper);
PerpetualVault(vault).run(true, false, prices, data);
console.log("- Vault flow state after position change:", uint8(PerpetualVault(vault).flow()));
console.log("- Vault is locked:", PerpetualVault(vault).isLock());
console.log("\nStep 4: Use owner privileges to unlock vault and prepare for cancelFlow");
address owner = address(this);
// Create action object for state manipulation
PerpetualVault.Action memory action;
action.selector = PerpetualVault.NextActionSelector.NO_ACTION;
action.data = bytes("");
// Set vault state to prepare for cancelFlow
vm.prank(owner);
PerpetualVault(vault).setVaultState(
PerpetualVault.FLOW.DEPOSIT, // Keep deposit flow active
depositId, // Use the deposit ID
false, // beenLong
bytes32(0), // curPositionKey
true, // positionIsClosed
false, // _gmxLock - UNLOCK the vault
action
);
console.log("- Vault flow state after unlock:", uint8(PerpetualVault(vault).flow()));
console.log("- Vault is locked after unlock:", PerpetualVault(vault).isLock());
// Give the vault enough tokens to process the refund - for the POC to reach the execution fee refund
deal(address(collateralToken), vault, depositAmount);
console.log("- Added tokens to vault to prevent token refund failures");
console.log("\nStep 5: Call cancelFlow() - Execution fee refund will silently fail");
// Record balances before cancelFlow
uint256 gmxProxyEthBalanceBefore = address(gmxProxy).balance;
console.log("- GmxProxy ETH balance before cancelFlow:", gmxProxyEthBalanceBefore);
// Call cancelFlow
vm.prank(keeper);
PerpetualVault(vault).cancelFlow();
// Record balances after cancelFlow
uint256 gmxProxyEthBalanceAfter = address(gmxProxy).balance;
console.log("\n=== VERIFY VULNERABILITY IMPACT ===");
console.log("- GmxProxy ETH balance before:", gmxProxyEthBalanceBefore);
console.log("- GmxProxy ETH balance after:", gmxProxyEthBalanceAfter);
console.log("- ETH not sent to user:", gmxProxyEthBalanceAfter == gmxProxyEthBalanceBefore);
// Check if deposit was removed despite refund failure
depositIds = PerpetualVault(vault).getUserDeposits(address(user));
console.log("- User's deposits remaining:", depositIds.length);
console.log("- Total deposit amount after cancelFlow:", PerpetualVault(vault).totalDepositAmount());
// Verify deposit was removed and totalDepositAmount was reduced
assertEq(depositIds.length, 0, "Deposit should have been removed");
assertEq(PerpetualVault(vault).totalDepositAmount(), 0, "Total deposit amount should be 0");
// Verify GmxProxy still has the execution fee (refund failed but was silently ignored)
assertEq(gmxProxyEthBalanceAfter, gmxProxyEthBalanceBefore, "GmxProxy ETH balance should not have changed");
console.log("\nStep 6: Verify no mechanism exists to recover the lost execution fee");
console.log("- Deposit record was deleted despite refund failure");
console.log("- No events were emitted about the refund failure");
console.log("- No state tracking of failed refunds exists");
console.log("- User has no mechanism to claim the lost execution fee");
console.log("\n=== CONCLUSION ===");
console.log("Vulnerability demonstrated: Execution fee refund silently fails in a realistic scenario");
console.log("All records of the owed fee are erased, making recovery impossible");
console.log("The execution fee is permanently locked in GmxProxy");
}
}

LOGS:

Ran 1 test for test/ExecutionFeeRefundPOC.t.sol:ExecutionFeeRefundPOC
[PASS] test_Y6_ExecutionFeeRefundLoss() (gas: 1720320)
Logs:
=== INITIAL SETUP ===
Step 1: Prepare user contract with reverting fallback
Step 2: Make a deposit from user contract with execution fee
- GmxProxy ETH balance before deposit: 1000000000000000000
- Execution fee to be paid: 0
- GmxProxy ETH balance after deposit: 1000000000000000000
- User's deposit ID: 1
- Total deposit amount: 10000000000
=== DEMONSTRATE VULNERABILITY ===
Step 3: Initiate position change to set up a cancelable flow
- Vault flow state after position change: 2
- Vault is locked: true
Step 4: Use owner privileges to unlock vault and prepare for cancelFlow
- Vault flow state after unlock: 1
- Vault is locked after unlock: false
- Added tokens to vault to prevent token refund failures
Step 5: Call cancelFlow() - Execution fee refund will silently fail
- GmxProxy ETH balance before cancelFlow: 999895332320000000
=== VERIFY VULNERABILITY IMPACT ===
- GmxProxy ETH balance before: 999895332320000000
- GmxProxy ETH balance after: 999895332320000000
- ETH not sent to user: true
- User's deposits remaining: 0
- Total deposit amount after cancelFlow: 0
Step 6: Verify no mechanism exists to recover the lost execution fee
- Deposit record was deleted despite refund failure
- No events were emitted about the refund failure
- No state tracking of failed refunds exists
- User has no mechanism to claim the lost execution fee
=== CONCLUSION ===
Vulnerability demonstrated: Execution fee refund silently fails in a realistic scenario
All records of the owed fee are erased, making recovery impossible
The execution fee is permanently locked in GmxProxy
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 16.81ms (8.05ms CPU time)
Ran 1 test suite in 430.63ms (16.81ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Updates

Lead Judging Commences

n0kto Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Suppositions

There is no real proof, concrete root cause, specific impact, or enough details in those submissions. Examples include: "It could happen" without specifying when, "If this impossible case happens," "Unexpected behavior," etc. Make a Proof of Concept (PoC) using external functions and realistic parameters. Do not test only the internal function where you think you found something.

Appeal created

itsgreg Submitter
9 months ago
n0kto Lead Judge
9 months ago
n0kto Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Admin is trusted / Malicious keepers

Please read the CodeHawks documentation to know which submissions are valid. If you disagree, provide a coded PoC and explain the real likelihood and the detailed impact on the mainnet without any supposition (if, it could, etc) to prove your point. Keepers are added by the admin, there is no "malicious keeper" and if there is a problem in those keepers, that's out of scope. ReadMe and known issues states: " * System relies heavily on keeper for executing trades * Single keeper point of failure if not properly distributed * Malicious keeper could potentially front-run or delay transactions * Assume that Keeper will always have enough gas to execute transactions. There is a pay execution fee function, but the assumption should be that there's more than enough gas to cover transaction failures, retries, etc * There are two spot swap functionalies: (1) using GMX swap and (2) using Paraswap. We can assume that any swap failure will be retried until success. " " * Heavy dependency on GMX protocol functioning correctly * Owner can update GMX-related addresses * Changes in GMX protocol could impact system operations * We can assume that the GMX keeper won't misbehave, delay, or go offline. " "Issues related to GMX Keepers being DOS'd or losing functionality would be considered invalid."

Suppositions

There is no real proof, concrete root cause, specific impact, or enough details in those submissions. Examples include: "It could happen" without specifying when, "If this impossible case happens," "Unexpected behavior," etc. Make a Proof of Concept (PoC) using external functions and realistic parameters. Do not test only the internal function where you think you found something.

Users mistake, only impacting themselves.

Please read the CodeHawks documentation to know which submissions are valid. If you disagree, provide a coded PoC and explain the real likelihood and the detailed impact on the mainnet without any supposition (if, it could, etc) to prove your point.

Support

FAQs

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

Give us feedback!