DEV Community

Daniel
Daniel

Posted on

How Successful 0xMarkets Requests Could Permanently Orphan Users’ WNT Execution Fees

A successful request is usually the path auditors worry about the least.

The user submits the request, a keeper executes it, the expected protocol action completes, a success event is emitted, and the request disappears from storage. From the outside, everything looks finished.

In 0xMarkets, that assumption was wrong for several request types.

A user could supply a nonzero WNT execution fee through the normal production flow. The protocol accepted and recorded that fee, stored it in the request, executed the request successfully, and removed the request from storage.

But the success path never settled the fee.

The keeper received no WNT execution payment.

The user received no refund of the original fee.

The WNT remained inside the request vault and remained included in the vault’s StrictBank accounting baseline.

A later request then recorded only newly transferred WNT, so the old fee was not rediscovered through the normal request lifecycle.

I reported this finding as High through the 0xMarkets program on HackenProof.

It earned $0.24.

The reward was small, but the underlying lifecycle failure affected five distinct production request paths.

The proof reproduced the same behavior across five successful production paths:

Withdrawal

Order

GLV Deposit

GLV Withdrawal

Shift
Enter fullscreen mode Exit fullscreen mode

None of those requests failed.

None depended on cancellation.

None depended on a frozen order.

Every mandatory request executed successfully.

That is what made the bug interesting: the requested action completed, while value associated with that request did not.

The execution fee has its own lifecycle

0xMarkets request flows can carry a WNT execution fee intended to support keeper execution.

A simplified expected lifecycle is:

User supplies WNT execution fee
        ↓
Request vault records the transfer
        ↓
Request stores executionFee
        ↓
Keeper executes the request
        ↓
GasUtils.payExecutionFee
        ↓
Keeper receives the execution payment
        ↓
Any remaining amount is refunded
Enter fullscreen mode Exit fullscreen mode

The affected success paths instead behaved like this:

User supplies WNT execution fee
        ↓
Request vault records the transfer
        ↓
Request stores executionFee
        ↓
Keeper executes the request successfully
        ↓
Request is removed
        ↓
Execution fee payout is skipped
        ↓
WNT remains in the request vault
Enter fullscreen mode Exit fullscreen mode

The protocol finished the request lifecycle.

It did not finish the fee lifecycle.

Withdrawal shows the mismatch clearly

WithdrawalUtils.createWithdrawal records incoming WNT through the real WithdrawalVault:

address wnt = TokenUtils.wnt(dataStore);
uint256 wntAmount =
    withdrawalVault.recordTransferIn(
        wnt
    );

if (
    wntAmount
        <
    params.executionFee
) {
    revert Errors.InsufficientWntAmount(
        wntAmount,
        params.executionFee
    );
}

params.executionFee =
    wntAmount;
Enter fullscreen mode Exit fullscreen mode

Source: WithdrawalUtils.sol

The important point is that this is not a stray token accidentally sent to a vault.

The protocol:

Receives the WNT

Records the WNT

Checks the amount against executionFee

Stores the resulting value in the request
Enter fullscreen mode Exit fullscreen mode

The problem appears after successful execution.

ExecuteWithdrawalUtils.executeWithdrawal removes the request, performs the withdrawal, emits WithdrawalExecuted, executes the callback, calculates the oracle price count, and then reaches a disabled payout:

// ! EXECUTION FEE EXEMPTION
// GasUtils.payExecutionFee(
//     params.dataStore,
//     params.eventEmitter,
//     params.withdrawalVault,
//     params.key,
//     withdrawal.callbackContract(),
//     withdrawal.executionFee(),
//     params.startingGas,
//     cache.oraclePriceCount,
//     params.keeper,
//     withdrawal.receiver()
// );
Enter fullscreen mode Exit fullscreen mode

Source: ExecuteWithdrawalUtils.sol

The request succeeds.

The request is gone.

The fee remains.

Orders had the same lifecycle break

Normal user orders could also store a nonzero execution fee.

OrderUtils.createOrder records WNT and stores the resulting amount:

uint256 wntAmount =
    orderVault.recordTransferIn(
        cache.wnt
    );

if (
    wntAmount
        <
    params.numbers.executionFee
) {
    revert Errors.InsufficientWntAmountForExecutionFee(
        wntAmount,
        params.numbers.executionFee
    );
}

params.numbers.executionFee =
    wntAmount;
Enter fullscreen mode Exit fullscreen mode

Later:

uint256 executionFee;

(
    executionFee,
    cache.executionFeeDiff
) =
    (
        params.numbers.executionFee,
        0
    );

order.setExecutionFee(
    executionFee
);
Enter fullscreen mode Exit fullscreen mode

Source: OrderUtils.sol

The success path then skips the production payout routine:

