58396 sc high total locked is not cleared proportionally to the total debt this forces the collateral weight to become incorrect and new users transmuter redeem repayment will repay more debt fo

Submitted on Nov 1st 2025 at 22:58:06 UTC by @Outliers for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #58396

  • Report Type: Smart Contract

  • Report severity: High

  • 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

Description

Brief/Intro

redeem() reduces totalDebt and cumulativeEarmarked correctly, but updates _totalLocked using totalOut (the collateral moved out) instead of reducing _totalLocked proportionally to the debt that was cleared. When the transmuter covers some debt or fees/rounding apply, _totalLocked can remain > 0 even though the corresponding earmarked debt is cleared — leaving stale locked collateral that skews future collateral weight calculations and causes new users to pay less debt than they should.

Vulnerability Details

The system compute survival (i.e. remaining earmarks) based on redeemedDebtTotal / liveEarmarked, and use that to update _survivalAccumulator/weights — but then you reduce _totalLocked by totalOut (an absolute collateral value), which is unrelated to the same debt-proportion factor when cover/fees/rounding differ. The correct semantic is: _totalLocked should be reduced in the same proportion as cumulativeEarmarked/totalDebt was reduced (i.e. scale by (liveEarmarked - redeemedDebtTotal)/liveEarmarked). Collateral-weight bookkeeping must use the actual locked collateral removed (old - new) as the weight increment, not totalOut.

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


        emit cumulativeguy(cumulativeEarmarked);

        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);        // total out is updated 
        uint256 feeCollateral = collRedeemed * protocolFee / BPS;
        uint256 totalOut      = collRedeemed + feeCollateral;

       
      //  uint256 newtotalLocked  = (convertDebtTokensToYield(totalDebt) * minimumCollateralization) / FIXED_POINT_SCALAR;  

      
        // update locked collateral + collateral weight
        uint256 old = _totalLocked;                              

@audit>>>          _totalLocked = totalOut > old ? 0 : old - totalOut; // reduce this with th debt to amount ratio. that is debt in transmuter covered/
 
@audit>>>        _collateralWeight += PositionDecay.WeightIncrement(totalOut > old ? old : totalOut, old);       // test this well

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

        emit Redemption(redeemedDebtTotal);
    }

The residual total locked is not proportional to the present debt of the system. This will cause the next claim from the transmuter that calls redeems leads to an incorrect weight calculation.

In the sync function.

This breaks the expected proportionality between debt and collateral:

  • Example:

    • Before redeem: totalDebt = 500, _totalLocked = 600

    • After redeem: totalDebt = 0, but _totalLocked ≈ 50 (residual left due to incorrect subtraction)

This residual collateral inflates the global collateral weight denominator, lowering the effective collateral weight for new participants. Consequently, subsequent users repaying with transmuter will pay more debt for less collateral, leading to incorrect collateralization and inconsistent system accounting.


Impact Details

  1. Accounting Drift _totalLocked remains higher than intended after redemptions, desynchronizing total collateral from total debt.

  2. Incorrect Collateral Weight The collateral weight becomes incorrectly stated, allowing new users to redeem their debt, repaying with lesser collateral than expected.

References

Proof of Concept

Proof of Concept

User makes away with 16681943171402383119464 instead of 9999999999999999991000, almost a 2x loss for the protocol.

Was this helpful?