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

Fee Skimming & Rounding Error in Execution Fee & Governance Fee Calculation

Summary

The execution fee and governance fee calculations in the Perpetual Vault Protocol may introduce rounding errors and fee skimming, leading to potential losses for users.

  • The execution fee is not always refunded if excess gas is sent.

  • The governance fee rounding may cause users to pay slightly more than expected.

  • Fee skimming occurs silently, meaning users are unaware of small losses over time.


Vulnerability Details

1. Execution Fee Skimming in PerpetualVault.sol::_payExecutionFee

Fee Issue:

  • The execution fee is calculated based on estimated gas costs, but excess ETH may not be refunded.

  • Users may overpay without receiving a refund for unused execution gas.

Key Findings:

1.1 PerpetualVault.sol::_payExecutionFee() does not refund excess ETH

Code Snippet:

function _payExecutionFee(uint256 depositId, bool isDeposit) internal {
uint256 minExecutionFee = getExecutionGasLimit(isDeposit) * tx.gasprice;
if (msg.value < minExecutionFee) {
revert Error.InsufficientAmount();
}
if (msg.value > 0) {
payable(address(gmxProxy)).transfer(msg.value);
depositInfo[depositId].executionFee = msg.value;
}
}
  • This function sends the entire msg.value to gmxProxy, even if it is more than required.

  • No refund logic exists within this function.

1.2. There is a Refund Function (GmxProxy.sol::refundExecutionFee), But It’s Not Always Triggered

Code Snippet:

function refundExecutionFee(address receipient, uint256 amount) external {
require(msg.sender == perpVault, "invalid caller");
payable(receipient).transfer(amount);
}
  • Refunds are only processed when refundExecutionFee() is called.

  • The function is a callback from GMX, meaning refunds only happen when an order is canceled or fails execution.

  • If no explicit refund request is made, excess ETH remains with the protocol.

1.3. Manual Refund Call from PerpetualVault.sol::_handleReturn() and PerpetualVault.sol::_cancelFlow()

  • Function: PerpetualVault.sol::_handleReturn() (called at the end of a withdrawal).

  • Function: PerpetualVault.sol::_cancelFlow() (called if a deposit or withdrawal fails).

  • Both attempt to refund any remaining execution fee, but they use a try-catch block, meaning if the call fails, the refund never happens.

2. Rounding Errors in Governance Fee Calculation (PerpetualVault.sol::_transferToken)

Rounding Issue:

  • The governance fee is calculated using integer division, which can result in rounding errors where users are charged slightly more than intended.

Impacted Code:

function _transferToken(uint256 depositId, uint256 amount) internal {
uint256 fee;
if (amount > depositInfo[depositId].amount) {
fee = (amount - depositInfo[depositId].amount) * governanceFee / BASIS_POINTS_DIVISOR;
if (fee > 0) {
collateralToken.safeTransfer(treasury, fee);
}
}
try collateralToken.transfer(depositInfo[depositId].recipient, amount - fee) {}
catch {
collateralToken.transfer(treasury, amount - fee);
emit TokenTranferFailed(depositInfo[depositId].recipient, amount - fee);
}
}

Impact

  1. Users Lose Funds Due to Silent Fee Skimming

    • No refund mechanism exists for excess execution fees.

    • Small rounding errors result in unclaimed amounts being transferred to the treasury.

  2. Cumulative Rounding Losses Over Time

    • Since users repeatedly deposit and withdraw, small rounding errors accumulate, resulting in long-term user losses.


Tools Used

As per previous findings I used OpenAi as part of my research into the code vulnerabilities.


Recommendations

1. Ensure Proper Execution Fee Refund Mechanism:

Modify _payExecutionFee() to refund unused gas fees to the user.

function _payExecutionFee(uint256 depositId, bool isDeposit) internal {
uint256 minExecutionFee = getExecutionGasLimit(isDeposit) * tx.gasprice;
if (msg.value < minExecutionFee) {
revert Error.InsufficientAmount();
}
uint256 excessFee = msg.value - minExecutionFee;
if (excessFee > 0) {
payable(msg.sender).transfer(excessFee); // Refund excess fee
}
payable(address(gmxProxy)).transfer(minExecutionFee);
depositInfo[depositId].executionFee = minExecutionFee;
}

2. Fix Governance Fee Calculation to Reduce Rounding Errors:

Recalculate the fee calculation itself by using rounding up logic:

uint256 fee = ((amount - depositInfo[depositId].amount) * governanceFee + BASIS_POINTS_DIVISOR - 1) / BASIS_POINTS_DIVISOR;
  • Adding BASIS_POINTS_DIVISOR - 1 ensures rounding up rather than rounding down.

  • Prevents users from overpaying due to integer division.

Updates

Lead Judging Commences

n0kto Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Too generic
Assigned finding tags:

Informational or Gas

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!