// ! EXECUTION FEE EXEMPTION
// GasUtils.payExecutionFee(
//     params.contracts.dataStore,
//     params.contracts.eventEmitter,
//     params.contracts.orderVault,
//     params.key,
//     params.order.callbackContract(),
//     params.order.executionFee(),
//     params.startingGas,
//     GasUtils.estimateOrderOraclePriceCount(
//         params.order.swapPath().length
//     ),
//     params.keeper,
//     params.order.receiver()
// );
Enter fullscreen mode Exit fullscreen mode

Source: ExecuteOrderUtils.sol

The code comments correctly note that liquidation and ADL orders can have zero execution fees.

That does not resolve normal user orders that actually stored a nonzero fee before successful execution.

Both GLV directions were affected

The same pattern existed in GLV request processing.

GLV Deposit

GlvDepositUtils.createGlvDeposit either separates WNT from the deposited token amount or records a separate WNT transfer and stores the result as the request’s executionFee.

After successful execution, the payout call is disabled:

// ! EXECUTION FEE EXEMPTION
// GasUtils.payExecutionFee(
//     params.dataStore,
//     params.eventEmitter,
//     params.glvVault,
//     params.key,
//     glvDeposit.callbackContract(),
//     glvDeposit.executionFee(),
//     params.startingGas,
//     cache.oraclePriceCount,
//     params.keeper,
//     glvDeposit.receiver()
// );
Enter fullscreen mode Exit fullscreen mode

Source: GlvDepositUtils.sol

GLV Withdrawal

GlvWithdrawalUtils.createGlvWithdrawal records WNT in GlvVault and stores that amount as executionFee.

Its successful execution path reaches the same disabled settlement pattern:

// ! EXECUTION FEE EXEMPTION
// GasUtils.payExecutionFee(
//     params.dataStore,
//     params.eventEmitter,
//     params.glvVault,
//     params.key,
//     glvWithdrawal.callbackContract(),
//     glvWithdrawal.executionFee(),
//     params.startingGas,
//     cache.oraclePriceCount,
//     params.keeper,
//     glvWithdrawal.receiver()
// );
Enter fullscreen mode Exit fullscreen mode

Source: GlvWithdrawalUtils.sol

Different request direction.

Same orphaned fee.

Shift makes the lifecycle problem especially visible

Shift performs an internal withdrawal followed by an internal deposit.

Those internal requests deliberately use:

executionFee
0
Enter fullscreen mode Exit fullscreen mode

because the outer Shift request carries the user-funded fee.

That is internally consistent.

The problem is what happens when the Shift finishes.

After the internal withdrawal and deposit complete, ShiftExecuted is emitted and the callback runs.

The outer fee settlement is then disabled:

// ! EXECUTION FEE EXEMPTION
// GasUtils.payExecutionFee(
//     params.dataStore,
//     params.eventEmitter,
//     params.shiftVault,
//     params.key,
//     shift.callbackContract(),
//     shift.executionFee(),
//     params.startingGas,
//     GasUtils.estimateShiftOraclePriceCount(),
//     params.keeper,
//     shift.receiver()
// );
Enter fullscreen mode Exit fullscreen mode

Source: ShiftUtils.sol

The entire Shift can therefore succeed while its original nonzero WNT execution fee remains in ShiftVault.

StrictBank is why the old fee does not become the next fee

The strongest part of the proof was not simply showing WNT sitting in the vault.

It showed why a later request does not naturally recover it.

Each request vault inherits StrictBank, which keeps an accounting baseline:

mapping(
    address =>
    uint256
) public tokenBalances;
Enter fullscreen mode Exit fullscreen mode

recordTransferIn calculates only the balance increase since the previous accounting point:

function _recordTransferIn(
    address token
)
    internal
    returns (
        uint256
    )
{
    uint256 prevBalance =
        tokenBalances[
            token
        ];

    uint256 nextBalance =
        IERC20(
            token
        ).balanceOf(
            address(
                this
            )
        );

    tokenBalances[
        token
    ] =
        nextBalance;

    return
        nextBalance
            -
        prevBalance;
}
Enter fullscreen mode Exit fullscreen mode

Source: StrictBank.sol

Suppose the first request adds one execution fee:

Vault actual WNT
old balance + fee

StrictBank tokenBalances
old balance + fee
Enter fullscreen mode Exit fullscreen mode

The request then succeeds without paying or refunding that fee.

Nothing removes it from either the real vault balance or the accounting baseline.

When another request transfers new WNT:

prevBalance
already includes old fee

nextBalance
old fee + newly transferred fee

recordTransferIn
new fee only
Enter fullscreen mode Exit fullscreen mode

The old fee is not picked up again.

The PoC explicitly created a later request for every affected path and verified that only newly transferred WNT was recorded.

