DEV Community

Daniel
Daniel

Posted on

How Transaction-Scoped 0xMarkets Oracle Prices Could Freeze CarthaVault After GM Settlement

The bug was not a bad oracle price.

It was a mismatch in how long that price was supposed to exist.

CarthaVault included the value of its settled 0xMarkets GM balance in total value locked. To calculate that value, PoolDeployLib.gmValueInUsdc queried the 0xMarkets Oracle for the market’s primary prices.

That looks reasonable until you follow the lifetime of those prices.

0xMarkets primary prices are transaction scoped execution values. OracleModule.withOraclePrices sets them before keeper execution and clears them before the transaction ends.

CarthaVault, however, can continue holding the GM token long after that execution transaction is over.

The resulting steady state was:

CarthaVault holds GM
YES

0xMarkets primary prices exist
NO
Enter fullscreen mode Exit fullscreen mode

From there, the next CarthaVault operation that needed TVL could reach Oracle.getPrimaryPrice and revert with EmptyPrimaryPrice.

The proof reproduced that failure across:

depositAndLock

lockTopUp

release

processQueuedWithdrawals
Enter fullscreen mode Exit fullscreen mode

It also broke:

totalValueLocked

deployedGmBalance
Enter fullscreen mode Exit fullscreen mode

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

It earned $0.52.

The important part was not that a view could revert. The same valuation dependency blocked state changing deposit and withdrawal paths while the vault held real GM.

The lifecycle mismatch

CarthaVault calculates TVL from two components:

Idle vault asset
+
Value of settled GM
Enter fullscreen mode Exit fullscreen mode

The implementation is:

function _totalValueLocked()
    internal
    view
    returns (uint256)
{
    CarthaVaultStorage storage $ =
        _getCarthaVaultStorage();

    return
        IERC20($.asset)
            .balanceOf(address(this))
        +
        _gmValueInUsdc($);
}
Enter fullscreen mode Exit fullscreen mode

The GM valuation is delegated to PoolDeployLib:

function _gmValueInUsdc(
    CarthaVaultStorage storage $
)
    internal
    view
    returns (uint256)
{
    return
        PoolDeployLib.gmValueInUsdc(
            address($.poolToken),
            $.reader,
            $.dataStore,
            $.marketToken,
            $.oracle
        );
}
Enter fullscreen mode Exit fullscreen mode

Source: CarthaVault.sol

That is safe only if the configured price source is available whenever CarthaVault needs to value its GM holdings.

PoolDeployLib.gmValueInUsdc takes an early return while the GM balance is zero. Once GM is present, it reaches _getGmPrice, which reads the 0xMarkets Oracle:

function _getGmPrice(
    address reader,
    address dataStore,
    address market,
    address oracle
)
    private
    view
    returns (
        int256 gmPrice
    )
{
    I0xMReader r =
        I0xMReader(reader);

    I0xMOracle o =
        I0xMOracle(oracle);

    I0xMReader.MarketProps memory mkt =
        r.getMarket(
            dataStore,
            market
        );

    I0xMReader.PriceProps memory indexPrice =
        o.getPrimaryPrice(
            mkt.indexToken
        );

    I0xMReader.PriceProps memory longPrice =
        o.getPrimaryPrice(
            mkt.longToken
        );

    I0xMReader.PriceProps memory shortPrice =
        o.getPrimaryPrice(
            mkt.shortToken
        );

    (
        gmPrice,
    ) =
        r.getMarketTokenPrice(
            dataStore,
            mkt,
            indexPrice,
            longPrice,
            shortPrice,
            MAX_PNL_FACTOR_FOR_DEPOSITS,
            false
        );
}
Enter fullscreen mode Exit fullscreen mode

Source: PoolDeployLib.sol

The integration therefore assumes that Oracle.getPrimaryPrice behaves like a persistent price feed.

It does not.

0xMarkets clears the prices by design

The 0xMarkets execution model makes the lifetime of primaryPrices explicit:

