57662 sc critical portion of users alasset amount that staked in transmuter can be lost forever when amount cumulativeearmarked

Submitted on Oct 27th 2025 at 23:16:29 UTC by @zeroK for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #57662

  • Report Type: Smart Contract

  • Report severity: Critical

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

  • Impacts:

    • Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

    • Permanent freezing of funds

Description

Brief/Intro

The Transmuter contract allows users to stake their alAsset tokens for a fixed lock period (currently 3 months), After maturity, users can call claimRedemption to receive their share of MYT tokens, either partially or in full, this mechanism plays a key role in the Alchemix V3 protocol’s redemption flow. However, there’s a critical flaw that can lead to the loss or permanent lock of users’ funds. When claimRedemption triggers a call to AlchemistV3.sol#redeem(), if the Transmuter does not hold enough MYT tokens to fulfill the redemption, the function applies the check if (amount > liveEarmarked) amount = liveEarmarked this caps the amount to the current earmarked balance, while this is intended to safely handle the case where the Transmuter temporarily lacks sufficient MYT, it introduces a severe issue, the difference between the requested amount and the capped amount remains locked in the Transmuter, while the user’s redemption position is burned, resulting in irrecoverable loss of the remainder funds of the caller/staker.

Vulnerability Details

first thing users need to invoke call to createRedemption to create claim request and stake their alAsset, and then invoke call to claimRedemption, which allow calling from users even if maturity not reached:

function claimRedemption(uint256 id) external {
        StakingPosition storage position = _positions[id];

        if (position.maturationBlock == 0) {
            revert PositionNotFound();
        }

        if (position.startBlock == block.number) {
            revert PrematureClaim();
        }

        uint256 transmutationTime = position.maturationBlock - position.startBlock;
        uint256 blocksLeft = position.maturationBlock > block.number ? position.maturationBlock - block.number : 0;
        uint256 rounded = position.amount * blocksLeft / transmutationTime + (position.amount * blocksLeft % transmutationTime == 0 ? 0 : 1);
        uint256 amountNottransmuted = blocksLeft > 0 ? rounded : 0;
        uint256 amountTransmuted = position.amount - amountNottransmuted;

        if (_requireOwned(id) != msg.sender) {
            revert CallerNotOwner();
        }

        // Burn position NFT
        _burn(id);
        
        // Ratio of total synthetics issued by the alchemist / underlingying value of collateral stored in the alchemist
        // If the system experiences bad debt we use this ratio to scale back the value of yield tokens that are transmuted
        uint256 yieldTokenBalance = TokenUtils.safeBalanceOf(alchemist.myt(), address(this));
        // Avoid divide by 0
        uint256 denominator = alchemist.getTotalUnderlyingValue() + alchemist.convertYieldTokensToUnderlying(yieldTokenBalance) > 0 ? alchemist.getTotalUnderlyingValue() + alchemist.convertYieldTokensToUnderlying(yieldTokenBalance) : 1;
        uint256 badDebtRatio = alchemist.totalSyntheticsIssued() * 10**TokenUtils.expectDecimals(alchemist.underlyingToken()) / denominator;

        uint256 scaledTransmuted = amountTransmuted;

        if (badDebtRatio > 1e18) {
            scaledTransmuted = amountTransmuted * FIXED_POINT_SCALAR / badDebtRatio;
        }

        // If the contract has a balance of yield tokens from alchemist repayments then we only need to redeem partial or none from Alchemist earmarked
        uint256 debtValue = alchemist.convertYieldTokensToDebt(yieldTokenBalance);
        uint256 amountToRedeem = scaledTransmuted > debtValue ? scaledTransmuted - debtValue : 0;

        if (amountToRedeem > 0) alchemist.redeem(amountToRedeem);

        uint256 totalYield = alchemist.convertDebtTokensToYield(scaledTransmuted);

        // Cap to what we actually hold now (handles redeem() rounding shortfalls).
        uint256 balAfterRedeem = TokenUtils.safeBalanceOf(alchemist.myt(), address(this));
        uint256 distributable = totalYield <= balAfterRedeem ? totalYield : balAfterRedeem;

        // Split distributable amount. Round fee down; claimant gets the remainder.
        uint256 feeYield = distributable * transmutationFee / BPS;
        uint256 claimYield = distributable - feeYield;

        uint256 syntheticFee = amountNottransmuted * exitFee / BPS;
        uint256 syntheticReturned = amountNottransmuted - syntheticFee;

        // Remove untransmuted amount from the staking graph
        if (blocksLeft > 0) _updateStakingGraph(-position.amount.toInt256() * BLOCK_SCALING_FACTOR / transmutationTime.toInt256(), blocksLeft);

        TokenUtils.safeTransfer(alchemist.myt(), msg.sender, claimYield);
        TokenUtils.safeTransfer(alchemist.myt(), protocolFeeReceiver, feeYield);

        TokenUtils.safeTransfer(syntheticToken, msg.sender, syntheticReturned);
        TokenUtils.safeTransfer(syntheticToken, protocolFeeReceiver, syntheticFee);

        // Burn remaining synths that were not returned
        TokenUtils.safeBurn(syntheticToken, amountTransmuted);
        alchemist.reduceSyntheticsIssued(amountTransmuted);
        alchemist.setTransmuterTokenBalance(TokenUtils.safeBalanceOf(alchemist.myt(), address(this)));

        totalLocked -= position.amount;

        emit PositionClaimed(msg.sender, claimYield, syntheticReturned);

        delete _positions[id];
    }

many things in this function worth notice, first thing is how the amount get calculated depending on how many blocks passed since the position got created, second thing is how the function invoke call to redeem function when transmuter does not hold enough myt(in our case we say it hold zero myt for simplicity) and third thing how the position get deleted Permanently. we focus on the line that lead to stuck/lose of users funds which is the execution of redeem function, which it sets the amount we request to redeem(the amount of myt token we ask to transfer to transmuter) to liveEarmarked amount or cumulativeEarmarked as shown below and then transfer it to transmuter:

even invoking earmark will not prevent the issue directly, in this case i show one scenario where the two transaction occur in one block, however the core issue of this report is possible to happen in many other scenario and most important of them is when there is a load on redemptions. let's imagine the steps below occur:

  • user A creates request redeem for full debt amount which is 50 debt token

  • transmuter holds zero myt token.

  • users won't wait and decide to invoke claimRedemption after 100 block passed.

  • while there is no current myt token in transmuter, the redeem function invoked, the cumulativEarmark after invoking the _earmark is equal to half of what user A requested.

  • user A requested 50 token to claim, the amount for 100 blocks is 8.9 token for example, and cumulative is 5 token, then the transmuter receive 5 tokens.

  • user A other 4.9 token will get lost, because claim redemption function does not account for the scenario that amount > cumulative earmark since users can claim way before maturity.

this became more clear and real when we have two users claim in same block as shown in the POC, the impact is losing of users funds due to setting amount == cumulative and ignoring the rest of the amount and letting it stuck in transmuter(because the position id will be burn and deleted later)

Impact Details

lose and freezing of users funds due to business logic flow in claim redemption flow.

References

https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/Transmuter.sol#L191C5-L266C6 https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L588C5-L641C6

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

https://keenanlukeom.github.io/alchemix-v3-docs/dev/transmuter/transmuter-contract

Proof of Concept

Proof of Concept

add test below in alchemistV3.t.sol here: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/test/AlchemistV3.t.sol

and run forge test --match-test testRedemptionExceedsCumulativeEarmarked_SimplifiedProof -vvv

Was this helpful?