57530 sc high stale tvl accounting in liquidations leads to protocol insolvency

Submitted on Oct 27th 2025 at 00:26:52 UTC by @legion for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #57530

  • 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

The AlchemistV3 contract contains a critical accounting flaw where liquidations transfer yield token shares out of the protocol but fail to update the global _mytSharesDeposited counter. This creates a permanent divergence between the protocol's reported Total Value Locked (TVL) and the actual on-chain balance. Because critical protocol operations—including deposit caps, liquidation calculations, and health checks—rely on this inflated TVL number, the system gradually becomes insolvent as liquidations accumulate bad debt while appearing healthy. Users are unable to redeem their synthetic tokens once the transmuter exhausts its real collateral reserves.

Vulnerability Details

The AlchemistV3 contract accepts deposits of yield-bearing tokens (e.g., Morpho vault shares) and allows users to borrow synthetic debt tokens against this collateral. The protocol maintains a single global state variable _mytSharesDeposited to track the total amount of yield token shares held by the contract:

uint256 private _mytSharesDeposited;

This counter is intended to represent the actual on-chain balance of yield tokens that the contract controls. It serves as the foundation for several critical protocol functions:

  1. TVL Reporting: getTotalUnderlyingValue() converts _mytSharesDeposited to underlying token value

  2. Deposit Cap Enforcement: Checks _mytSharesDeposited + amount <= depositCap before accepting deposits

  3. Global Health Metrics: Feeds into calculateLiquidation() to determine protocol-wide collateralization

  4. Risk Assessment: Used by operators and dashboards to monitor protocol solvency

The bug occurs in the way it was used during liquidation, during liquidations; tokens are transferred OUT of the contract but _mytSharesDeposited is never decremented:

*State after liquidation:

  • Contract's actual balance: -amountLiquidated (tokens sent to transmuter + liquidator)

  • _mytSharesDeposited: unchanged (still includes the liquidated tokens)

  • Accounting is now desynchronized by amountLiquidated.

Since the protocol's TVL calculation fully trusts _mytSharesDeposited:

After liquidations occur, this function returns a value higher than the actual contract balance:

Reported TVL = convertYieldTokensToUnderlying(_mytSharesDeposited) Includes tokens already sent to transmuter/liquidators, while Actual TVL = Only counts tokens still in the contract.

Example with Numbers

Initial State:

  • _mytSharesDeposited = 1000e18 shares

  • Actual contract balance = 1000e18 shares

  • Share price = 1:1 for simplicity

  • Reported TVL = 1000 underlying tokens

User deposits and borrows:

  • User deposits 100e18 more shares

  • _mytSharesDeposited = 1100e18

  • User borrows max amount (assuming 200% collateralization)

  • User debt = 50 debt tokens (worth 50 underlying)

Price drops, liquidation triggered:

  • Liquidator calls liquidate(tokenId)

  • amountLiquidated = 60e18 shares calculated

  • feeInYield = 5e18 shares

  • Total removed from contract = 65e18 shares

Transfers executed:

  • 55e18 shares → Transmuter

  • 5e18 shares → Liquidator

  • User's collateralBalance reduced by 60e18

  • Actual contract balance = 1100e18 - 65e18 = 1035e18 shares

BUT:

  • _mytSharesDeposited still = 1100e18

  • Reported TVL = 1100 underlying (inflated by 65)

  • Actual TVL = 1035 underlying

  • Discrepancy = 65 underlying tokens

After just one liquidation, the protocol believes it has 6.3% more collateral than it actually controls. This gap compounds with every subsequent liquidation. since the calculateLiquidation logic checks if the protocol's global collateralization ratio is healthy:

With inflated TVL:

  • Protocol appears healthier than reality

  • Liquidations seize less collateral than needed

  • Under-liquidation leaves bad debt in the system

  • Global collateralization continues degrading

Impact Details

  1. Protocol Insolvency Mechanism:

  • Liquidations appear to restore health but actually accumulate bad debt

  • Global collateralization calculation uses inflated TVL

  • System believes it's overcollateralized when actually undercollateralized

  • No mechanism exists to correct the accounting error retroactively

