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 V3
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
Transmuterand later callclaimRedemption().AlchemistV3.redeem()is the only path that applies redemption decay/weights, decrementscumulativeEarmarked, and reduces globaltotalDebt.Transmuter.claimRedemption()conditionally callsredeem(). If the Transmuter’s MYT balance already covers the matured amount, it bypassesredeem()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:
cumulativeEarmarkednot reduced;_redemptionWeightnot increased;totalDebtnot reduced.Inflated
account.debtafter_sync(), inflatinglockedCollateraland potentially revertingwithdraw().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?