That is why the issue is stronger than “the vault has leftover tokens.”

The original execution fee becomes detached from the request that owned it and invisible to later request accounting.

What “permanent” means in this report

The word permanent should be read precisely.

The PoC proves that the fee is permanently lost from the normal request lifecycle:

Original request
Removed

Keeper settlement
Absent

User refund
Absent

Old fee discovered by later request
No

Normal request-based recovery
Gone
Enter fullscreen mode Exit fullscreen mode

The proof does not establish that no privileged migration, contract upgrade, or extraordinary administrative recovery could ever move the WNT.

That is not required for the demonstrated bug.

The security failure is that after a successful request is removed, the protocol leaves no normal request state through which that user’s fee is settled or recovered.

The production settlement routine already existed

GasUtils.payExecutionFee contains the expected settlement logic.

It calculates the keeper portion:

uint256 executionFeeForKeeper =
    adjustGasUsage(
        dataStore,
        gasUsed,
        oraclePriceCount
    )
        *
    tx.gasprice;

if (
    executionFeeForKeeper
        >
    executionFee
) {
    executionFeeForKeeper =
        executionFee;
}

bank.transferOutNativeToken(
    keeper,
    executionFeeForKeeper
);
Enter fullscreen mode Exit fullscreen mode

and then computes the remainder:

cache.refundFeeAmount =
    executionFee
        -
    executionFeeForKeeper;
Enter fullscreen mode Exit fullscreen mode

Source: GasUtils.sol

This matters because the report is not inventing a hypothetical fee destination.

The protocol already has a production routine designed to:

Pay the keeper

Refund the remainder
Enter fullscreen mode Exit fullscreen mode

Cancellation paths use fee settlement behavior.

The affected successful paths do not.

The PoC used real routers, handlers, vaults, and accounting

The proof was a single TypeScript test built on the repository’s production deployment fixture.

It exercised these complete paths:

Path Creation entrypoint Execution entrypoint Vault
Withdrawal ExchangeRouter.createWithdrawal WithdrawalHandler.executeWithdrawal WithdrawalVault
Order ExchangeRouter.createOrder OrderHandler.executeOrder OrderVault
GLV Deposit GlvRouter.createGlvDeposit GlvHandler.executeGlvDeposit GlvVault
GLV Withdrawal GlvRouter.createGlvWithdrawal GlvHandler.executeGlvWithdrawal GlvVault
Shift ExchangeRouter.createShift ShiftHandler.executeShift ShiftVault

It did not depend on:

Mock affected contracts

Custom harnesses

Direct storage writes

Direct vault accounting mutation

Failed requests

Cancellation

Frozen orders

Mainnet

Testnet

Public RPC
Enter fullscreen mode Exit fullscreen mode

The test execution fee was:

1000000000000000 wei
0.001 WNT
Enter fullscreen mode Exit fullscreen mode

All five mandatory paths passed.

The exact test run finished with:

✔ locks (614607ms)

1 passing (10m)
Enter fullscreen mode Exit fullscreen mode

What was verified after each successful execution

A commented-out function alone would not prove impact.

For every path, the PoC followed the state before and after execution.

It verified:

Nonzero executionFee stored before success

Request created through the production router

Production handler execution succeeded

Expected success event emitted

Cancel event absent

Freeze event absent where applicable

Request removed after success

KeeperExecutionFee event absent

ExecutionFeeRefund event absent

ExecutionFeeRefundCallback event absent

Keeper WNT delta zero

Original fee remained in the request vault

Original fee remained in StrictBank accounting

Later request registered only newly transferred WNT
Enter fullscreen mode Exit fullscreen mode

There is one important measurement nuance.

Withdrawal-like operations may legitimately send WNT to the receiver as protocol output.

For that reason, the proof did not identify the missing execution-fee refund merely by looking at the receiver’s raw WNT delta.

It relied on the absence of the production refund events together with the fact that the original fee remained in the vault and in StrictBank accounting.

That prevents normal withdrawal output from being mistaken for a fee refund.

Why this is not merely an unpaid keeper

The keeper receiving zero WNT is one symptom.

The user-funded value is the more important part.

If successful execution was intentionally supposed to be fee exempt, the protocol had safe design options:

Do not accept a nonzero fee

or

Return the supplied fee

or

Settle it explicitly under another documented policy
Enter fullscreen mode Exit fullscreen mode

Instead, the affected flows:

Accept the WNT

Validate the WNT

Store executionFee

Execute successfully

Delete the request

Leave the WNT in the vault
Enter fullscreen mode Exit fullscreen mode

A fee exemption can explain why the keeper is not paid.

It does not explain why user-supplied WNT should become detached from the request that supplied it.

Why I reported it as High

The submitted report mapped the issue to three impact categories:

