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

Inconsistent Execution Fee Refund Attribution Across Asynchronous Flows

Summary:
The protocol’s execution fee mechanism is implemented in both PerpetualVault and GmxProxy. In PerpetualVault, refund logic in functions like _mint and _handleReturn uses a global counter (e.g. depositInfo[counter]) to determine which deposit’s fee to refund. Meanwhile, GmxProxy’s refundExecutionFee function (callable only by the vault) does not cross‑reference the refunded fee with a deposit‑specific identifier. As a result, if multiple orders (and associated deposits) are processed in close succession—or if asynchronous callbacks occur out‑of‑order—the refunded execution fee may be misattributed to the wrong deposit. This misattribution violates the invariant that each depositor’s fee should match exactly the cost incurred by their order.


Where the Issue Is:

  • In PerpetualVault:
    The refund logic in functions such as _mint and _handleReturn uses the global deposit counter to access deposit information:

    if (refundFee) {
    uint256 usedFee = callbackGasLimit * tx.gasprice;
    if (depositInfo[counter].executionFee > usedFee) {
    try IGmxProxy(gmxProxy).refundExecutionFee(depositInfo[counter].owner, depositInfo[counter].executionFee - usedFee) {} catch {}
    }
    }

    This means that the fee refund is applied based solely on the latest value of counter, rather than on a deposit‑specific ID.

  • In GmxProxy:
    The refund is executed via:

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

    There is no mechanism here that binds the refunded fee to a particular order or deposit.


Root Cause:
The execution fee refund mechanism lacks a binding between the asynchronous order flows and the deposit-specific data. By using a global counter (depositInfo[counter]) to reference fee data, the vault does not ensure that the fee refund corresponds to the deposit that actually incurred the fee. This creates a risk that, if multiple orders are initiated before earlier callbacks are processed, the refund will be misattributed to the most recent deposit rather than the intended one.


Attack Path / Foundry Proof-of-Concept:

  1. Scenario Setup:

    The vault receives a deposit and initiates an order (Order1). At this point, depositInfo[counter] holds the fee information for Order1.

    Before the callback for Order1 is processed, the vault (or an attacker influencing the sequence) triggers a second deposit and order (Order2). The global counter is incremented and depositInfo[counter] now holds data for Order2.

  2. Callback Mismatch:

    Later, when the callback for Order1 is processed, the refund logic in PerpetualVault references depositInfo[counter]. However, since counter now points to Order2’s data, the refund is computed based on Order2’s execution fee rather than Order1’s.

  3. Outcome:

    • The depositor associated with Order1 either receives an incorrect fee refund or, conversely, Order2’s fee is misapplied. This misalignment disrupts the invariant that each depositor’s fee should only reflect the cost incurred for their specific order.

An attacker who can force rapid successive order submissions (or influence the timing of callbacks via gas price manipulation or network delays) can deliberately cause this misattribution, effectively reducing the effective fee cost on malicious orders or overcharging honest users.

-->This PoC clearly demonstrates that by relying on a global counter (i.e., depositInfo[counter]) to handle execution fee refunds, the protocol misattributes refunds when multiple deposits/orders are processed in rapid succession. The refund function ends up applying the refund data of the latest deposit (e.g., Depositor B) rather than that of an earlier deposit (e.g., Depositor A). This misalignment directly violates the protocol’s invariant that each depositor is only charged (or refunded) for the fee corresponding to their own order, potentially enabling fee manipulation or unintended fund transfers.

File: src/FakePerpetualVault.sol

// SPDX-License-Identifier: GPL-3.0-only
pragma solidity ^0.8.4;
/**
* @title FakePerpetualVault
* @dev A simplified version of a vault contract that simulates deposit orders and execution fee refund logic.
* It uses a global counter to store deposit data (including execution fee and owner) without binding
* each deposit to its own unique identifier in the refund logic.
*/
contract FakePerpetualVault {
struct DepositInfo {
uint256 executionFee;
address owner;
}
// Mapping to store deposit info by a global counter.
mapping(uint256 => DepositInfo) public depositInfo;
uint256 public counter;
/**
* @notice Simulates a deposit that initiates an order.
* @param fee The execution fee paid with the deposit.
* The deposit is recorded using a global counter.
*/
function deposit(uint256 fee) external {
counter++;
depositInfo[counter] = DepositInfo({
executionFee: fee,
owner: msg.sender
});
}
/**
* @notice Simulated refund logic that is meant to refund the execution fee.
* @return refundOwner The address that receives the refund.
* @return refundAmount The amount of the refund.
*
* @dev The refund logic mistakenly references depositInfo[counter] (the latest deposit),
* even though it might be processing a refund for an earlier deposit.
* Here, for demonstration, we assume a fixed used fee of 100.
*/
function refund() external view returns (address refundOwner, uint256 refundAmount) {
uint256 usedFee = 100;
DepositInfo memory info = depositInfo[counter];
if (info.executionFee > usedFee) {
refundAmount = info.executionFee - usedFee;
}
refundOwner = info.owner;
}
}