modifier withOraclePrices(
    OracleUtils.SetPricesParams memory params
) {
    oracle.setPrices(params);
    _;
    oracle.clearAllPrices();
}
Enter fullscreen mode Exit fullscreen mode

The atomic-action variant follows the same pattern:

modifier withOraclePricesForAtomicAction(
    OracleUtils.SetPricesParams memory params
) {
    oracle.setPricesForAtomicAction(
        params
    );
    _;
    oracle.clearAllPrices();
}
Enter fullscreen mode Exit fullscreen mode

Source: OracleModule.sol

The prices are available while the keeper action is executing.

Then clearAllPrices removes them:

function clearAllPrices()
    external
    onlyController
{
    uint256 length =
        tokensWithPrices.length();

    for (
        uint256 i;
        i < length;
        i++
    ) {
        address token =
            tokensWithPrices.at(0);

        _removePrimaryPrice(
            token
        );
    }

    minTimestamp = 0;
    maxTimestamp = 0;
}
Enter fullscreen mode Exit fullscreen mode

Later, getPrimaryPrice explicitly rejects an empty value:

function getPrimaryPrice(
    address token
)
    external
    view
    returns (
        Price.Props memory
    )
{
    if (
        token == address(0)
    ) {
        return
            Price.Props(
                0,
                0
            );
    }

    Price.Props memory price =
        primaryPrices[
            token
        ];

    if (
        price.isEmpty()
    ) {
        revert
            Errors.EmptyPrimaryPrice(
                token
            );
    }

    return price;
}
Enter fullscreen mode Exit fullscreen mode

Source: Oracle.sol

So seeing EmptyPrimaryPrice after an execution is not evidence that 0xMarkets itself failed to clean up.

The cleanup is the expected behavior.

The integration failure is using that transaction scoped state as though it were available for persistent vault accounting.

A normal deposit creates the frozen steady state

The PoC did not manufacture the condition by directly minting GM or directly clearing the Oracle.

It used the real 0xMarkets deposit execution path.

DepositHandler.executeDeposit runs under withOraclePrices:

