Smart contract integrations often fail at the boundary between two systems.
Each side may behave correctly on its own, yet a single wrong receiver address can make the combined flow unusable.
That is what happened in the CarthaVault integration with 0xMarkets.
CarthaVault exposed payable deploy and recall functions and forwarded msg.value as an execution fee. The underlying asset and GM tokens were sent to the correct 0xMarkets request vaults, but the WNT execution fee was sent somewhere else: back to CarthaVault itself.
0xMarkets did not look for that WNT inside CarthaVault. Deposit creation expected it in DepositVault, while withdrawal creation expected it in WithdrawalVault.
As a result, both Cartha integration paths reverted before request creation whenever they were called with a nonzero execution fee.
The finding was validated as Medium. It was reported by 23 researchers, so the shared reward was $0.92.

The intended integration flow
CarthaVault uses two keeper-controlled entry points to interact with 0xMarkets:
deployToPool, which sends vault assets into a market depositrecallFromPool, which sends GM tokens into a market withdrawal
Both functions are payable.
That matters because the native value supplied by the keeper is forwarded as the request execution fee.
The deploy path passes msg.value directly into PoolDeployLib.deploy.
Source: CarthaVault.sol
function deployToPool(
uint256 amount,
uint256 minMarketTokens
)
external
payable
onlyRole(Roles.KEEPER_BOT)
whenNotPaused
returns (bytes32 depositKey)
{
CarthaVaultStorage storage $ =
_getCarthaVaultStorage();
depositKey = PoolDeployLib.deploy(
$.asset,
$.exchangeRouter,
$.marketToken,
$.depositVault,
amount,
minMarketTokens,
msg.value
);
}
The recall path does the same through PoolDeployLib.recall.
Source: CarthaVault.sol
function recallFromPool(
uint256 gmAmount,
uint256 minShortTokenAmount
)
external
payable
onlyRole(Roles.KEEPER_BOT)
whenNotPaused
returns (bytes32 withdrawalKey)
{
CarthaVaultStorage storage $ =
_getCarthaVaultStorage();
withdrawalKey = PoolDeployLib.recall(
$.exchangeRouter,
$.marketToken,
$.withdrawalVault,
gmAmount,
minShortTokenAmount,
msg.value
);
}
The contract interface therefore clearly intends to support a nonzero execution fee.
The problem appears one layer deeper.
The deploy path sends the fee to the wrong contract
Inside PoolDeployLib.deploy, the underlying asset is routed correctly to the configured depositVault.
The WNT fee is not.
Source: PoolDeployLib.sol
IExchangeRouter(router).sendTokens(
asset,
depositVault,
amount
);
if (executionFee > 0) {
IExchangeRouter(router)
.sendWnt{value: executionFee}(
address(this),
executionFee
);
}
At first glance, address(this) may look like a convenient way to identify the current request owner.
That assumption is wrong in this context.
The library executes from the CarthaVault context. Therefore, address(this) resolves to the CarthaVault contract.
The resulting token destinations are:
Asset
→ DepositVault
WNT execution fee
→ CarthaVault
But 0xMarkets expects both the request assets and the execution fee to be available through the deposit request lifecycle.
For the non-WNT asset flow demonstrated by the proof, 0xMarkets explicitly reads WNT from DepositVault.
Source: DepositUtils.sol
uint256 wntAmount =
depositVault.recordTransferIn(wnt);
if (wntAmount < params.executionFee) {
revert Errors
.InsufficientWntAmountForExecutionFee(
wntAmount,
params.executionFee
);
}
Because Cartha sent the WNT to itself, DepositVault records zero.
The request declares a nonzero fee, but the vault responsible for funding that fee has none.
The creation call reverts.
The recall path repeats the same mistake
The withdrawal flow has the same receiver mismatch.
PoolDeployLib.recall sends GM tokens to withdrawalVault, then sends WNT to address(this).
Source: PoolDeployLib.sol
IExchangeRouter(router).sendTokens(
market,
withdrawalVault,
gmAmount
);
if (executionFee > 0) {
IExchangeRouter(router)
.sendWnt{value: executionFee}(
address(this),
executionFee
);
}
Again, address(this) is CarthaVault.
The resulting destinations are:
GM tokens
→ WithdrawalVault
WNT execution fee
→ CarthaVault
0xMarkets withdrawal creation does not inspect CarthaVault for the fee. It records WNT entering WithdrawalVault.
Source: WithdrawalUtils.sol
uint256 wntAmount =
withdrawalVault.recordTransferIn(wnt);
if (wntAmount < params.executionFee) {
revert Errors.InsufficientWntAmount(
wntAmount,
params.executionFee
);
}
The observed amount is zero, so the recall request also reverts before creation.
Why the router does not correct the receiver
The 0xMarkets helper behaves exactly as instructed.
Its sendWnt function wraps the native value and transfers WNT to the receiver chosen by the caller.
Source: BaseRouter.sol
function sendWnt(
address receiver,
uint256 amount
)
external
payable
nonReentrant
{
AccountUtils.validateReceiver(receiver);
TokenUtils
.depositAndSendWrappedNativeToken(
dataStore,
receiver,
amount
);
}
The router does not infer whether a deposit or withdrawal request is being created.
It does not replace the supplied receiver with DepositVault or WithdrawalVault.
This means the integration is responsible for selecting the correct destination.
Cartha selected itself.
Complete failing deploy flow
The deploy failure can be reduced to six steps:
1. A keeper calls deployToPool with msg.value greater than zero
2. CarthaVault forwards msg.value as executionFee
3. PoolDeployLib sends the asset to DepositVault
4. PoolDeployLib sends WNT to CarthaVault
5. 0xMarkets reads WNT from DepositVault
6. DepositVault reports zero WNT and request creation reverts
The observed custom error was:
InsufficientWntAmountForExecutionFee(
0,
executionFee
)
No deposit request was created.
Complete failing recall flow
The recall path follows the same pattern:
1. A keeper calls recallFromPool with msg.value greater than zero
2. CarthaVault forwards msg.value as executionFee
3. PoolDeployLib sends GM to WithdrawalVault
4. PoolDeployLib sends WNT to CarthaVault
5. 0xMarkets reads WNT from WithdrawalVault
6. WithdrawalVault reports zero WNT and request creation reverts
The observed error was:
InsufficientWntAmount(
0,
executionFee
)
No withdrawal request was created.
How the proof isolated the receiver mismatch
A simple revert test would not have been enough.
The integration contains many moving parts:
CarthaVault configuration
Keeper permissions
Asset balances
Token approvals
0xMarkets router configuration
Deposit and withdrawal handlers
Oracle parameters
Market token state
Any one of those could cause a request to fail.
The proof therefore used three kinds of runs.
Nonzero fee failure
The main test called both Cartha paths with a nonzero fee.
The deploy request reverted because DepositVault recorded zero WNT.
The recall request reverted because WithdrawalVault recorded zero WNT.
In both cases, no request was created.
Zero fee controls
The same integration was then exercised with an execution fee of zero.
The deploy request was successfully created and executed, minting real GM tokens to CarthaVault.
The recall request was also successfully created and executed, burning the GM tokens and returning the underlying asset.
These controls proved that the market, token balances, approvals, handlers, router, and oracle setup were valid.
The difference between success and failure was the nonzero fee path.
Diagnostic prefunding controls
The strongest confirmation came from prefunding the correct request vault.
Before the deploy call, the proof sent WNT directly to DepositVault using the real 0xMarkets transfer helper.
The Cartha deploy request could then be created.
At the same time, the WNT supplied through Cartha's own msg.value still appeared inside CarthaVault.
The recall diagnostic repeated the process with WithdrawalVault and produced the same result.
This established both sides of the mismatch:
0xMarkets reads the fee from the request vault
Cartha sends its fee to CarthaVault
The proof used the real CarthaVault implementation, real 0xMarkets router, real request vaults, real handlers, and real market token.
It did not depend on a mock of the affected components, a custom harness, direct storage modification, or a live RPC.
The final result was:
1 passing
Why this was a Medium finding
The impact was real but bounded.
Both payable integration paths advertised support for nonzero execution fees, yet neither could create a request in that mode.
That affects functional correctness in both directions:
deployToPool with a nonzero fee
→ unusable
recallFromPool with a nonzero fee
→ unusable
The issue was not classified as Critical because the failing transactions reverted atomically.
The proof did not demonstrate:
Theft of user funds
Insolvency
Permanent asset loss
A permanently frozen vault
High was also not supported by the demonstrated behavior because the zero fee controls remained operational, and the proof did not establish that a nonzero execution fee was mandatory in every real deployment condition.
Medium captured the actual impact:
A supported execution mode in both directions of the integration was broken because the fee was routed to the wrong contract.
Why the revert being atomic does not make the bug cosmetic
Atomicity prevents partial state from being committed, which is good.
It does not make a broken integration path correct.
The functions are payable, accept a nonzero execution fee, and pass that fee into request creation. A keeper following the intended interface receives a revert because the implementation funds the wrong vault.
This is not a display issue or an incorrect event.
The requested operation cannot be created.
Because the transaction reverts atomically, the failed main path does not leave the asset, GM, or WNT partially transferred. The bug is the unusable request mode, not trapped funds from the reverted call.
The problem affects both asset deployment and asset recall, which are core parts of the Cartha and 0xMarkets integration.
The correction
The fix is small because the root cause is precise.
For a deploy request, WNT must be sent to depositVault:
if (executionFee > 0) {
IExchangeRouter(router)
.sendWnt{value: executionFee}(
- address(this),
+ depositVault,
executionFee
);
}
For a recall request, WNT must be sent to withdrawalVault:
if (executionFee > 0) {
IExchangeRouter(router)
.sendWnt{value: executionFee}(
- address(this),
+ withdrawalVault,
executionFee
);
}
After that change, the request vault whose incoming WNT is measured by recordTransferIn receives the fee before request creation.
The declared execution fee and the recorded fee balance are aligned.
Broader audit lessons
Follow every asset independently
A request may involve several token movements with different destinations.
In this case:
Deposit asset
GM token
WNT execution fee
Reviewing only the primary asset transfer would miss the bug.
Each token must be traced from the payer to the exact contract that later accounts for it.
address(this) is context dependent
Inside libraries and proxy-based systems, address(this) may not refer to the component a developer has in mind.
The important question is not where the source code is located.
The important question is which contract context executes it.
Match funding locations with accounting locations
The integration sent WNT to one address while the downstream protocol measured it at another.
A useful review invariant is:
The contract that records incoming value must be the contract that receives that value.
Positive controls are as important as failing tests
The zero fee controls showed that the system could perform real deposit and withdrawal operations.
Without those controls, the revert could have been dismissed as a configuration problem.
Diagnostic prefunding can prove an exact receiver bug
Prefunding the expected receiver transformed the same failing call into a successful request.
That is stronger than observing a revert alone because it identifies the missing state and the contract where it must exist.
Conclusion
CarthaVault exposed payable deploy and recall functions and forwarded msg.value as an execution fee for 0xMarkets.
The underlying asset and GM tokens were routed correctly. The WNT fee was not.
PoolDeployLib sent WNT to address(this), which resolved to CarthaVault. Deposit creation expected the fee in DepositVault, and withdrawal creation expected it in WithdrawalVault.
The result was deterministic:
Nonzero fee deploy
→ reverts before request creation
Nonzero fee recall
→ reverts before request creation
Zero fee deploy
→ succeeds and mints GM
Zero fee recall
→ succeeds and returns the asset
The engineering lesson is straightforward:
In cross-contract integrations, sending a fee is not enough. It must be sent to the exact contract that the downstream protocol uses to account for that fee.
Top comments (0)