File: test/FakePerpetualVaultTest.t.sol

// SPDX-License-Identifier: GPL-3.0-only
pragma solidity ^0.8.4;
import "forge-std/Test.sol";
import "../src/FakePerpetualVault.sol";
/**
* @title FakePerpetualVaultTest
* @dev This test demonstrates the execution fee refund misattribution vulnerability.
*
* Scenario:
* 1. Depositor A (address 0xABCD) deposits with an execution fee of 1000.
* This deposit is recorded as deposit #1.
* 2. Depositor B (address 0xBEEF) deposits with an execution fee of 2000.
* This deposit is recorded as deposit #2, overwriting the global reference.
* 3. When refund() is called, it uses depositInfo[counter] (i.e. deposit #2) instead of deposit #1.
* Thus, the refund is incorrectly attributed to Depositor B.
*/
contract FakePerpetualVaultTest is Test {
FakePerpetualVault vault;
address depositorA = address(0xABCD);
address depositorB = address(0xBEEF);
function setUp() public {
vault = new FakePerpetualVault();
}
function testRefundMisattribution() public {
// Depositor A submits deposit (order #1) with an execution fee of 1000.
vm.prank(depositorA);
vault.deposit(1000);
// Depositor B submits deposit (order #2) with an execution fee of 2000.
vm.prank(depositorB);
vault.deposit(2000);
// At this point, the global counter points to deposit #2 (Depositor B).
// The refund() function will use depositInfo[counter], i.e., deposit #2.
(address refundOwner, uint256 refundAmount) = vault.refund();
// Expected refund for deposit #2: 2000 - 100 = 1900.
// This demonstrates that even if refund was intended for deposit #1,
// the global counter causes refund to be misattributed to Depositor B.
assertEq(refundOwner, depositorB, "Refund should be attributed to Depositor B");
assertEq(refundAmount, 1900, "Refund amount should be 1900");
}
}

Compile and Run Tests:
From the project root, run:

forge test --match-path test/FakePerpetualVaultTest.t.sol

The test should pass, demonstrating that the refund is misattributed due to the global counter referencing the latest deposit rather than the intended one.


Impact:

  • Fee Accounting Violation: Each depositor may not receive (or may be overcharged for) the correct execution fee, breaking the fairness of the protocol.

  • Financial Risk: Persistent misattribution of fees can lead to imbalances in the vault’s accounting, potentially allowing an attacker to extract excess funds or dilute other depositors’ positions over time.

  • State Inconsistency: The protocol’s invariant that every deposit’s execution fee is matched exactly to its incurred cost is violated, undermining the trust in vault accounting.


Recommendation:

  • Bind Refunds to Deposit IDs:
    At the time an order is initiated, store a unique deposit ID (or a mapping linking the order’s unique request key to the deposit data) rather than relying solely on a global counter. In the refund callback, verify that the deposit associated with the refund matches the deposit that incurred the fee.

  • Cross-Check Refund Data:
    Modify the refund logic in both PerpetualVault and GmxProxy so that the refund amount is cross‑checked against the recorded fee for the specific deposit. This prevents misattribution if orders overlap.

  • Enforce Sequential Flow (if feasible):
    Alternatively, ensure that a new order cannot be initiated until the previous order’s callback is fully processed, thereby eliminating the possibility of overlapping fee refund states.


Severity:
High – This vulnerability directly undermines the protocol’s core accounting principles and can lead to exploitable discrepancies in fee management, potentially resulting in financial loss.


Tools Used:
Manual.

Updates

Lead Judging Commences

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

finding_counter_invalid_during_handleReturn

Likelihood: Medium/High, when withdraw on a 1x vault. Impact: High, the fees will be distributed to the last depositor and not the withdrawer.

Support

FAQs

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

Give us feedback!