DEV Community

Daniel
Daniel

Posted on • Edited on

How Pending 0xMarkets Requests Enabled Share Inflation in CarthaVault

Asynchronous vault integrations introduce a subtle accounting problem:

What happens to share pricing when vault assets have left the contract but still belong economically to the vault?

That question mattered in the CarthaVault integration with 0xMarkets.

CarthaVault calculated its total value from two balances:

  1. Idle USDC held by the vault

  2. GM tokens currently held by the vault

That works while every asset is locally visible. It breaks during an unresolved 0xMarkets request.

During a deploy request, USDC leaves CarthaVault before the corresponding GM tokens arrive. During a recall request, GM tokens leave CarthaVault before the replacement USDC returns.

The value has not been lost. It is still economically owned by the vault. However, it temporarily disappears from the denominator used to mint and redeem shares.

A user who deposits during that window can receive far more shares than the deposit justifies. Once the external request settles, the missing value reappears and those inflated shares become a claim on funds that belonged to earlier liquidity providers.

The contest confirmed the finding as High. I submitted it as Critical because the proof demonstrated a real transfer of USDC value from an existing LP to an unprivileged depositor. The final reward was $27.65.

The invariant behind fair share pricing

Vault shares represent proportional ownership.

A simplified mint calculation is:

shares minted =
    deposit amount
    × existing share supply
    ÷ vault value
Enter fullscreen mode Exit fullscreen mode

The formula is fair only when the vault value includes everything economically owned by the vault at the moment of minting.

CarthaVault used _totalValueLocked as that denominator:

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

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

This counts local USDC and local GM value.

It does not count assets inside a pending 0xMarkets deploy or recall lifecycle.

That omission creates the vulnerable state.

The deploy window

A keeper can call deployToPool to move idle USDC into 0xMarkets.

The vault delegates the operation to PoolDeployLib, which sends USDC into the external deposit vault and then creates a deposit request.

The relevant flow appears in PoolDeployLib.sol:

IExchangeRouter(router).sendTokens(
    asset,
    depositVault,
    amount
);

depositKey =
    IExchangeRouter(router)
        .createDeposit(params);
Enter fullscreen mode Exit fullscreen mode

The transfer and the final GM receipt do not happen at the same time.

While the request is pending:

  1. The USDC is no longer held by CarthaVault

  2. The GM tokens have not yet been minted to CarthaVault

  3. The value represented by the pending request still belongs economically to CarthaVault

  4. _totalValueLocked sees neither side of that value

The local balance falls immediately, but the economic value should not.

The recall window

The same accounting gap appears in reverse when the keeper calls recallFromPool.

PoolDeployLib sends GM tokens into the external withdrawal lifecycle. USDC returns only after 0xMarkets executes the request.

During that interval:

  1. The GM tokens are no longer held locally

  2. The replacement USDC has not arrived

  3. The pending recall still represents vault owned value

  4. The TVL calculation omits it

The proof demonstrated this omission as well. A recall of 350,000 USDC worth of GM temporarily reduced reported TVL by the same 350,000 USDC even though the vault still owned the pending withdrawal claim.

The deploy window was used for the complete theft scenario because it produced the clearest share inflation path.

Deposits remained available during the unsafe state

The public entry point was depositAndLock.

It validated the deposit and created a position without checking whether a 0xMarkets request was unresolved:

function depositAndLock(
    bytes32 poolId_,
    uint256 amount,
    uint64 lockDays
) external whenNotPaused {
    CarthaVaultStorage storage $ =
        _getCarthaVaultStorage();

    _validateLockInputs(
        $,
        poolId_,
        amount,
        lockDays
    );

    _createPosition(
        $,
        poolId_,
        amount,
        lockDays
    );
}
Enter fullscreen mode Exit fullscreen mode

Inside _createPosition, shares were calculated before the new USDC entered the vault:

uint256 shares =
    _convertToShares(amount);

IERC20($.asset).safeTransferFrom(
    msg.sender,
    address(this),
    amount
);

_mint(msg.sender, shares);
Enter fullscreen mode Exit fullscreen mode

Calculating shares before receiving the new deposit is not the bug.

The problem is the value used by _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

If pending external value is missing from totalAssets_, the denominator is too small.

A smaller denominator produces more shares for the same deposit.

The exploit in numbers

The proof used one prior LP and one attacker.

Initial vault state

The prior LP deposited:

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

The vault minted:

1,000,000 shares
Enter fullscreen mode Exit fullscreen mode