function executeDeposit(
    bytes32 key,
    OracleUtils.SetPricesParams calldata oracleParams
)
    external
    globalNonReentrant
    onlyOrderKeeper
    withOraclePrices(
        oracleParams
    )
{
Enter fullscreen mode Exit fullscreen mode

Source: DepositHandler.sol

During execution, the market token is minted to the request receiver.

For this integration, the receiver is the real CarthaVault.

The transaction therefore legitimately performs both of these actions:

Mint GM to CarthaVault

Clear Oracle primary prices
Enter fullscreen mode Exit fullscreen mode

After the transaction ends, the PoC observed:

CarthaVault GM balance
50000000000000000000000

Oracle tokensWithPrices count
0
Enter fullscreen mode Exit fullscreen mode

That raw GM balance is 50,000e18.

CarthaVault now has an asset it must value across transactions, but the price state selected by the integration has already completed its lifecycle.

Before GM existed, the vault worked normally

The proof first established a healthy control state.

User A started with:

1,000,000 USDC
Enter fullscreen mode Exit fullscreen mode

and deposited:

200,000 USDC
Enter fullscreen mode Exit fullscreen mode

The result was:

User A balance after deposit
800,000 USDC

CarthaVault idle USDC
200,000 USDC

CarthaVault GM balance
0

totalValueLocked
PASS
Enter fullscreen mode Exit fullscreen mode

This matters because gmValueInUsdc skips the oracle path while GM is zero.

The vault only enters the broken state after a real 0xMarkets deployment has settled and GM is actually held.

The keeper deployed only 50,000 USDC

The PoC then deployed:

50,000 USDC
Enter fullscreen mode Exit fullscreen mode

from the vault into 0xMarkets.

That deliberately left:

150,000 USDC
Enter fullscreen mode Exit fullscreen mode

inside CarthaVault.

The real 0xMarkets deposit request was created and executed.

GM was minted to the vault.

The Oracle finished the keeper transaction with:

tokensWithPrices
0
Enter fullscreen mode Exit fullscreen mode

That produced the exact state needed to test whether CarthaVault could continue operating after normal settlement.

It could not.

TVL and deployed GM accounting reverted

Once GM was positive, the early-return condition in gmValueInUsdc no longer applied.

CarthaVault attempted to value the GM.

The valuation reached Oracle.getPrimaryPrice after the prices had been cleared.

The PoC observed:

totalValueLocked
REVERTS EmptyPrimaryPrice

deployedGmBalance
REVERTS EmptyPrimaryPrice
Enter fullscreen mode Exit fullscreen mode

If that were the full impact, this could be dismissed as a reporting or UI problem.

The same dependency sits inside state changing user paths.

New deposits were blocked

depositAndLock calculates shares through _convertToShares:

function _convertToShares(
    uint256 assets
)
    internal
    view
    returns (
        uint256 shares
    )
{
    uint256 totalShares =
        totalSupply();

    uint256 totalAssets_ =
        _totalValueLocked();

    if (
        totalShares == 0
            ||
        totalAssets_ == 0
    ) {
        CarthaVaultStorage storage $ =
            _getCarthaVaultStorage();

        return
            assets
            *
        10 ** (
            decimals()
                -
            IERC20Metadata(
                $.asset
            ).decimals()
        );
    }

    return
        (
            assets
                *
            totalShares
        )
            /
        totalAssets_;
}
Enter fullscreen mode Exit fullscreen mode

Source: CarthaVault.sol

User B had:

1,000,000 USDC
Enter fullscreen mode Exit fullscreen mode

before the attempted deposit.

While GM was held, depositAndLock reverted with EmptyPrimaryPrice.

Afterward:

User B balance
1,000,000 USDC

Position created
NO
Enter fullscreen mode Exit fullscreen mode

The failed call did not consume the user’s assets, but new deposits were unavailable.

Existing positions could not be topped up

The PoC then exercised lockTopUp on User A’s existing position.

That path also required TVL based share conversion.

It reverted with:

EmptyPrimaryPrice
Enter fullscreen mode Exit fullscreen mode

The user balance and position values remained unchanged.

So the deposit side was blocked for both new users and existing positions.

Direct release failed despite abundant idle liquidity

This was the strongest control in the entire proof.

A withdrawal failure after deploying funds externally could otherwise be explained as an ordinary liquidity problem.

The PoC made that explanation impossible.

Before the release probe, CarthaVault still held:

150,000 USDC
Enter fullscreen mode Exit fullscreen mode

The user attempted to release only:

10,000 USDC
Enter fullscreen mode Exit fullscreen mode

The vault had fifteen times the probe amount available locally.

After the lock and cooldown requirements were satisfied, User A called the real release path.

The result was:

release
REVERTS EmptyPrimaryPrice

User release delta
0 USDC
Enter fullscreen mode Exit fullscreen mode

The reason is that release needs _convertToAssets:

function _convertToAssets(
    uint256 shares
)
    internal
    view
    returns (
        uint256 assets
    )
{
    uint256 totalShares =
        totalSupply();

    if (
        totalShares == 0
    ) {
        return 0;
    }

    return
        (
            shares
                *
            _totalValueLocked()
        )
            /
        totalShares;
}
Enter fullscreen mode Exit fullscreen mode

Source: CarthaVault.sol

The vault had enough USDC.

What it lacked was a price source that survived long enough to calculate the payout.

That isolates the liveness failure from ordinary external illiquidity.

Queued withdrawal processing was blocked too

The proof also exercised the keeper-driven withdrawal flow.

User A successfully called:

requestRelease
Enter fullscreen mode Exit fullscreen mode

and the pending withdrawal was set.

The keeper then called:

processQueuedWithdrawals
Enter fullscreen mode Exit fullscreen mode

That operation also depended on TVL and reverted with:

EmptyPrimaryPrice
Enter fullscreen mode Exit fullscreen mode

The pending withdrawal was not processed.

The two tested withdrawal modes therefore failed for the same root cause:

Direct release
FAILED

Queued withdrawal processing
FAILED
Enter fullscreen mode Exit fullscreen mode

A full GM recall restored normal accounting

The issue had a valid recovery path.

The keeper created a real 0xMarkets withdrawal for the vault’s complete GM balance and executed it through the real withdrawal handler.

Afterward:

CarthaVault GM balance
0

deployedGmBalance
0

totalValueLocked
PASS
Enter fullscreen mode Exit fullscreen mode

Once the GM balance reaches zero, gmValueInUsdc no longer needs to query the cleared 0xMarkets primary prices.

This is why the demonstrated impact is a temporary freeze rather than a permanent one.

The proof establishes that normal accounting resumes after a full recall. It does not rely on keeping stale primary prices alive.

The PoC used the real integration

The test was:

FreezeFlow.ts
Enter fullscreen mode Exit fullscreen mode

and used the real:

CarthaVault

CarthaVault proxy and factory

Cartha AccessControl

0xMarkets Oracle

0xMarkets Reader

0xMarkets ExchangeRouter

0xMarkets DepositHandler

0xMarkets WithdrawalHandler

0xMarkets MarketToken

OracleModule.withOraclePrices
Enter fullscreen mode Exit fullscreen mode

It did not:

Mock the 0xMarkets Oracle

Mock the 0xMarkets Reader

Mock the ExchangeRouter

Mint GM directly

Call clearAllPrices directly

Depend on mainnet

Depend on testnet

Depend on public RPC

Require governance compromise

Require malicious admin behavior
Enter fullscreen mode Exit fullscreen mode

The validated run ended with:

✔ freezes (611575ms)

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

The runtime evidence

Metric Observed result
User A initial deposit 200,000 USDC
Idle USDC before deploy 200,000 USDC
USDC deployed to 0xMarkets 50,000 USDC
Idle USDC after deploy 150,000 USDC
GM before execution 0
GM after execution 50,000e18 raw units
Oracle tokens with prices after execution 0
totalValueLocked EmptyPrimaryPrice
deployedGmBalance EmptyPrimaryPrice
New depositAndLock EmptyPrimaryPrice
lockTopUp EmptyPrimaryPrice
Direct release probe 10,000 USDC
Idle USDC available for release 150,000 USDC
release EmptyPrimaryPrice
processQueuedWithdrawals EmptyPrimaryPrice
Full GM recall Restored TVL

The most important comparison is:

Idle liquidity
150,000 USDC

Release probe
10,000 USDC

Liquidity sufficient
YES

Release result
EmptyPrimaryPrice
Enter fullscreen mode Exit fullscreen mode

The user was not blocked because the vault lacked assets.

The user was blocked because the vault could not value the GM it already held.

Why I reported it as High

The proof did not demonstrate:

Direct theft

Insolvency

Permanent freezing of funds

No honest recovery path
Enter fullscreen mode Exit fullscreen mode

A complete GM recall restored accounting.

That bounded the severity.

But the issue was clearly more than a failed view.

While GM remained held, the same root cause blocked:

New deposits

Position top-ups

Direct releases

Queued withdrawal processing
Enter fullscreen mode Exit fullscreen mode

Existing users could not release through either tested withdrawal route even though enough idle USDC was already available.

Recovery required a keeper to unwind the complete GM position before normal TVL based operations resumed.

That is why I reported the finding as High.

The HackenProof reward allocated to the report was $0.52.

Why this is not the GM decimal mismatch

A separate CarthaVault finding also involved settled GM valuation, but the failure mode is different.

The decimal-normalization issue has this shape:

GM exists

Price exists

Valuation returns

Returned value uses the wrong scale
Enter fullscreen mode Exit fullscreen mode

This finding has this shape:

GM exists

Primary price has been cleared

Valuation reverts
Enter fullscreen mode Exit fullscreen mode

One corrupts the number returned by TVL.

The other prevents TVL from being calculated at all.

They require different fixes.

Why this is not the pending-request accounting issue

The pending-request finding exists before 0xMarkets settlement.

This issue exists after settlement.

Here:

Deposit request
EXECUTED

GM minted to CarthaVault
YES

Oracle price cache
CLEARED

Steady-state TVL
BROKEN
Enter fullscreen mode Exit fullscreen mode

The bug does not depend on value being in transit.

It depends on using transaction scoped oracle state to value an asset held persistently across transactions.

Fix the price lifetime mismatch

CarthaVault should not use Oracle.primaryPrices as its persistent GM valuation source.

Those prices belong to the 0xMarkets execution lifecycle.

The report proposes a dedicated GM pricing adapter backed by a persistent token price source.

Conceptually:

CarthaVault
    ↓
Persistent GM price adapter
    ↓
Persistent token prices
    ↓
0xMarkets Reader.getMarketTokenPrice
    ↓
GM valuation
Enter fullscreen mode Exit fullscreen mode

instead of:

CarthaVault
    ↓
0xMarkets Oracle.primaryPrices
    ↓
Transaction scoped execution state
    ↓
EmptyPrimaryPrice after execution
Enter fullscreen mode Exit fullscreen mode

This lets CarthaVault continue using the market valuation logic without assuming that execution-only Oracle state survives into later transactions.

The integration should enforce a simple invariant:

If CarthaVault can hold GM across transactions, it must have a valuation source that remains available across transactions.

The lifetime of the asset and the lifetime of its pricing dependency must be compatible.

Regression coverage

A corrected implementation should reproduce the same normal lifecycle:

1. User deposits into CarthaVault

2. Keeper calls deployToPool

3. Real 0xMarkets deposit executes

4. GM is minted to CarthaVault

5. 0xMarkets clears primary prices

6. CarthaVault continues holding GM
Enter fullscreen mode Exit fullscreen mode

At that point, none of the CarthaVault operations should depend on the cleared cache.

The regression suite should require:

totalValueLocked
PASS

deployedGmBalance
PASS

depositAndLock
PASS

lockTopUp
PASS

release with sufficient idle USDC
PASS

processQueuedWithdrawals
PASS
Enter fullscreen mode Exit fullscreen mode

The test should not preserve or restore 0xMarkets primaryPrices to make these checks pass.

The fix should remove the persistent dependency on that transient state.

The broader audit lesson

Cross-protocol integrations need compatible state lifetimes, not just compatible interfaces.

A function called:

getPrimaryPrice
Enter fullscreen mode Exit fullscreen mode

can look like a conventional oracle read.

That name alone says nothing about whether the value exists outside an execution transaction.

When integrating another protocol’s state, I now treat these as separate questions:

Who sets the value?

When does it become valid?

Who clears it?

How long is it expected to exist?

Does my protocol need it after that lifetime ends?
Enter fullscreen mode Exit fullscreen mode

0xMarkets cleared its primary prices as part of the normal execution lifecycle.

CarthaVault held the resulting GM after that lifecycle had finished.

The integration crossed those two lifetimes without a persistent pricing layer in between.

That was the bug.

Conclusion

CarthaVault valued its settled 0xMarkets GM through PoolDeployLib.gmValueInUsdc, which queried Oracle.getPrimaryPrice.

A real DepositHandler.executeDeposit call set the required prices for execution, minted GM to CarthaVault, and then cleared the Oracle primary prices through OracleModule.withOraclePrices.

After the transaction, the vault was left in a normal but incompatible state:

GM held by CarthaVault
YES

0xMarkets primary prices available
NO
Enter fullscreen mode Exit fullscreen mode

Any CarthaVault operation that needed TVL then reached the cleared price cache and reverted with EmptyPrimaryPrice.

The proof demonstrated failures in depositAndLock, lockTopUp, release, and processQueuedWithdrawals.

The release control was especially strong: the vault had 150,000 USDC of idle liquidity while the user attempted to release only 10,000 USDC, yet the call still reverted.

A full GM recall restored accounting, so the demonstrated impact was a temporary freeze rather than a permanent one.

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

The core engineering lesson is:

Persistent accounting cannot depend on transaction scoped oracle state.

If a vault holds an external asset after the execution transaction ends, the valuation source for that asset must still exist when the next transaction begins.

Top comments (0)