58471 sc high accounting error in forcerepay doliquidation overstates tvl enabling under scaled redemptions and potential insolvency

Submitted on Nov 2nd 2025 at 14:40:22 UTC by @winnerz for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #58471

  • Report Type: Smart Contract

  • Report severity: High

  • Target: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/AlchemistV3.sol

  • Impacts:

    • Protocol insolvency

Description

Brief/Intro

When _forceRepay and liquidation _doLiquidation transfer MYT out of the Alchemist, the contract does not decrement _mytSharesDeposited. Since getTotalUnderlyingValue() derives TVL from _mytSharesDeposited, reported TVL remains inflated while actual holdings drop. This depresses badDebtRatio used by the Transmuter and can delay or under‑apply scaling of redemptions, creating a path to protocol insolvency. It can also soften liquidation sizing by overstating global collateralization inputs.

Vulnerability Details

TVL is derived from _mytSharesDeposited:

// src/AlchemistV3.sol: 1238-1241
    function _getTotalUnderlyingValue() internal view returns (uint256 totalUnderlyingValue) {
        uint256 yieldTokenTVLInUnderlying = convertYieldTokensToUnderlying(_mytSharesDeposited);
        totalUnderlyingValue = yieldTokenTVLInUnderlying;
    }

In _forceRepay, MYT is transferred out but _mytSharesDeposited is not decremented, so reported TVL does not change:

Similarly, in _doLiquidation, MYT leaves the contract without decreasing _mytSharesDeposited:

By contrast, other outflow paths do reduce _mytSharesDeposited:

Transmuter claimRedemption() relies on the Alchemist TVL in its denominator; an inflated TVL depresses this ratio and delays scaling:

flow-of-funds

  • _mytSharesDeposited intent (state):

  • Why repay() only decrements fees: the principal is transferred from the payer directly to the Transmuter (Alchemist never increases its MYT), so only the fee portion is an Alchemist outflow that must reduce _mytSharesDeposited.

  • In _forceRepay and _doLiquidation, the principal leaves from the Alchemist balance itself, so _mytSharesDeposited must be decreased by what is sent out (plus any fee outflows) to keep TVL honest.

Effect on the Transmuter ratio

  • Let den_used = TVL_used + transmuterUnderlying and den_true = TVL_true + transmuterUnderlying.

  • Since TVL_used > TVL_true after drift, den_used > den_true which implies usedR = totalSyntheticsIssued / den_used < trueR = totalSyntheticsIssued / den_true.

  • Scaling in claimRedemption() uses min(1, 1/ratio). If trueR > 1 but usedR <= 1, the claim is paid unscaled when it should be scaled. If both exceed 1, the paid amount under the used ratio is larger than under the true ratio by a factor trueR/usedR > 1.

The drift persists until a path that decrements _mytSharesDeposited runs (withdraw or redeem or the fee leg of repay). Multiple _forceRepay/liquidation events can accumulate drift across blocks, affecting all subsequent redemptions and liquidation decisions protocol-wide.

Why this matters:

  • getTotalUnderlyingValue() uses _mytSharesDeposited to compute TVL. Omitting decrements during _forceRepay/liquidation keeps reported TVL unchanged even though MYT actually left the protocol.

  • Transmuter claimRedemption() computes a badDebtRatio using getTotalUnderlyingValue(). Inflated TVL depresses this ratio (usedR < trueR), delaying or under‑applying scaling when bad debt exists. This can systematically overpay claims and lead to insolvency.

  • Liquidation sizing (calculateLiquidation) that takes global collateralization as an input can be influenced to under‑liquidate due to the overstated TVL.

Impact Details

  • Impact: Protocol insolvency

  • Rationale:

    • Inflated TVL depresses badDebtRatio and reduces/delays redemption scaling, overpaying claimants when bad debt exists.

    • Over time, this can drain reserves and result in protocol insolvency.

    • Secondary: Under-liquidation due to overstated global collateralization inputs.

References

  • _forceRepay MYT outflow without _mytSharesDeposited decrement: https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L738

  • _doLiquidation MYT outflow without _mytSharesDeposited decrement: https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L867

  • withdraw() correct decrement: https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L410

  • redeem() correct decrement: https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L638

  • repay() fee decrement (principal comes from payer, not Alchemist): https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L541

Mitigation

  • In _forceRepay, decrement _mytSharesDeposited by the MYT actually sent out:

    • Subtract creditToYield and protocolFeeTotal when they are transferred to Transmuter and protocolFeeReceiver respectively.

  • In _doLiquidation, decrement _mytSharesDeposited by the full amountLiquidated (the gross MYT outflow), i.e., the portion sent to Transmuter and the fee to the liquidator.

Proof of Concept

Proof of Concept

Set-up

  • Foundry; solc 0.8.28; EVM cancun. No RPC/fork required.

Command

Work-flow

PoC 1 - TVL overstatement after _forceRepay/liquidation

  • Create a position: deposit 20,000 MYT shares and mint 12,000 debt.

  • Create a matured redemption (1,000 debt) and poke() the position to earmark.

  • Tighten collateralization bounds to 2.0 so liquidation executes.

  • Record reportedBefore = getTotalUnderlyingValue() and trueBefore = convert(MYT balanceOf(Alchemist)) in debt units.

  • Call liquidate(tokenId); record Transmuter MYT balance delta and yPaid.

  • Record reportedAfter and trueAfter.

  • Assertions: reportedAfter == reportedBefore, trueAfter < trueBefore, drift = reportedAfter - trueAfter > 0.

PoC 2 - Depressed ratio delays scaling in claimRedemption()

  • Set Transmuter fee to 0 for simple accounting.

  • Induce drift as in PoC 1 (create position, earmark, liquidate).

  • Compute two denominators for the ratio: den_used = getTotalUnderlyingValue() + convert(transmuterShares) and den_true = trueTVL + convert(transmuterShares).

  • Compute usedR = totalSyntheticsIssued / den_used and trueR = totalSyntheticsIssued / den_true; verify usedR < trueR.

  • Create a small matured redemption (500 debt) and ensure Transmuter already holds enough MYT to pay it (no redeem() call).

  • Claim and measure debtClaimed from the user’s MYT delta converted back to debt units.

  • Expected in healthy conditions: expectedDebtTrue = 500 (no scaling). Assert debtClaimed == expectedDebtTrue while usedR < trueR, showing scaling activation is delayed by the inflated denominator.

Supporting observation - under‑liquidation at the decision boundary

  • After drift, compute global ratios usedGlobalM (with getTotalUnderlyingValue()) and trueGlobalM (with actual MYT balance).

  • Set globalMinimumCollateralization to a midpoint between them.

  • Call calculateLiquidation(...) twice, once with usedGlobalM and once with trueGlobalM.

  • Expect debtBurnUsed < accountDebt while debtBurnTrue == accountDebt, demonstrating that inflated TVL can reduce liquidation severity.

Output

PoC 1: TVL overstatement after _forceRepay/liquidation

  • Asserts reported TVL (via _mytSharesDeposited) stays constant while true MYT holdings decrease; prints a positive “tvl drift”.

PoC 2: Depressed ratio delays scaling in claimRedemption()

  • Shows usedR < trueR after drift. A small claim remains unscaled, evidencing that the inflated denominator depresses the ratio and delays scaling activation.

Supporting observation

  • Demonstrates that liquidation sizing based on an inflated global collateralization can under‑liquidate compared to the “true” input computed from actual MYT holdings.

Was this helpful?