58763 sc high accounting is broken when redeem is bypassed due to transmuter balance

Submitted on Nov 4th 2025 at 12:17:03 UTC by @OxPhantom for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #58763

  • Report Type: Smart Contract

  • Report severity: High

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

  • Impacts:

    • Permanent freezing of funds

Description

Brief/Intro

When Transmuter.claimRedemption() detects that the Transmuter already holds enough MYT to satisfy a matured claim, it skips calling AlchemistV3.redeem(). This pays the user from the Transmuter balance but does not reduce cumulativeEarmarked, does not increase _redemptionWeight, and does not reduce global totalDebt. Accounting remains stale, inflating locked collateral and potentially blocking withdraw() even though the user’s claim was fulfilled.

Vulnerability Details

High-level flow:

  • Users create positions in Transmuter and later call claimRedemption().

  • AlchemistV3.redeem() is the only path that applies redemption decay/weights, decrements cumulativeEarmarked, and reduces global totalDebt.

  • Transmuter.claimRedemption() conditionally calls redeem(). If the Transmuter’s MYT balance already covers the matured amount, it bypasses redeem() and pays from its own balance.

Key branch in Transmuter.claimRedemption() that skips redeem() when coverage is available locally:

Consequence of bypass: the Alchemist’s accounting is not updated.

What AlchemistV3.redeem() would have done (and is skipped when amountToRedeem == 0):

Because redeem() is the only place where cumulativeEarmarked is reduced and _redemptionWeight is increased for redemptions, skipping it leaves the earmarked bucket and weights unchanged while the user is still paid from the Transmuter’s existing balance. Transmuter then burns synths and updates the pointer (setTransmuterTokenBalance()), but that does not backfill the missing Alchemist-side accounting.

If there is a redemption position and the transmuter doesn't have the required balance and some repayments occur in the same block before the user calls claimRedemption, and these repayments are sufficient to cover the redemption, then the redeem function will not be called. The problem is that all the earmarked debt will not be decreased by the new transmuter balance, even though it is already covered. This breaks the protocol’s accounting.

Downstream effects on withdrawals: withdraw() relies on up-to-date account.debt and lockedCollateral = convertDebtTokensToYield(account.debt) * minimumCollateralization / 1e18. Since _redemptionWeight wasn’t advanced and cumulativeEarmarked wasn’t reduced, _sync() applies less redemption to user accounts and account.debt stays higher than it should. This keeps lockedCollateral inflated and can cause withdraw() to revert even after a user’s redemption has effectively been fulfilled from Transmuter balance.

As a result, the user receives 100% of the collateral from their redemption, but the protocol remains overcollateralized. For instance if the minimum collateralization ratio is equal to 120%, then 20% of the collateral will remain stuck in the Alchemist contract.

Relevant snippets:

Root cause: Transmuter.claimRedemption() conditionally bypasses AlchemistV3.redeem() when amountToRedeem == 0, causing Alchemist’s redemption-side accounting (cumulativeEarmarked, _redemptionWeight, totalDebt) to remain unmodified even though the user’s redemption was served from Transmuter balance.

Impact Details

  • Accounting desync: cumulativeEarmarked not reduced; _redemptionWeight not increased; totalDebt not reduced.

  • Inflated account.debt after _sync(), inflating lockedCollateral and potentially reverting withdraw().

  • System appears more indebted/earmarked than reality; follow-on flows (e.g., earmark clamping, decay attribution) skewed.

References

https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/Transmuter.sol#L230-L232

https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L592-L603

https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L632-L634

https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L606-L610

Proof of Concept

Proof of Concept

You can run the coded POC by copy pasting this code in the IntegrationTest.t.sol and running forge test --mt test_claimRedemption_locked_POC

Was this helpful?