56923 sc high missing cumulativeearmarked update in forcerepay causes incorrect debt accounting in alchemistv3

Submitted on Oct 21st 2025 at 19:49:31 UTC by @godwinudo for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #56923

  • Report Type: Smart Contract

  • Report severity: High

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

  • Impacts:

    • Contract fails to deliver promised returns, but doesn't lose value

Description

Brief/Intro

The _forceRepay function in the AlchemistV3.sol contract fails to update the global cumulativeEarmarked variable when repaying earmarked debt during liquidations, causing an inflated value that disrupts debt accounting. This leads to incorrect liveUnearmarked calculations, skewing debt earmarking and collateral allocation. In normal operations with high totalDebt, the issue persists without triggering the clamping mechanism in _subDebt.

Vulnerability Details

The protocol tracks debt in two ways: totalDebt (total outstanding debt across all positions) and cumulativeEarmarked (total debt earmarked for redemption). Each position has a local account.earmarked value, representing the portion of its debt earmarked for repayment. The liveUnearmarked value (totalDebt - cumulativeEarmarked) is used to calculate how much new debt can be earmarked for redemption in the _earmark function.

The _forceRepay function is designed to repay a position’s debt, prioritizing earmarked debt, using the position’s collateral.

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

    _earmark();
    _sync(accountId);

    uint256 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;
    account.earmarked -= earmarkToRemove;

    // ... (rest of the function handles collateral and fees)
}

The function calculates earmarkToRemove, the amount of earmarked debt to repay (capped at account.earmarked). It reduces account.earmarked by earmarkToRemove, correctly updating the position’s local state. Then, it calls _subDebt to reduce account.debt and totalDebt by credit.

However, it does not reduce cumulativeEarmarked, leaving the global earmarked debt inflated.

For comparison, the repay function (used for voluntary repayments) correctly updates both local and global earmark values:

The absence of cumulativeEarmarked -= earmarkPaidGlobal in _forceRepay means that when earmarked debt is repaid during liquidation, the global cumulativeEarmarked remains unchanged, overstating the total earmarked debt.

The _subDebt function, called by _forceRepay, includes a clamping mechanism to prevent cumulativeEarmarked from exceeding totalDebt:

After reducing totalDebt, the clamp ensures cumulativeEarmarked <= totalDebt. This prevents an invalid state but only triggers if cumulativeEarmarked exceeds totalDebt after a liquidation.

However, in many cases, especially in normal operations with high totalDebt (e.g., many active positions), the inflated cumulativeEarmarked remains below totalDebt. Until then, the incorrect state affects _earmark and _sync calculations. For example:

  • Initial state: totalDebt = 540e18, cumulativeEarmarked = 360e18.

  • Liquidation repays 155e18 earmarked debt: totalDebt = 385e18, cumulativeEarmarked = 360e18 (should be 205e18).

  • Since 360e18 < 385e18, the clamp doesn’t trigger, and the incorrect cumulativeEarmarked persists.

Impact Details

cumulativeEarmarked, which tracks the total debt earmarked for redemption across all positions, becomes overstated. An inflated cumulativeEarmarked reduces liveUnearmarked, which represents the pool of debt available for new earmarking. This leads to an artificially low liveUnearmarked, causing the protocol to miscalculate how much debt can be earmarked for redemption.

Proof of Concept

Proof of Concept

Add this to the AlchemistV3.t.sol test and run

Was this helpful?