Theft of gas or execution fee

Permanent freezing of funds

Functional correctness failure
Enter fullscreen mode Exit fullscreen mode

The affected value is not the user’s principal position or LP pool principal.

The proof also did not demonstrate:

Protocol insolvency

Protocol-wide unrecoverable loss

Direct theft of market principal

Attacker-controlled extraction of the locked WNT
Enter fullscreen mode Exit fullscreen mode

Those limits are important.

But the affected WNT is still user-supplied value accepted by production request flows.

The protocol stores it specifically as an execution fee and then loses the normal settlement path after successful request deletion.

The same root cause reproduced across five distinct request classes.

That is why I submitted the finding as High.

The HackenProof reward allocated to my report was $0.24.

The reward amount reflects contest payout mechanics. It does not alter what the proof demonstrated.

Why this is separate from other 0xMarkets fee findings

Several 0xMarkets findings involved execution fees, but the lifecycle and root cause here are different.

Frozen order execution fee lock

That issue occurs in the freeze path.

This report requires successful execution and does not use freezeOrder.

Callback fee-cap behavior

Those findings concern oversized execution fees and callback-based recovery behavior.

This report uses normal nonzero execution fees that remain in the vault after success.

Pyth Lazer provider drain

That finding concerns ETH held by the oracle provider and permissionless verification calls.

This report concerns user-supplied WNT stored in request vaults.

Cartha fee routing

The Cartha integration issue sends WNT to the wrong destination before 0xMarkets can record the request fee.

Here, WNT reaches the correct 0xMarkets vault and is correctly recorded.

The failure happens after successful execution.

Fix the lifecycle, not just the symptom

The safest correction is to make every success path that accepts a nonzero execution fee settle that fee before returning.

The commented GasUtils.payExecutionFee calls show the intended settlement structure and already contain the required request-specific parameters.

However, the fix should be applied consistently with the protocol’s intended fee policy.

For example, order creation also contains disabled execution-fee validation and capping logic. A production patch should review creation and settlement together rather than blindly uncommenting one line in isolation.

The invariant should be:

A successful request that accepts a nonzero execution fee must not leave that fee orphaned from the request lifecycle.

If successful execution is intentionally subsidized, creation should reject unnecessary nonzero fees or return them explicitly.

What should never be possible is:

Accept fee
Store fee
Execute successfully
Delete request
Leave fee without a normal owner-specific settlement path
Enter fullscreen mode Exit fullscreen mode

Regression tests

A complete correction should cover all five affected request families:

Withdrawal success
Fee settled
No orphaned WNT

Order success
Fee settled
No orphaned WNT

GLV Deposit success
Fee settled
No orphaned WNT

GLV Withdrawal success
Fee settled
No orphaned WNT

Shift success
Fee settled
No orphaned WNT
Enter fullscreen mode Exit fullscreen mode

The suite should also verify:

Later requests record only their own incoming fee

StrictBank accounting matches the intended post-settlement balance

Cancellation behavior remains correct

Liquidation and ADL zero-fee behavior remains correct

Zero-fee requests remain valid where intended

Receiver protocol output is not mistaken for execution-fee refund
Enter fullscreen mode Exit fullscreen mode

The broader audit lesson

Request-based protocols have two lifecycles that need to be reviewed independently.

The first is the action:

Create
↓
Execute
↓
Complete
Enter fullscreen mode Exit fullscreen mode

The second is every asset attached to that action:

Fund
↓
Account
↓
Store
↓
Settle
↓
Release
Enter fullscreen mode Exit fullscreen mode

A success event only proves the first lifecycle completed.

It says nothing about whether every associated balance was settled correctly.

This finding existed precisely in that gap.

The request succeeded.

The fee did not.

Conclusion

0xMarkets accepted nonzero WNT execution fees in successful Withdrawal, Order, GLV Deposit, GLV Withdrawal, and Shift flows.

The protocol recorded the fee and stored it in each request.

The production handler then executed the requested operation successfully and removed the request.

But the corresponding execution-fee payout was disabled.

The keeper received no execution-fee WNT.

No production refund was emitted for the original fee.

The WNT remained in the request vault and remained part of StrictBank accounting.

Later requests recorded only newly transferred WNT, so the old fee did not return through subsequent request processing.

The proof reproduced this behavior across all five mandatory paths using real routers, handlers, vaults, and accounting. It required no affected-contract mocks, custom harnesses, direct storage writes, failed requests, cancellation, freeze path, live RPC, mainnet, or testnet.

I reported the finding as High through HackenProof and received $0.24.

The engineering invariant is simple:

Successful execution must settle every piece of value that the request accepted before the request is deleted.

A success event should never be the moment a user-funded fee becomes orphaned from the protocol’s normal lifecycle.

Top comments (0)