58337 sc high incorrect handling of cumulativeearmarked in forcerepay leads to inflated survival accumulator

#58337 [SC-High] Incorrect Handling of cumulativeEarmarked in _forceRepay leads to inflated survival accumulator.

Submitted on Nov 1st 2025 at 11:22:49 UTC by @Bizarro for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #58337

  • Report Type: Smart Contract

  • Report severity: High

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

  • Impacts:

    • Protocol insolvency

Description

Brief/Intro

In the _forceRepay function, when a liquidator liquidates a user, the user's individial earmarked amount is reduced but the global cumulativeEarmarked variable is not reduced. This leads to an inflated cumulativeEarmarked value, when redemptions occur, which in turn causes the _survivalAccumulator to be incorrectly calculated.

Vulnerability Details

When a liquidator liquidates a user's position, the liquidate function is called which internally calls the _liquidate fucntion, In the liquidate function if the user's collateraleralizaionRation is less than the collateralizationLowerBound, the function calls the _forceRepay to repay the earmarked position of the user. Inside the _forceRepay function user's collateral is reduced by the earmarked amount + fee amount and the user's earmarked amount is set to 0, but here the cumulativeEarmaked which globally tracks the earmarked amount is not updated which leads to inflated cumulativeEarmaked amount. The main problem lies in the redeem function because at the time of redemption, the cumulativeEarmarked is used to calculate the new _survivalAccumulator which signifies the fraction of earmarked debt that has survived redemption. and as the cumulativeEarmarked is wrong as well as the _survivalAccumulator.

function _forceRepay(uint256 accountId, uint256 amount) internal returns (uint256) {
        if (amount == 0) {
            return 0;
        }
        _checkForValidAccountId(accountId);
        Account storage account = _accounts[accountId];

        // Query transmuter and earmark global debt
        _earmark();

        // Sync current user debt before deciding how much is available to be repaid
        _sync(accountId);

        uint256 debt;

        // Burning yieldTokens will pay off all types of debt
        _checkState((debt = account.debt) > 0);

        uint256 credit = amount > debt ? debt : amount;
        uint256 creditToYield = convertDebtTokensToYield(credit);
        _subDebt(accountId, credit);

        // Repay debt from earmarked amount of debt first
        uint256 earmarkToRemove = credit > account.earmarked ? account.earmarked : credit;
@1>        account.earmarked -= earmarkToRemove;

        creditToYield = creditToYield > account.collateralBalance ? account.collateralBalance : creditToYield;
        account.collateralBalance -= creditToYield;

        uint256 protocolFeeTotal = creditToYield * protocolFee / BPS;

        emit ForceRepay(accountId, amount, creditToYield, protocolFeeTotal);

        if (account.collateralBalance > protocolFeeTotal) {
            account.collateralBalance -= protocolFeeTotal;
            // Transfer the protocol fee to the protocol fee receiver
            TokenUtils.safeTransfer(myt, protocolFeeReceiver, protocolFeeTotal);
        }

        if (creditToYield > 0) {
            // Transfer the repaid tokens from the account to the transmuter.
            TokenUtils.safeTransfer(myt, address(transmuter), creditToYield);
        }
        return creditToYield;
    }

function redeem(uint256 amount) external onlyTransmuter {
        _earmark();

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

        ---

        uint256 redeemedDebtTotal = amount + coverToApplyDebt;

       // Apply redemption weights/decay to the full amount that left the earmarked bucket
        if (liveEarmarked != 0 && redeemedDebtTotal != 0) {
@3>            uint256 survival = ((liveEarmarked - redeemedDebtTotal) << 128) / liveEarmarked;
@4>            _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;

        ---

        emit Redemption(redeemedDebtTotal);
    }
  • @1 -> here user's earmarked amount is redeuced but the cumulativeEarmark amount is not.

  • @2 -> here liveEarmark is cumulativeEarmark

  • @3 -> here survival is calculated as (liveEarmarked - redeemedDebtTotal) / liveEarmarked

  • @4 -> wrong _survivalAccumulator is set because of inflated liveEarmarked.

Impact Details

  1. since the cumulativeEarmarked is too high, the survival ratio decreases more than it should. All remaining user share are reduced unfairly.

  2. Protocol ends up transferring more mytTokens to the transmuter than it should because of increased cumulativeEarmarked causing protocol to transfer unearmarked amount to the transmuter.

Attack Path

  1. System starts with 1e6 tokens and 9e5 earmarked.

  2. user liqudiator for 1e5 eamarked, but cumulativeEarmarked stays at 9e5(should be 8e5).

  3. On redemption, the system uses 9e5 in the denominator, so everyone's claim shrinks by more than 1/9th instead of 1/8th.

  4. After several rounds, the sum of all user claims + protocol liabilities exceeds the vault's real assets.

  5. Users cannot withdraw all the valut they are owed, even though no explicit hack or loss happened.

References

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

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

Proof of Concept

paste this test in test/AlchemistV3.t.sol and run forge test --mt test_cumulativeEarmarked_notReduced -vvv

Was this helpful?