The vault value and share supply were aligned.

Keeper creates a pending deploy

A normal keeper deployed:

900,000 USDC
Enter fullscreen mode Exit fullscreen mode

That USDC left CarthaVault and entered the pending 0xMarkets deposit lifecycle.

Before settlement, CarthaVault held:

100,000 USDC
0 GM tokens
Enter fullscreen mode Exit fullscreen mode

The reported TVL became:

100,000 USDC
Enter fullscreen mode Exit fullscreen mode

The economic TVL was still:

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

The pending 900,000 USDC had not disappeared economically. It was only absent from the two balances used by _totalValueLocked.

Attacker deposits during the window

The attacker called depositAndLock with:

100,000 USDC
Enter fullscreen mode Exit fullscreen mode

The vulnerable calculation was:

100,000
× 1,000,000
÷ 100,000
= 1,000,000 shares
Enter fullscreen mode Exit fullscreen mode

The correct calculation was:

100,000
× 1,000,000
÷ 1,000,000
= 100,000 shares
Enter fullscreen mode Exit fullscreen mode

The attacker deposited one tenth of the amount deposited by the prior LP but received the same number of shares.

The attacker received ten times the fair share amount.

Settlement turned the accounting error into ownership

The 0xMarkets deposit request later executed normally.

The omitted 900,000 USDC value returned to the CarthaVault accounting as GM tokens.

The final vault value became:

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

The total share supply became:

2,000,000 shares
Enter fullscreen mode Exit fullscreen mode

The prior LP and the attacker each owned half of the supply.

Each position was therefore worth:

550,000 USDC
Enter fullscreen mode Exit fullscreen mode

The final economic result was:

Attacker deposit
100,000 USDC

Attacker payout
550,000 USDC

Attacker profit
450,000 USDC

Prior LP economic loss
450,000 USDC
Enter fullscreen mode Exit fullscreen mode

The attacker’s gain and the LP’s loss matched exactly.

The request settlement did not repair the dilution. It confirmed it.

The incorrectly minted shares remained valid and represented half of the restored vault value.

Redeeming the inflated shares

After the required lock and cooldown periods, the attacker could use the normal release path.

release converted shares back into assets through _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

A normal recall returned enough idle USDC for the payout.

The attacker then released the position and received 550,000 USDC through the standard protocol flow.

The proof did not rely on a direct balance edit to manufacture the profit. It exercised the real CarthaVault paths for deposit, share minting, deploy, recall, valuation, redemption, and release.

How the proof isolated the root cause

The Foundry test contained an exploit path and a clean control.

Exploit path

The deploy request remained pending while the attacker deposited.

The proof observed:

  1. Reported TVL of 100,000 USDC

  2. Economic TVL of 1,000,000 USDC

  3. A 100,000 USDC attacker deposit

  4. 1,000,000 attacker shares

  5. The same share count held by the prior LP

  6. A 550,000 USDC attacker payout

  7. A 450,000 USDC attacker profit

  8. A matching 450,000 USDC loss to the prior LP claim

Control path

The control settled the external request before the attacker deposited.

With the full value visible in TVL, the same 100,000 USDC deposit received:

100,000 shares
Enter fullscreen mode Exit fullscreen mode

Those shares represented:

100,000 USDC
Enter fullscreen mode Exit fullscreen mode

The control showed that the ordinary conversion formula was correct when no pending value was omitted.

Privilege and dependency checks

The attacker had no keeper, administrator, factory, or governance role.

The proof did not depend on mainnet, testnet, an RPC provider, oracle manipulation, governance compromise, or keeper compromise.

Local mocks represented the asynchronous 0xMarkets settlement process. They did not replace the vulnerable CarthaVault share accounting.

Both tests passed:

2 passed
0 failed
Enter fullscreen mode Exit fullscreen mode

Why I submitted it as Critical

My severity argument was based on the final economic result.

An unprivileged depositor contributed 100,000 USDC and later received 550,000 USDC. The additional 450,000 USDC came from the prior LP claim.

The vulnerability did more than display an incorrect TVL.

It changed the share supply and therefore changed ownership of the vault.

The important facts were:

  1. The attacker used public user functions

  2. The keeper performed only normal protocol operations

  3. The attacker received a real USDC payout

  4. The earlier LP suffered an equal economic loss

  5. The inflated shares survived external settlement

  6. No privileged attacker action was required

From that perspective, I classified the impact as direct theft of user funds through share inflation.

