57093 sc critical potential locked funds due to partial redeem shortfall and miss calculation lead to user loss their myt token forever

Submitted on Oct 23rd 2025 at 12:28:40 UTC by @aua_oo7 for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #57093

  • Report Type: Smart Contract

  • Report severity: Critical

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

  • Impacts:

    • Permanent freezing of funds

Description

Summary:

The redeemption incorrect logic can permanently lock a portion of yield tokens (myt) inside the Transmuter contract when totalYield < scaledTransmuted due to redeem shortfalls, lead to permanent loss for user.

Description:

When transmuter yield token balance is less than user transmuted amount the tranmuter call redeem function in AlchemistV3 and redeem myt token. After redeeming from the AlchemistV3 the available amount myt token only send to user according to transmuted amount the shortfall amount is not transfer to user and user loss it forever due to wrong calculation, the code sets:

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;
        }

// @audit after redeem when the totalYield that is transmuted amount of user less then after redeeming then only available amount myt
// will transmute to user the remaining portion is not transfer to user it remain in contract but for user the hole scaledTransmuted amount is burn. 
@>        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];
    }

This mean when the (myt) token balance of transmuter is less than the scaledTransmuter then the redeem happen to increase the myt token balance of tranmuter. after redeem the the transmuter balance is check against the scaledTransmuter amount if it's still less than scaledTransmuter amount then only avialable myt token will be send to user. The main problem is that the extra amount from available balance which is user synthetic token, is not transfer back to user with amountNottransmuted amount. The function burn hole amountTransumted which also include that shortfall amount. user will loss that amount of synthetic token forever due the wrong and miscalculation logic of function, below in code i provide comment for better understanding.

The function at end transfer only available myt compare to user scaledTransmuter amount transfer to user but burn complete amount for user.

That remaining amount of scaledTransmuted amount(after transmuter redeeming if still the myt balance of transmuter is less than scaledTransmuter(totalYield in myt format)) that shortfall amount is not transfer as a syntheticToken to user back, this will lead to user loss that amount forever. this is completely wrong and lead to significant loss for user when using the transmuter contract for transmution.

Impact:

Users suffer partial redeemption loss and lead to loss of thier synthetic token forever. this will disincentivize user to use this contract.

Mitigation step

Add the following code into claimRedemption function to correct the logic and behavior of function.

Proof of Concept

Proof of Concept

Was this helpful?