56827 sc high missing global earmark reduction in forcerepay

Submitted on Oct 21st 2025 at 02:23:58 UTC by @Petrus for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #56827

  • 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

cumulativeEarmarked becomes inflated relative to the actual sum of user earmarks. Subsequent redeem calls (by the transmuter) can over-reduce totalDebt (since redeem subtracts from cumulativeEarmarked without per-account adjustments until _sync). This could allow excessive debt reduction, leading to undercollateralization globally and incorrect survival ratios in _sync. Because, during liquidation When an account has earmarked debt, _liquidate calls _forceRepay to repay it using the account's collateral. This reduces the account's debt (via _subDebt) and earmarked but fails to reduce the global cumulativeEarmarked. This decouples per-account earmarks from the global total.

Vulnerability Details

In the AlchemistV3.sol,

During liquidation,

function _liquidate(uint256 accountId) internal returns (uint256 amountLiquidated, uint256 feeInYield, uint256 feeInUnderlying) { 

        // Query transmuter and earmark global debt 

        _earmark(); 

        // Sync current user debt before deciding how much needs to be liquidated 

        _sync(accountId); 

 

        Account storage account = _accounts[accountId]; 

 

        // Early return if no debt exists 

        if (account.debt == 0) { 

            return (0, 0, 0); 

        } 

 

        // In the rare scenario where 1 share is worth 0 underlying asset 

        if (IVaultV2(myt).convertToAssets(1e18) == 0) { 

            return (0, 0, 0); 

        } 

 

        // Calculate initial collateralization ratio 

        uint256 collateralInUnderlying = totalValue(accountId); 

        uint256 collateralizationRatio = collateralInUnderlying * FIXED_POINT_SCALAR / account.debt; 

 

        // If account is healthy, nothing to liquidate 

        if (collateralizationRatio > collateralizationLowerBound) { 

            return (0, 0, 0); 

        } 

 

        // Try to repay earmarked debt if it exists 

        uint256 repaidAmountInYield = 0; 

        if (account.earmarked > 0) { 

      @>      repaidAmountInYield = _forceRepay(accountId, account.earmarked); 

        } 

        // If debt is fully cleared, return with only the repaid amount, no liquidation needed, caller receives repayment fee 

        if (account.debt == 0) { 

            feeInYield = _resolveRepaymentFee(accountId, repaidAmountInYield); 

            TokenUtils.safeTransfer(myt, msg.sender, feeInYield); 

            return (repaidAmountInYield, feeInYield, 0); 

        } 

 

        // Recalculate ratio after any repayment to determine if further liquidation is needed 

        collateralInUnderlying = totalValue(accountId); 

        collateralizationRatio = collateralInUnderlying * FIXED_POINT_SCALAR / account.debt; 

 

        if (collateralizationRatio <= collateralizationLowerBound) { 

            // Do actual liquidation 

            return _doLiquidation(accountId, collateralInUnderlying, repaidAmountInYield); 

        } else { 

            // Since only a repayment happened, send repayment fee to caller 

            feeInYield = _resolveRepaymentFee(accountId, repaidAmountInYield); 

            TokenUtils.safeTransfer(myt, msg.sender, feeInYield); 

            return (repaidAmountInYield, feeInYield, 0); 

        } 

    } 

when an account has earmarked debt, _liquidate () calls _forceRepay to repay it using the account's collateral.

In the _forceRepay (),

It reduces the account's earmarked debt (account.earmarked -= earmarkToRemove;) and calls _subDebt to reduce totalDebt, but it does not reduce the global cumulativeEarmarked variable, causing the global earmark total to remain inflated compared to the actual sum of user earmarks.

Impact Details

This issue causes cumulativeEarmarked to overstate total earmarked debt, leading to excessive debt reductions in redeem calls and potential global undercollateralization drift during liquidations.

soln

In the _forceRepay () after account.earmarked -= earmarkToRemove;, add:

References

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

Proof of Concept

Proof of Concept

Add the test in the src/test/AlchemistV3.t.sol

And run with forge test --match-test testLiquidation_ForceRepay_Does_Not_Reduce_CumulativeEarmarked -vvvv

My POC brief explaination of the issue The Invariant Violation:

Before liquidation: earmarkedBefore = 90e22 and cumulativeEarmarkedBefore = 90e22 After liquidation: earmarkedAfter = 0 but cumulativeEarmarkedAfter = 90e22

The Bug: When _forceRepay() is called during liquidation:

It correctly reduces account.earmarked from 90e22 to 0 It does not reduce cumulativeEarmarked - it stays at 90e22

What should happen: Both should decrease by the same amount (90e22), resulting in:

earmarkedAfter = 0 cumulativeEarmarkedAfter = 0

What actually happens:

earmarkedAfter = 0 cumulativeEarmarkedAfter = 90e22 (unchanged)

The test assertion assertTrue(cumulativeEarmarkedReduction < earmarkedRepaid) passes because 0 < 90e22 is true, which mathematically proves the global counter was not updated. Impact: The global cumulativeEarmarked is now inflated relative to the sum of individual user earmarks. On the next redemption or earmarking operation, this stale value will cause incorrect debt calculations.

Was this helpful?