Vault accounting bugs often begin with a simple question:
Are all values entering the same formula expressed in the same unit?
In CarthaVault, they were not.
The vault used USDC as its underlying asset, so idle balances were stored in 6 decimal raw units. The settled 0xMarkets GM token used 18 decimals. PoolDeployLib.gmValueInUsdc removed the 0xMarkets price precision, but it did not convert the GM amount from the 18 decimal token scale into the 6 decimal USDC scale.
CarthaVault then added both raw values in _totalValueLocked as though they shared the same unit.
That made settled GM appear one trillion times more valuable than it really was.
A later depositor therefore received almost no shares for a real USDC deposit. When the GM was recalled back into USDC, the TVL unit became correct again, but the distorted share distribution remained. An earlier LP could then redeem a claim containing almost all of the later depositor's funds.
The finding was submitted as Critical and officially validated as High during the 0xMarkets Audit Contest. The final platform record shows a reward of $0.01, shared across 73 researchers.

The unit mismatch
CarthaVault calculated TVL by adding idle USDC to the value returned for settled GM:
function _totalValueLocked()
internal
view
returns (uint256)
{
CarthaVaultStorage storage $ =
_getCarthaVaultStorage();
return
IERC20($.asset)
.balanceOf(address(this))
+ _gmValueInUsdc($);
}
Source: CarthaVault.sol
The first term is measured in USDC raw units:
USDC
6 decimals
The second term comes from PoolDeployLib.gmValueInUsdc:
return
gmBalance
* uint256(gmPrice)
/ GM_PRICE_PRECISION;
Source: PoolDeployLib.sol
The formula removes the 1e30 price precision, but it leaves the result in the 18 decimal GM token scale.
The relevant units were:
Vault asset
USDC
6 decimals
GM token
18 decimals
GM price precision
1e30
For a balance representing 100,000 GM tokens:
gmBalance = 100,000e18
gmPrice = 1e30
the vulnerable formula returned:
100,000e18
The correct USDC raw value was:
100,000e6
The ratio between the reported and correct raw values was:
1e12
The correct conversion
The GM amount must be converted into the vault asset's raw decimal scale before it enters TVL.
Conceptually:
gm value in asset units =
gm balance
× gm price
× asset scale
÷ gm scale
÷ price scale
For this USDC vault:
gm value =
gm balance
× gm price
× 1e6
÷ 1e18
÷ 1e30
The vulnerable implementation only performed:
gm value =
gm balance
× gm price
÷ 1e30
That removed price precision but preserved the GM token scale.
How the wrong TVL corrupted share minting
CarthaVault priced new deposits 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
The mint formula is:
new shares =
deposit amount
× existing share supply
÷ reported TVL
A denominator that is too large produces too few shares.
In the proof, settled GM dominated TVL and inflated the denominator by 1e12. The later depositor was therefore under-minted by the same factor.
The complete attack sequence
The proof used two equal deposits.
The earlier LP entered before the vault held any GM. The later depositor entered after a normal 0xMarkets deploy had already settled.
The earlier LP deposits
The attacker deposited:
100,000 USDC
Because no GM existed yet, the first deposit path behaved correctly.
The attacker received:
100,000.000000000000000000 shares
A normal keeper deploy settles into GM
A keeper deployed the full 100,000 USDC into 0xMarkets.
The request was fully executed before the victim deposited.
CarthaVault then held:
100,000.000000000000000000 GM
represented in raw units as:
100,000e18
The correct economic value was:
100,000 USDC
The function returned 100,000e18 and _totalValueLocked treated that result as 6 decimal USDC raw units. When formatted as USDC, the vault reported:
100,000,000,000,000,000 USDC
instead of:
100,000 USDC
The victim deposits the same amount
The victim then deposited:
100,000 USDC
A fair mint would have produced:
100,000.000000000000000000 shares
The vulnerable calculation produced only:
0.000000100000000000 shares
The victim deposited the same USDC amount as the attacker but received one trillionth of the fair share amount.
Why this was not the pending request bug
The deploy request had already executed before the victim entered.
There was no unresolved request window, and the GM was already held by CarthaVault.
The vulnerable state was:
Deploy request
Executed
GM
Settled inside CarthaVault
Victim deposit
Occurs after settlement
Root cause
18 decimal GM value added to 6 decimal USDC TVL
This is distinct from the pending request accounting bug.
The pending request bug omits value that is temporarily in flight.
This bug overstates value that has already settled.
Recall fixed the TVL unit, not the share distribution
After the victim received almost no shares, the keeper recalled the GM back into USDC.
CarthaVault then held:
200,000 USDC
and no GM.
The decimal mismatch disappeared because TVL now consisted entirely of 6 decimal USDC. The accounting unit was correct again.
The ownership distribution was not.
The attacker still held almost the entire share supply, while the victim held only the tiny balance created during the inflated TVL state.
CarthaVault converted shares back into assets through:
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 attacker then used the normal release path.
The measured economic result
The proof observed:
Attacker initial deposit
100,000 USDC
Victim deposit
100,000 USDC
Attacker payout
199,999.999999 USDC
Attacker profit
99,999.999999 USDC
Victim residual claim
0.000001 USDC
Victim economic loss
99,999.999999 USDC
The attacker did not require:
Administrator privileges
Governance control
Keeper compromise
Oracle manipulation
A pending request window
Mainnet or testnet access
An external RPC
The keeper only performed normal deploy and recall operations.
The attacker only needed to be an existing LP before another user deposited while the vault held settled GM.
How the proof isolated the root cause
The Foundry proof contained three tests.
Full economic path
The main test used the real CarthaVault and PoolDeployLib implementations.
It exercised:
depositAndLockdeployToPoolSettled GM valuation through
totalValueLockedA later
depositAndLockrecallFromPool_convertToAssetsrelease
The final USDC payout and victim residual claim were measured directly.
The local mocks supplied deterministic settlement, token conversion, reader pricing, and oracle values. They did not replace the vulnerable CarthaVault or PoolDeployLib accounting logic.
Clean control
The control used two equal deposits without settled GM.
Both users deposited:
100,000 USDC
Both received:
100,000.000000000000000000 shares
Both held a claim worth:
100,000 USDC
This proved that the normal share decimal offset was not the root cause.
Isolated math check
A separate test evaluated the settled GM value directly:
GM balance
100,000e18
Correct asset value
100,000e6
Reported value
100,000e18
Overvaluation factor
1e12
The complete result was:
3 passed
0 failed
Why I argued for Critical
My severity argument focused on the realized economic outcome.
The victim deposited 100,000 USDC and retained only 0.000001 USDC of claim.
The attacker deposited the same amount and later received 199,999.999999 USDC.
The attacker's extra claim came directly from the victim's under-minted ownership.
The exploit used normal user and keeper flows, and the attacker received a real USDC payout through release.
From that perspective, the issue matched direct theft of user funds through share mispricing.
It was not limited to:
A misleading display
Temporary TVL inaccuracy
A non-economic accounting mismatch
A denial of service
The share distribution became permanently corrupted, and the attacker realized the resulting value transfer.
Why triage kept it at High
Triage agreed that the finding belonged to the direct theft and value appropriation family.
They also confirmed the technical root cause:
gmValueInUsdc returns an 18 decimal GM-scaled value
CarthaVault adds it to 6 decimal USDC
TVL is overstated by 1e12
Later depositors are under-minted
The official severity remained High for two stated reasons.
Consistency across the grouped finding
The report was grouped with the broader settled GM decimal normalization finding.
Triage applied the same High severity to every report in that finding group.
Re-apportionment between LPs
The assets remained inside the vault.
The decimal mismatch redistributed ownership between LPs rather than removing assets from the protocol pool itself.
Triage reserved Critical for external appropriation of principal, an unbounded pool drain, or insolvency-class impact.
They treated the demonstrated loss as real and severe, but not as a Critical external drain.
The final classification remained High.
Recommended remediation
The value returned by gmValueInUsdc must use the same raw decimal scale as the vault asset.
CarthaVault._gmValueInUsdc should pass the underlying asset address to the library, allowing the library to read both decimal counts.
The core normalization can then be implemented with full precision arithmetic:
uint8 gmDecimals =
IERC20Metadata(poolToken)
.decimals();
uint8 assetDecimals =
IERC20Metadata(asset)
.decimals();
uint256 gmScale =
10 ** uint256(gmDecimals);
uint256 assetScale =
10 ** uint256(assetDecimals);
uint256 valueUsdFloat =
Math.mulDiv(
gmBalance,
uint256(gmPrice),
gmScale
);
return
Math.mulDiv(
valueUsdFloat,
assetScale,
GM_PRICE_PRECISION
);
The caller must provide $.asset:
return PoolDeployLib.gmValueInUsdc(
address($.poolToken),
$.asset,
$.reader,
$.dataStore,
$.marketToken,
$.oracle
);
The regression invariant is:
100,000e18 GM
at a 1e30 price
inside a 6 decimal USDC vault
must equal
100,000e6 USDC raw units
After the correction, two equal deposits under unchanged economic conditions must mint equal shares.
Broader audit lessons
Function names do not guarantee units
A function named gmValueInUsdc can still return the wrong raw scale.
Every value entering an addition, subtraction, or ratio must have its unit verified explicitly.
Decimal bugs can become ownership bugs
The assets may remain inside the vault while the share distribution becomes corrupted.
When shares determine redemption rights, a unit mismatch can transfer economic ownership without moving tokens during the vulnerable mint transaction.
Pending and settled states need separate tests
The pending state and the settled state can fail for different reasons.
Vault integrations should be tested before request creation, during a pending request, after settlement, and after recall.
Equal deposits are a strong invariant
Under unchanged economic conditions, equal deposits should mint equal shares.
A clean control with equal deposits exposes denominator and decimal errors clearly.
Normalize at protocol boundaries
External values should be converted into the local accounting unit before they are returned to vault logic.
The safest boundary rule is:
Every value returned to vault accounting must already be expressed in the vault asset's raw decimal scale.
Conclusion
CarthaVault added settled 0xMarkets GM value to idle USDC without converting the result from the 18 decimal GM scale into the vault asset's 6 decimal scale.
That overstated TVL by 1e12.
A later depositor contributed 100,000 USDC and received only:
0.000000100000000000 shares
After GM was recalled into USDC, the TVL unit became correct but the ownership distortion remained.
The attacker redeemed:
199,999.999999 USDC
and realized:
99,999.999999 USDC
of profit, while the victim retained only:
0.000001 USDC
of residual claim.
The engineering invariant is simple:
Values from different token systems must never enter the same vault accounting formula until they have been normalized into the same unit.
Top comments (0)