DEV Community

Daniel
Daniel

Posted on • Edited on

How a Wrong WNT Receiver Broke CarthaVault's Nonzero Execution Fee Paths

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:

  1. deployToPool, which sends vault assets into a market deposit

  2. recallFromPool, 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
    );
}
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

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
        );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
        );
}
Enter fullscreen mode Exit fullscreen mode

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
        );
}
Enter fullscreen mode Exit fullscreen mode

Again, address(this) is CarthaVault.

The resulting destinations are:

GM tokens
→ WithdrawalVault

WNT execution fee
→ CarthaVault
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

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
        );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The observed custom error was:

InsufficientWntAmountForExecutionFee(
    0,
    executionFee
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The observed error was:

InsufficientWntAmount(
    0,
    executionFee
)
Enter fullscreen mode Exit fullscreen mode

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:

  1. CarthaVault configuration

  2. Keeper permissions

  3. Asset balances

  4. Token approvals

  5. 0xMarkets router configuration

  6. Deposit and withdrawal handlers

  7. Oracle parameters

  8. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The issue was not classified as Critical because the failing transactions reverted atomically.

The proof did not demonstrate:

  1. Theft of user funds

  2. Insolvency

  3. Permanent asset loss

  4. 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
        );
}
Enter fullscreen mode Exit fullscreen mode

For a recall request, WNT must be sent to withdrawalVault:

if (executionFee > 0) {
    IExchangeRouter(router)
        .sendWnt{value: executionFee}(
-           address(this),
+           withdrawalVault,
            executionFee
        );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)