58530 sc high protocol insolvency via stale totallocked zeroed totallocked prevents collateralweight update in redeem leading to missed collateral haircut

Submitted on Nov 3rd 2025 at 03:18:16 UTC by @x0xmechanic for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #58530

  • 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 MYT price changes, _totalLocked does not track the true aggregate of users’ rawLocked (which depends on price). This desynchronization allows a later burn to free more collateral than _totalLocked thinks exists; the code clamps and sets toFree = _totalLocked, pushing _totalLocked to zero. With _totalLocked == 0, redeem() applies zero collateral weight increment, so no user collateral is written down while MYT is transferred out to the transmuter. The system becomes insolvent and produces a race: first fully-repaid user can withdraw in full; later users cannot.

Vulnerability Details

The drift between _totalLocked and the sum of users’ rawLocked starts in _addDebt where the account's rawLocked is fully re-calculated to the current MYT price, but the global _totalLocked is not:

function _addDebt(uint256 tokenId, uint256 amount) internal {
        Account storage account = _accounts[tokenId];

        // Update collateral variables
        uint256 toLock = convertDebtTokensToYield(amount) * minimumCollateralization / FIXED_POINT_SCALAR;
        uint256 lockedCollateral = convertDebtTokensToYield(account.debt) * minimumCollateralization / FIXED_POINT_SCALAR;

        if (account.collateralBalance - lockedCollateral < toLock) revert Undercollateralized();

        account.rawLocked = lockedCollateral + toLock; // reindexed to *current* price
        _totalLocked += toLock;                        // only adds the *increment*
        account.debt += amount;
        totalDebt += amount;
    }

When MYT price moves between mints, convertDebtTokensToYield(account.debt) changes, so the account’s rawLocked jumps by the reindex delta Δ_i = lockedCollateral_new − lockedCollateral_prev. The code overwrites account.rawLocked to the new amount but _totalLocked is only increased by toLock (for the new debt) and does not correct for the old debt. Summing across users, the sum of rawLocked includes all these reindex deltas, while _totalLocked misses them; the gap accumulates and _totalLocked becomes different than the sum of the users' rawLocked.

Because of this drift, _totalLocked can fall below a single user’s rawLocked. Then, when that user calls burn, _subDebt computes toFree but clamps it to _totalLocked (we show all of this in the PoC):

Because _totalLocked is too small (stale), a normal burn for a healthy account can have toFree > _totalLocked. The clamp sets toFree = _totalLocked, and then _totalLocked -= toFree makes _totalLocked == 0. This is not expressing “no more lock exists globally”; it’s an artifact of drift.

Then, when a redemption happens, redeem misses the collateral haircut when _totalLocked == 0 because redeem() computes the increment to _collateralWeight relative to old = _totalLocked:

If old == 0, then WeightIncrement contributes zero. The redemption sends MYT out:

but does not increase _collateralWeight. Consequently, _sync() applies no collateral removal:

Therefore, per-user collateralBalance stays overstated (book), while contract's MYT decreased.

Withdraw enforces only the caller’s lock, not overall contract solvency, because withdraw() checks the user’s own lock before transferring MYT. There’s no guard that sum collateralBalance ≤ contract MYT. After a missed haircut, the first fully-repaid user can withdraw their full (stale) collateralBalance; later users’ attempts revert with ERC20 underflow.

PoC Walkthrough:

  1. Two users (beef and user1) deposit and mint; the Alchemist holds ~200k MYT total, each user’s collateralBalance is ~100k.

  2. The MYT price changes via a supply increase.

  3. Users mint again at the new price.

  4. Because _addDebt only adds toLock for the delta and never reindexes past debt, _totalLocked drifts below the true sum of rawLocked.

  5. A subsequent burn by beef clamps in _subDebt (toFree = min(toFree, _totalLocked)), which drives _totalLocked → 0 due to the drift.

  6. A redemption executes with old == 0, so no _collateralWeight update occurs; MYT exits the contract but per-user books remain unchanged: contract MYT ≈ 198k, users still show 100k + 100k.

  7. Beef (after repay and debt=0) withdraws 100k → succeeds; contract MYT drops to ~98k.

  8. User1 (after repay and debt=0) then tries to withdraw 100kreverts (panic 0x11) since only ~98k remain in the contract.

Impact Details

Impact category: Critical - Protocol insolvency. Any redemption executed after _totalLocked has been artificially zeroed will transfer MYT out without writing down users’ collateralBalance. The resulting shortfall is at least the redemption amount + fee for that event, and can compound across events.

First-withdrawer advantage: A repaid user can extract their full book amount. Later users’ withdrawals revert, even with zero debt.

References

Add any relevant links to documentation or code

Proof of Concept

Proof of Concept

We provided the PoC walk-through in the Description section.

We use exactly the same setup as in the contract AlchemistV3Test.

The logs are:

The test is:

Note: We also made _totalLocked public in AlchemistV3 so that we can read it in the test.

The logs with public _totalLocked are:

The test with public _totalLocked is:

Was this helpful?