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

**Submitted on Oct 27th 2025 at 23:16:29 UTC by @zeroK for** [**Audit Comp | Alchemix V3**](https://immunefi.com/audit-competition/alchemix-v3-audit-competition)

* **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:

```solidity
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:

```solidity
/// @inheritdoc IAlchemistV3Actions
    function redeem(uint256 amount) external onlyTransmuter {
        _earmark();

        uint256 liveEarmarked = cumulativeEarmarked;
        if (amount > liveEarmarked) amount = liveEarmarked;

        // observed transmuter pre-balance -> potential cover
        uint256 transmuterBal = TokenUtils.safeBalanceOf(myt, address(transmuter));
        uint256 deltaYield    = transmuterBal > lastTransmuterTokenBalance ? transmuterBal - lastTransmuterTokenBalance : 0;
        uint256 coverDebt = convertYieldTokensToDebt(deltaYield);

        // cap cover so we never consume beyond remaining earmarked
        uint256 coverToApplyDebt = amount + coverDebt > liveEarmarked ? (liveEarmarked - amount) : coverDebt;

        uint256 redeemedDebtTotal = amount + coverToApplyDebt;

       // Apply redemption weights/decay to the full amount that left the earmarked bucket
        if (liveEarmarked != 0 && redeemedDebtTotal != 0) {
            uint256 survival = ((liveEarmarked - redeemedDebtTotal) << 128) / liveEarmarked;
            _survivalAccumulator = _mulQ128(_survivalAccumulator, survival);
            _redemptionWeight += PositionDecay.WeightIncrement(redeemedDebtTotal, cumulativeEarmarked);
        }

        // earmarks are reduced by the full redeemed amount (net + cover)
        cumulativeEarmarked -= redeemedDebtTotal;

        // global borrower debt falls by the full redeemed amount
        totalDebt -= redeemedDebtTotal;

        lastRedemptionBlock = block.number;

        // consume the observed cover so it can't be reused
        if (deltaYield != 0) {
            uint256 usedYield = convertDebtTokensToYield(coverToApplyDebt);
            lastTransmuterTokenBalance = transmuterBal > usedYield ? transmuterBal - usedYield : transmuterBal;
        }

        // move only the net collateral + fee
        uint256 collRedeemed  = convertDebtTokensToYield(amount);
        uint256 feeCollateral = collRedeemed * protocolFee / BPS;
        uint256 totalOut      = collRedeemed + feeCollateral;

        // update locked collateral + collateral weight
        uint256 old = _totalLocked;
        _totalLocked = totalOut > old ? 0 : old - totalOut;
        _collateralWeight += PositionDecay.WeightIncrement(totalOut > old ? old : totalOut, old);

        TokenUtils.safeTransfer(myt, transmuter, collRedeemed);
        TokenUtils.safeTransfer(myt, protocolFeeReceiver, feeCollateral);
        _mytSharesDeposited -= collRedeemed + feeCollateral;

        emit Redemption(redeemedDebtTotal);
    }

```

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`

```solidity
/**
 * @notice Simplified POC: Demonstrates amount > cumulativeEarmarked
 * 
 * This test shows the exact moment when the condition triggers by having
 * multiple claims drain the earmarked pool in the same block
 */
function testRedemptionExceedsCumulativeEarmarked_SimplifiedProof() external {
    console.log("\n=== SIMPLIFIED PROOF: amount > cumulativeEarmarked ===\n");
    
    // ========================================
    // PHASE 1: Setup first user
    // ========================================
    uint256 da = 100e18; //deposit amount
    uint256 ba = 50e18; // borrow amount
    
    deal(address(vault), alice, da);
    deal(address(alToken), alice, ba);
    
    console.log("ALICE Setup:"); 
    console.log("- Deposit:", da);
    console.log("- Borrow:", ba);
    
    vm.startPrank(alice);
    IERC20(address(vault)).approve(address(alchemist), da);
    alchemist.deposit(da, alice, 0);
    uint256 aliceTokenId = AlchemistNFTHelper.getFirstTokenId(alice, address(alchemistNFT));
    alchemist.mint(aliceTokenId, ba, alice);
    IERC20(address(alToken)).approve(address(transmuterLogic), ba);
    transmuterLogic.createRedemption(ba);
    vm.stopPrank();
    
    console.log("Created redemption position 1");
    
    // ========================================
    // PHASE 2: Setup second user(bob)
    // ========================================
    
    deal(address(vault), bob, da);
    deal(address(alToken), bob, ba);
    
    console.log("BOB Setup:");
    console.log("- Deposit:", da);
    console.log("- Borrow:", ba);
    
    vm.startPrank(bob);
    IERC20(address(vault)).approve(address(alchemist), da);
    alchemist.deposit(da, bob, 0);
    uint256 bobTokenId = AlchemistNFTHelper.getFirstTokenId(bob, address(alchemistNFT));
    alchemist.mint(bobTokenId, ba, bob);
    IERC20(address(alToken)).approve(address(transmuterLogic), ba);
    transmuterLogic.createRedemption(ba);
    vm.stopPrank();
    
    console.log("Created redemption position 2");
    
    // ========================================
    // PHASE 3: Let redemptions partially mature (small amount)
    // ========================================
    uint256 startBlock = block.number;
    vm.roll(block.number + 100); // Only 100 blocks (tiny maturation)
    
    console.log(" TIME PASSES ");
    console.log("Blocks advanced:", block.number - startBlock);
    console.log("Maturation: 100 / 600000 = 0.0167%");
    
    // Calculate expected mature amounts
    uint256 aliceMatureAmount = (ba * 100) / 600000;
    uint256 bobMatureAmount = (ba * 100) / 600000;
    uint256 totalExpectedMature = aliceMatureAmount + bobMatureAmount;
    
    console.log("\nExpected mature amounts:");
    console.log("- Alice:", aliceMatureAmount);
    console.log("- Bob:", bobMatureAmount);
    console.log("- Total expected:", totalExpectedMature);
    
    // ========================================
    // PHASE 4: Alice claims first
    // ========================================
    console.log("ALICE CLAIMS (Block", block.number, ")");
    
    uint256 cumulativeBefore = alchemist.cumulativeEarmarked();
    uint256 totalDebtBefore = alchemist.totalDebt();
    
    console.log("Before Alice's claim:");
    console.log("- cumulativeEarmarked :", cumulativeBefore);
    console.log("- totalDebt:", totalDebtBefore);
    
    vm.prank(alice);
    transmuterLogic.claimRedemption(1);
    
    uint256 cumulativeAfterAlice = alchemist.cumulativeEarmarked();
    uint256 totalDebtAfterAlice = alchemist.totalDebt();
    uint256 aliceReceived = IERC20(address(vault)).balanceOf(alice);
    
    console.log("After Alice's claim:");
    console.log("- cumulativeEarmarked:", cumulativeAfterAlice);
    console.log("- totalDebt:", totalDebtAfterAlice);
    console.log("- Alice received:", aliceReceived);
    console.log("- Debt reduced by:", totalDebtBefore - totalDebtAfterAlice);
    
    // ========================================
    // PHASE 5: Bob claims SAME BLOCK
    // ========================================
    console.log(" BOB CLAIMS (SAME BLOCK)");
    console.log(" Since it's the same block, _earmark() will NOT run again!");
    console.log("(Condition: block.number <= lastEarmarkBlock will be true)");

    
    uint256 liveEarmarkedBeforeBob = cumulativeAfterAlice;
    
    console.log("\nBefore Bob's claim:");
    console.log("- cumulativeEarmarked available:", liveEarmarkedBeforeBob);
    console.log("- Bob's mature amount:", bobMatureAmount);
    
    // proof
    
    console.log("CHECKING CONDITION");
    if (bobMatureAmount > liveEarmarkedBeforeBob) {
        console.log("YES! Bob's amount");
        console.log("The cap WILL trigger! <<<");
    } else {
        console.log("Bob's amount:", bobMatureAmount);
        console.log("Available earmarked:", liveEarmarkedBeforeBob);
        console.log("Checking if they're close...");
    }
    
    vm.prank(bob);
    transmuterLogic.claimRedemption(2);
    
    uint256 bobReceived = IERC20(address(vault)).balanceOf(bob);
    uint256 cumulativeAfterBob = alchemist.cumulativeEarmarked();
    
    console.log("\nAfter Bob's claim:");
    console.log("- Bob received:", bobReceived);
    console.log("- cumulativeEarmarked:", cumulativeAfterBob);
    
    // ========================================
    // ANALYSIS
    // ========================================
    console.log("\n=== ANALYSIS ===");
    console.log("Total both users should have received:", totalExpectedMature);
    console.log("Alice actually received:", aliceReceived);
    console.log("Bob actually received:", bobReceived);
    console.log("Total actually received:", aliceReceived + bobReceived);
    
    // Check if the condition was met
    console.log("\n=== PROOF STATUS ===");
    
    // The system uses rounding, so check approximately
    if (bobReceived < bobMatureAmount * 90 / 100) {
        console.log("Bob received significantly less than his mature amount!");
        console.log("This proves: amount > liveEarmarked triggered and capped the redemption");
    } else if (cumulativeAfterBob == 0) {
        console.log("cumulativeEarmarked was completely drained to 0!");
        console.log("This proves the cap mechanism works when pool is exhausted");
    } else {
        console.log("Edge case: Both users got their amounts");
        console.log("This can happen with tiny mature amounts and rounding");
        console.log("The cap logic still exists and would trigger with larger amounts");
    }
    console.log("------------END PROOF--------------");
}




```


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://reports.immunefi.com/alchemix-v3/57662-sc-critical-portion-of-users-alasset-amount-that-staked-in-transmuter-can-be-lost-forever-when.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
