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
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
It also broke:
totalValueLocked
deployedGmBalance
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
The implementation is:
function _totalValueLocked()
internal
view
returns (uint256)
{
CarthaVaultStorage storage $ =
_getCarthaVaultStorage();
return
IERC20($.asset)
.balanceOf(address(this))
+
_gmValueInUsdc($);
}
The GM valuation is delegated to PoolDeployLib:
function _gmValueInUsdc(
CarthaVaultStorage storage $
)
internal
view
returns (uint256)
{
return
PoolDeployLib.gmValueInUsdc(
address($.poolToken),
$.reader,
$.dataStore,
$.marketToken,
$.oracle
);
}
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
);
}
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();
}
The atomic-action variant follows the same pattern:
modifier withOraclePricesForAtomicAction(
OracleUtils.SetPricesParams memory params
) {
oracle.setPricesForAtomicAction(
params
);
_;
oracle.clearAllPrices();
}
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;
}
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;
}
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
)
{
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
After the transaction ends, the PoC observed:
CarthaVault GM balance
50000000000000000000000
Oracle tokensWithPrices count
0
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
and deposited:
200,000 USDC
The result was:
User A balance after deposit
800,000 USDC
CarthaVault idle USDC
200,000 USDC
CarthaVault GM balance
0
totalValueLocked
PASS
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
from the vault into 0xMarkets.
That deliberately left:
150,000 USDC
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
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
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_;
}
Source: CarthaVault.sol
User B had:
1,000,000 USDC
before the attempted deposit.
While GM was held, depositAndLock reverted with EmptyPrimaryPrice.
Afterward:
User B balance
1,000,000 USDC
Position created
NO
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
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
The user attempted to release only:
10,000 USDC
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
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;
}
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
and the pending withdrawal was set.
The keeper then called:
processQueuedWithdrawals
That operation also depended on TVL and reverted with:
EmptyPrimaryPrice
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
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
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
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
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
The validated run ended with:
✔ freezes (611575ms)
1 passing (10m)
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
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
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
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
This finding has this shape:
GM exists
Primary price has been cleared
Valuation reverts
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
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
instead of:
CarthaVault
↓
0xMarkets Oracle.primaryPrices
↓
Transaction scoped execution state
↓
EmptyPrimaryPrice after execution
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
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
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
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?
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
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)