57954 sc high lackf of tracking of excess cover in earmark function leads to permanent loss of cover value and stuck user positions

Submitted on Oct 29th 2025 at 15:16:51 UTC by @Tadev for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #57954

  • Report Type: Smart Contract

  • Report severity: High

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

  • Impacts:

    • Permanent freezing of funds

Description

Brief/Intro

The _earmark function is responsible for earmarking debt for redemptions. It is defined as follows:

    /// @dev Earmarks the debt for redemption.
    function _earmark() internal {
        if (totalDebt == 0) return;
        if (block.number <= lastEarmarkBlock) return;

        // Yield the transmuter accumulated since last earmark (cover)
        uint256 transmuterCurrentBalance = TokenUtils.safeBalanceOf(myt, address(transmuter));
        uint256 transmuterDifference = transmuterCurrentBalance > lastTransmuterTokenBalance ? transmuterCurrentBalance - lastTransmuterTokenBalance : 0;

        uint256 amount = ITransmuter(transmuter).queryGraph(lastEarmarkBlock + 1, block.number);

        // Proper saturating subtract in DEBT units
        uint256 coverInDebt = convertYieldTokensToDebt(transmuterDifference);
        amount = amount > coverInDebt ? amount - coverInDebt : 0;

        lastTransmuterTokenBalance = transmuterCurrentBalance;

        uint256 liveUnearmarked = totalDebt - cumulativeEarmarked;
        if (amount > liveUnearmarked) amount = liveUnearmarked;

        if (amount > 0 && liveUnearmarked != 0) {
            // Previous earmark survival
            uint256 previousSurvival = PositionDecay.SurvivalFromWeight(_earmarkWeight);
            if (previousSurvival == 0) previousSurvival = ONE_Q128;

            // Fraction of unearmarked debt being earmarked now in UQ128.128
            uint256 earmarkedFraction = _divQ128(amount, liveUnearmarked);

            _survivalAccumulator += _mulQ128(previousSurvival, earmarkedFraction);
            _earmarkWeight += PositionDecay.WeightIncrement(amount, liveUnearmarked);

            cumulativeEarmarked += amount;
        }

        lastEarmarkBlock = block.number;
    }

We can see that it calculates coverInDebt using transmuterDifference. This coverInDebt value is used to offset earmarking. This means the protocol doesn't earmark debt for the value of coverInDebt. The amount to be earmarked is computed as follows:

The problem arises in the case where coverInDebt > amount. amount will be 0, but the extra cover, i.e. coverInDebt - amount is not stored anywhere for future _earmark call.

This is problematic because it means that cumulativeEarmarked will be inflated over time, leading to user funds being stuck because debt is over-earmarked while it shouldn't.

Vulnerability Details

The provided POC highlights very clearly the vulnerability.

Basically, the lack of tracking of excess cover (coverInDebt - amount in _earmark function) leads to incorrect earmarking.

Let's imagine the following scenario:

  • A few users set a position with collateral and debt

  • A user creates a small redemption in the transmuter

  • During the redemption period, a user repays his debt with MYT tokens, which are sent to the transmuter

  • Next _earmark call for any reason: the amount to be earmarked is reduced to 0 thanks to coverInDebt which is bigger

  • Some time later, another _earmark call: no offset can happen, as transmuterDifference is 0. But there is still MYT tokens that could be used for offsetting earmarked debt. The result is that cumulativeEarmarked will be increased while it is not necessary

Impact Details

The impact of this issue is high as it leads to over-earmarking over time. This means user debt will be stuck and users might be unable to fully withdraw their collateral.

Proof of Concept

Proof of Concept

Please copy paste the following test in AlchemistV3.t.sol file:

The output of this test is:

This PoC demonstrates the bug by:

  • Setting up a position with collateral and debt

  • Creating a small redemption (50e18) in the transmuter

  • Providing excess MYT tokens (200e18) to the transmuter - much more than needed. This simulates repay calls.

  • Move forward and first earmark: The excess cover (150e18 worth) reduces the earmark to 0, but the unused part of the excess cover is not tracked anywhere

  • Move forward and second earmark: earmarking happens entirely as if the previous excess cover never existed

Was this helpful?