DEV Community

Daniel
Daniel

Posted on • Edited on

How a 1e12 Decimal Mismatch Let Existing LPs Capture Later Deposits

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

Source: CarthaVault.sol

The first term is measured in USDC raw units:

USDC
6 decimals
Enter fullscreen mode Exit fullscreen mode

The second term comes from PoolDeployLib.gmValueInUsdc:

return
    gmBalance
    * uint256(gmPrice)
    / GM_PRICE_PRECISION;
Enter fullscreen mode Exit fullscreen mode

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

For a balance representing 100,000 GM tokens:

gmBalance = 100,000e18
gmPrice   = 1e30
Enter fullscreen mode Exit fullscreen mode

the vulnerable formula returned:

100,000e18
Enter fullscreen mode Exit fullscreen mode

The correct USDC raw value was:

100,000e6
Enter fullscreen mode Exit fullscreen mode

The ratio between the reported and correct raw values was:

1e12
Enter fullscreen mode Exit fullscreen mode

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

For this USDC vault:

gm value =
    gm balance
    × gm price
    × 1e6
    ÷ 1e18
    ÷ 1e30
Enter fullscreen mode Exit fullscreen mode

The vulnerable implementation only performed:

gm value =
    gm balance
    × gm price
    ÷ 1e30
Enter fullscreen mode Exit fullscreen mode

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

Source: CarthaVault.sol

The mint formula is:

new shares =
    deposit amount
    × existing share supply
    ÷ reported TVL
Enter fullscreen mode Exit fullscreen mode

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

Because no GM existed yet, the first deposit path behaved correctly.

The attacker received:

100,000.000000000000000000 shares
Enter fullscreen mode Exit fullscreen mode

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

represented in raw units as:

100,000e18
Enter fullscreen mode Exit fullscreen mode

The correct economic value was:

100,000 USDC
Enter fullscreen mode Exit fullscreen mode

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

instead of:

100,000 USDC
Enter fullscreen mode Exit fullscreen mode

The victim deposits the same amount

The victim then deposited:

100,000 USDC
Enter fullscreen mode Exit fullscreen mode

A fair mint would have produced:

100,000.000000000000000000 shares
Enter fullscreen mode Exit fullscreen mode

The vulnerable calculation produced only:

0.000000100000000000 shares
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

The attacker did not require:

  1. Administrator privileges

  2. Governance control

  3. Keeper compromise

  4. Oracle manipulation

  5. A pending request window

  6. Mainnet or testnet access

  7. 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:

  1. depositAndLock

  2. deployToPool

  3. Settled GM valuation through totalValueLocked

  4. A later depositAndLock

  5. recallFromPool

  6. _convertToAssets

  7. release

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

Both received:

100,000.000000000000000000 shares
Enter fullscreen mode Exit fullscreen mode

Both held a claim worth:

100,000 USDC
Enter fullscreen mode Exit fullscreen mode

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

The complete result was:

3 passed
0 failed
Enter fullscreen mode Exit fullscreen mode

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:

  1. A misleading display

  2. Temporary TVL inaccuracy

  3. A non-economic accounting mismatch

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

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

The caller must provide $.asset:

return PoolDeployLib.gmValueInUsdc(
    address($.poolToken),
    $.asset,
    $.reader,
    $.dataStore,
    $.marketToken,
    $.oracle
);
Enter fullscreen mode Exit fullscreen mode

The regression invariant is:

100,000e18 GM
at a 1e30 price
inside a 6 decimal USDC vault
must equal
100,000e6 USDC raw units
Enter fullscreen mode Exit fullscreen mode

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

After GM was recalled into USDC, the TVL unit became correct but the ownership distortion remained.

The attacker redeemed:

199,999.999999 USDC
Enter fullscreen mode Exit fullscreen mode

and realized:

99,999.999999 USDC
Enter fullscreen mode Exit fullscreen mode

of profit, while the victim retained only:

0.000001 USDC
Enter fullscreen mode Exit fullscreen mode

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)