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 V3
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?