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
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
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
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;
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
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()
// );
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;
Later:
uint256 executionFee;
(
executionFee,
cache.executionFeeDiff
) =
(
params.numbers.executionFee,
0
);
order.setExecutionFee(
executionFee
);
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()
// );
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()
// );
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()
// );
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
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()
// );
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;
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;
}
Source: StrictBank.sol
Suppose the first request adds one execution fee:
Vault actual WNT
old balance + fee
StrictBank tokenBalances
old balance + fee
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
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
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
);
and then computes the remainder:
cache.refundFeeAmount =
executionFee
-
executionFeeForKeeper;
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
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
The test execution fee was:
1000000000000000 wei
0.001 WNT
All five mandatory paths passed.
The exact test run finished with:
✔ locks (614607ms)
1 passing (10m)
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
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
Instead, the affected flows:
Accept the WNT
Validate the WNT
Store executionFee
Execute successfully
Delete the request
Leave the WNT in the vault
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
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
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
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
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
The broader audit lesson
Request-based protocols have two lifecycles that need to be reviewed independently.
The first is the action:
Create
↓
Execute
↓
Complete
The second is every asset attached to that action:
Fund
↓
Account
↓
Store
↓
Settle
↓
Release
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)