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.
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.
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.
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);
}
receive() external payable {
revert("Cannot receive 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");
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);
user.deposit{value: totalExecutionFee}(vault, address(collateralToken), depositAmount);
console.log("- GmxProxy ETH balance after deposit:", address(gmxProxy).balance);
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");
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory data = new bytes[](1);
data[0] = abi.encode(3380000000000000);
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);
PerpetualVault.Action memory action;
action.selector = PerpetualVault.NextActionSelector.NO_ACTION;
action.data = bytes("");
vm.prank(owner);
PerpetualVault(vault).setVaultState(
PerpetualVault.FLOW.DEPOSIT,
depositId,
false,
bytes32(0),
true,
false,
action
);
console.log("- Vault flow state after unlock:", uint8(PerpetualVault(vault).flow()));
console.log("- Vault is locked after unlock:", PerpetualVault(vault).isLock());
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");
uint256 gmxProxyEthBalanceBefore = address(gmxProxy).balance;
console.log("- GmxProxy ETH balance before cancelFlow:", gmxProxyEthBalanceBefore);
vm.prank(keeper);
PerpetualVault(vault).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);
depositIds = PerpetualVault(vault).getUserDeposits(address(user));
console.log("- User's deposits remaining:", depositIds.length);
console.log("- Total deposit amount after cancelFlow:", PerpetualVault(vault).totalDepositAmount());
assertEq(depositIds.length, 0, "Deposit should have been removed");
assertEq(PerpetualVault(vault).totalDepositAmount(), 0, "Total deposit amount should be 0");
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");
}
}
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)