Why triage kept it at High

Triage agreed that the finding belonged to the direct theft and value appropriation family. They also confirmed that pending 0xMarkets value was omitted from CarthaVault TVL, allowing a depositor to capture value from existing LPs.

The official severity remained High for three stated reasons.

Consistency across the grouped finding

The report was grouped with a broader asynchronous window and share mispricing finding.

Triage applied High uniformly to the deploy, recall, and related pending value variants considered part of the same root cause group.

Timing dependent execution

The attacker could not independently call deployToPool or recallFromPool.

A keeper had to create the pending window through normal protocol operation. The attacker then had to deposit before 0xMarkets settlement closed that window.

The attacker also had to remain in the position through the configured lock and cooldown before releasing.

Triage considered this opportunistic and non atomic rather than a guaranteed single transaction drain.

Redistribution inside the vault

The vulnerable mint did not destroy the underlying assets.

Instead, it redistributed ownership of those assets through an incorrect share allocation.

Triage treated the LP loss as real and serious but distinguished it from an external protocol or pool drain at Critical scale.

The final decision remained High.

The disagreement over severity does not change the root cause or the mitigation. The vault priced shares while part of its economic value was invisible.

Recommended remediation

A safe correction needs to make CarthaVault aware of unresolved external requests.

Track pending value

Each deploy and recall request should be recorded by request key.

During a pending deploy, economic TVL should include the USDC value already sent to 0xMarkets.

During a pending recall, economic TVL should include the value of GM already sent into the withdrawal lifecycle.

Conceptually:

economic TVL =
    idle USDC
    + local GM value
    + pending deploy value
    + pending recall value
Enter fullscreen mode Exit fullscreen mode

Pending value should be removed only after the request is proven executed or cancelled.

The reconciliation logic must avoid counting both the pending claim and the returned asset at the same time.

Block share changing operations

A simpler protective rule is to block share changing operations while an external request remains unresolved.

That includes deposits, mints, burns, redemptions, releases, and other paths that depend on TVL.

Operations can reopen after the request is reconciled.

Combine both protections

The strongest design combines accounting and state isolation:

  1. Track each external request

  2. Record its type and amount

  3. Include unresolved value in economic TVL

  4. Block share changing operations until reconciliation

  5. Clear pending state only after execution or cancellation is verified

  6. Test deploy and recall execution paths

  7. Test deploy and recall cancellation paths

This approach preserves correct accounting and removes the unsafe entry window.

Broader audit lessons

Economic ownership is broader than local balance

A vault can transfer custody temporarily without transferring ownership.

Any value used to price shares must follow economic ownership across the complete external lifecycle.

Asynchronous integrations create real intermediate states

The time between request creation and settlement is not an implementation detail.

It is a state in which users, keepers, and external protocols may continue interacting.

Every intermediate state must preserve the same accounting invariants as the settled state.

Keeper activity can create user attack windows

The keeper did not steal funds and did not need to be compromised.

A normal keeper action created a temporary state that an unrelated user could exploit.

Audits should test deposits, withdrawals, mints, burns, locks, and releases while each keeper request is pending.

Share pricing bugs are ownership bugs

A vault can retain the same total assets while one holder loses value to another.

The share supply determines ownership. Incorrect minting can therefore transfer user funds without reducing the total protocol balance.

A clean control makes the proof stronger

The control settled the request before the attacker deposit.

That comparison isolated pending value omission from unrelated factors such as decimals, oracle behavior, or the ordinary share formula.

Conclusion

CarthaVault priced deposits and redemptions from local USDC and local GM balances while ignoring value inside unresolved 0xMarkets requests.

During deploy, USDC left before GM arrived. During recall, GM left before USDC returned. In both states, _totalValueLocked understated the value still owned by the vault.

The proof demonstrated the complete impact:

  1. A prior LP deposited 1,000,000 USDC

  2. A keeper placed 900,000 USDC into a pending deploy request

  3. Reported TVL fell to 100,000 USDC while economic TVL remained 1,000,000 USDC

  4. The attacker deposited 100,000 USDC and received 1,000,000 shares

  5. Settlement restored the omitted value to the accounting view

  6. The attacker later received 550,000 USDC

  7. The attacker gained 450,000 USDC

  8. The prior LP lost the same 450,000 USDC of economic value

The engineering invariant is simple:

Value must remain inside vault share pricing for as long as the vault still owns it economically, even while an external request is unresolved.

Top comments (0)