Consequences:

  • Bad debt accrues silently across multiple liquidation events

  • Eventually, total debt > actual collateral value

  • Transmuter cannot fulfill redemption requests

  • Synthetic token loses peg permanently

  • Users lose funds with no recovery mechanism

  1. Permanent Deposit Denial-of-Service (High)

Mechanism:

Since _mytSharesDeposited never decreases after liquidations:

Example:

  • Deposit cap set to 10,000e18

  • Current _mytSharesDeposited = 9,800e18

  • Actual balance after liquidations = 8,500e18

  • Real capacity available = 1,500e18

  • Reported capacity = 200e18

  • New deposit of 500e18 rejected despite ample real space

Consequences:

  • Protocol cannot accept new capital even when undercollateralized

  • Emergency capital injections during crisis are blocked

  • Strategy migrations fail (require new deposits)

  • System enters "frozen" state where it can only bleed value

  • Recovery becomes impossible without contract upgrade

  1. Under-Liquidation and Cascading Failures (High)

Mechanism: The calculateLiquidation function includes this logic:

With inflated TVL:

  • Global ratio appears: (inflated TVL) / (total debt) = looks healthy

  • Triggers partial liquidation when full liquidation needed

  • Leaves underwater positions in the system

  • Creates a death spiral: a. Under-liquidation → bad debt remains b. Next liquidation also under-liquidates (TVL still inflated) c. Bad debt compounds d. Actual collateralization degrades e. More positions become liquidatable f. Cycle repeats

Consequences:

  • Liquidators seize less collateral than required to restore health

  • Individual accounts left with debt > collateral value

  • Protocol accumulates systemic bad debt

  • Even proper liquidations can't fix earlier under-liquidations

  • Point of no return reached quickly

  1. False Risk Metrics and Failed Monitoring (Medium)

Mechanism:

  • Operators and automated systems call getTotalUnderlyingValue()

  • Dashboards show healthy collateralization ratios

  • Alert thresholds never trigger

  • Governance believes system is functioning normally

Consequences:

  • No early warning of insolvency

  • Risk management decisions based on false data

  • Emergency response delayed until user funds already lost

  • Reputation damage when "healthy" system suddenly fails

  • Regulatory and legal exposure from misleading metrics

References

Primary Source Code:

  • src/AlchemistV3.sol#L134 — Declaration of _mytSharesDeposited state variable

  • src/AlchemistV3.sol#L363-L389deposit() function correctly incrementing counter

  • src/AlchemistV3.sol#L391-L418withdraw() function correctly decrementing counter

  • src/AlchemistV3.sol#L851-L914_doLiquidation() function with missing decrement

  • src/AlchemistV3.sol#L1239-L1242_getTotalUnderlyingValue() trusting stale counter

  • src/AlchemistV3.sol#L869-L876calculateLiquidation() call site using inflated TVL

Proof of Concept

Proof of Concept## Proof of Concept

// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.28;

import {AlchemistV3Test} from "./AlchemistV3.t.sol"; import {IERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {IVaultV2} from "../../lib/vault-v2/src/interfaces/IVaultV2.sol"; import {IMockYieldToken} from "./mocks/MockYieldToken.sol"; import {SafeERC20} from "../libraries/SafeERC20.sol"; import {AlchemistNFTHelper} from "./libraries/AlchemistNFTHelper.sol";

contract AlchemistV3TVLInflationPoC is AlchemistV3Test { /// @dev Demonstrates that _mytSharesDeposited is not decremented when liquidations move /// MYT shares out of the contract, causing the reported TVL to diverge from reality. function testReportedTVLInflatedAfterLiquidation() external { // Prime the mock yield token so the vault has ample supply. vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank();

} //Deposit-cap DoS: once a few liquidations happen the cap stays “full,” so fresh deposits or strategy rotations just brick. //Under-liquidation & systemic insolvency: calculateLiquidation leans on the inflated TVL when deciding whether to escalate to full liquidations. The system ends up believing it’s healthy while the real collateral is gone, leaving bad debt to accumulate. //Operators flying blind: every TVL-derived metric is wrong, so risk dashboards and on-chain checks miss the insolvency until redemptions start failing.

Was this helpful?