57102 sc high tvl overstatement from mytsharesdeposited desync enables softened liquidations no haircut over redemptions transmuter

#57102 [SC-High] TVL Overstatement from _mytSharesDeposited Desync Enables Softened Liquidations & No‑Haircut Over‑Redemptions (Transmuter)

Submitted on Oct 23rd 2025 at 13:37:34 UTC by @cmds for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #57102

  • Report Type: Smart Contract

  • Report severity: High

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

  • Impacts:

    • Protocol insolvency

    • Theft of unclaimed royalties

Description

Brief/Intro

The global TVL counter _mytSharesDeposited is not decremented on two outflow paths—_forceRepay and _doLiquidation. As a result, getTotalUnderlyingValue() overstates TVL, which softens liquidations and lets Transmuter.claimRedemption pay 1:1 without haircut when a haircut is due, enabling over‑redemption and amplifying insolvency risk. getTotalUnderlyingValue() exposes the internal _getTotalUnderlyingValue() (derived from _mytSharesDeposited), and claimRedemption’s denominator directly includes Alchemist TVL.

Vulnerability Details

1.TVL derives from _mytSharesDeposited: the public TVL Overstatement from ... _getTotalUnderlyingValue(), which converts _mytSharesDeposited to underlying value.

2.That TVL feeds the global collateralization used in liquidation math via normalizeUnderlyingTokensToDebt(_getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / totalDebt.

3.Transmuter.claimRedemption’s haircut ratio denominator includes

The on-book TVL (denominator) is inflated → the bad debt ratio is suppressed → the reduction mechanism is often not triggered, leading to 1:1 distribution.

4.Inconsistent accounting (root cause):

— _forceRepay transfers creditToYield to the Transmuter and protocol fee to the fee receiver without decrementing _mytSharesDeposited.

— _doLiquidation transfers amountLiquidated - feeInYield to the Transmuter (and possibly the fee to liquidator) yet also does not decrement _mytSharesDeposited.

Conversely, other outflow paths do decrement it: withdraw reduces _mytSharesDeposited by amount, and redeem reduces it by redeemed amount plus fee.

Similarly, this causes an overestimation of TVL. Reference comparison: Functions such as withdraw() and redeem() correctly decrease _mytSharesDeposited when transferring out. In contrast, repay() only decreases the “fee” portion, since the principal is directly transferred by the user to the transmuter, bypassing the contract. Therefore, only the two aforementioned paths break the consistency.

5.Net effect: when collateral is moved to the Transmuter but _mytSharesDeposited stays unchanged, Alchemist-side TVL remains inflated while the same tokens are also counted on the Transmuter side (yieldTokenBalance)—a denominator “double‑count” that suppresses the bad‑debt ratio and can skip haircuts.

Impact Details

1.Softer/delayed liquidations: overstated global collateralization reduces liquidation amounts or defers them, slowing bad‑debt resolution.

2.No‑haircut over‑redemptions: the denominator inflation can push the ratio below the haircut threshold, enabling 1:1 payouts while under‑collateralized, draining value from other participants and widening the shortfall.

3.Severity mapping: aligns with “Protocol insolvency / Theft of unclaimed yield” (at least High; potentially Critical where predictable 1:1 over‑redemption constitutes direct value extraction).

eg: true state Alchemist=90, Transmuter=10, issuance=102 ⇒ true ratio=102/100=1.02 (haircut required); with the bug Alchemist still records 100 and Transmuter has 10 ⇒ denominator 110, ratio≈102/110=0.927 (no haircut, 1:1 payout).

References

https://github.com/elminnyc99/contest_15_10/blob/18085d275acf0fc53d6e063931fb573ed7a6661a/src/AlchemistV3.sol#L1240-L1242

https://github.com/elminnyc99/contest_15_10/blob/18085d275acf0fc53d6e063931fb573ed7a6661a/src/AlchemistV3.sol#L860-L867

https://github.com/elminnyc99/contest_15_10/blob/18085d275acf0fc53d6e063931fb573ed7a6661a/src/Transmuter.sol#L217-L226

https://github.com/elminnyc99/contest_15_10/blob/18085d275acf0fc53d6e063931fb573ed7a6661a/src/AlchemistV3.sol#L779-L783

https://github.com/elminnyc99/contest_15_10/blob/18085d275acf0fc53d6e063931fb573ed7a6661a/src/AlchemistV3.sol#L877-L882

Proof of Concept

Proof of Concept

run:

Logs:

Step‑by‑step

goal: Show that liquidation/forced‑repay can move MYT out of Alchemist without synchronizing the global counter that backs TVL, i.e., _mytSharesDeposited. This overstates getTotalUnderlyingValue() (TVL), which is then used in liquidation math and in Transmuter.claimRedemption’s bad‑debt denominator—leading to under‑discounted (1:1) redemptions. getTotalUnderlyingValue() proxies to _getTotalUnderlyingValue() (driven by _mytSharesDeposited). Liquidation math reads _getTotalUnderlyingValue() for global normalization. claimRedemption() computes the denominator as alchemist.getTotalUnderlyingValue() + convert(yieldTokenBalance).

Setup:

1.Configure convertToAssets(1e18)=1e18, minimumCollateralization=150%, collateralizationLowerBound=120%.

2.Deploy and wire AlchemistV3 + Transmuter. Note: getTotalUnderlyingValue() uses _mytSharesDeposited (book TVL), while getTotalDeposited() reads the actual MYT balance.

Steps

1.Open position: User A calls deposit(100e18, A, 0), then mint(tokenIdA, 60e18, A) (~166% CR).

2.Trigger liquidation: Push A below collateralizationLowerBound (e.g., lower PPS or increase debt) and have any user B call liquidate(tokenIdA):

Effect: MYT is sent out to transmuter/liquidator, but _mytSharesDeposited is not decreased.

3.Observe divergence:

getTotalDeposited() ≈ 50e18 (real balance). getTotalUnderlyingValue() still ≈ 100e18 (based on stale _mytSharesDeposited) → real < book TVL.

4.Over‑redemption: User C calls claimRedemption(id) on Transmuter: Since the denominator includes alchemist.getTotalUnderlyingValue(), the inflated TVL yields badDebtRatio ≤ 1 → no haircut, payout at 1:1, draining collateral beyond the true capacity.

5.Repeat: Repeat steps 2–4 to keep extracting during the “no‑haircut” window, compounding insolvency.

Pass Criteria:

1.You observe getTotalDeposited() ≪ getTotalUnderlyingValue();

2.claimRedemption pays 1:1 where a haircut should have applied (total redeemed exceeds true collateral support).

fix

1.In after transferring out the underlying (to the transmuter and/or the fee receiver), decrease the global counter to match what actually left the contract:_forceRepay()

2.In after sending out the liquidated underlying, decrease the counter by the amount that left the contract:_doLiquidation()

Was this helpful?