58442 sc high liquidation breaks core accounting invariant missing cumulativeearmarked update in forcerepay causes permanent state drift

Submitted on Nov 2nd 2025 at 12:05:26 UTC by @Bear36435 for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #58442

  • 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, which is called during liquidation, updates individual account earmarked amounts but fails to update the global cumulativeEarmarked variable. This creates a permanent state inconsistency that violates the protocol's core invariant: cumulativeEarmarked must always equal the sum of all individual account.earmarked amounts. Each liquidation increases this discrepancy, causing the earmarking system to operate with corrupted accounting data. Over time, this state drift will cause the transmuter redemption mechanism to malfunction, as it relies on accurate cumulativeEarmarked values to properly allocate collateral for redemptions.

Vulnerability Details

Root Cause:

In AlchemistV3.sol, the repay() function correctly maintains the relationship between individual account earmarked amounts and the global cumulativeEarmarked counter:

   
    // AlchemistV3.sol Lines 522-526 - CORRECT IMPLEMENTATION
uint256 earmarkToRemove = credit > account.earmarked ? account.earmarked : credit;
account.earmarked -= earmarkToRemove;
uint256 earmarkPaidGlobal = cumulativeEarmarked > earmarkToRemove ? earmarkToRemove : cumulativeEarmarked;
cumulativeEarmarked -= earmarkPaidGlobal; // Global state updated
   

However, the _forceRepay() function, which is called during liquidation, only updates the individual account's earmarked amount but completely omits the global cumulativeEarmarked update:

The Critical Invariant

The protocol maintains a critical accounting invariant throughout the codebase:

Invariant: cumulativeEarmarked == Σ(all account.earmarked values)

This invariant is essential because:

  1. The transmuter uses cumulativeEarmarked to calculate global earmark weights

  2. The _earmark() function checks cumulativeEarmarked against totalEarmarkableDebt to determine capacity

  3. Redemption accounting relies on accurate cumulativeEarmarked values

Execution Flow Leading to Bug

  1. User creates a position and borrows alUSD

  2. Another user creates a transmuter redemption, triggering earmarking via _earmark()

  3. Both the individual account.earmarked and global cumulativeEarmarked are incremented

  4. Position becomes undercollateralized due to debt accrual or collateral value drop

  5. Liquidator calls liquidate() -> calls _forceRepay() internally

  6. _forceRepay() removes debt and decrements account.earmarked

  7. BUG: cumulativeEarmarked is NOT decremented

  8. Invariant is now permanently violated

Contradiction to Intended Behavior

The code clearly shows the intended behavior in repay() where both local and global states are updated together. The _forceRepay() function should follow the exact same pattern since it performs the same logical operation (removing earmarked debt), just in a forced context during liquidation. The inconsistency between these two implementations is a clear bug.

Impact Details

Primary Impact: Contract Fails to Deliver Promised Returns

The earmarking mechanism is a core feature that enables users to receive their collateral back through the transmuter over time. The corrupted cumulativeEarmarked value causes several operational failures:

  1. Incorrect Earmark Weight Calculations

The transmuter calculates redemption weights based on cumulativeEarmarked:

With an inflated cumulativeEarmarked, each redemption receives less weight than it should, causing users to receive collateral at a slower rate than intended.

  1. Premature Earmark Capacity Limits

The _earmark() function checks if new earmarks exceed capacity:

An inflated cumulativeEarmarked means this limit is reached prematurely, blocking legitimate earmarks even when actual earmarked amounts are well below the limit.

  1. Accumulating State Drift

The discrepancy grows with each liquidation:

• 1 liquidation with 10k earmarked debt -> 10k drift.

• 10 liquidations -> 100k drift.

• 100 liquidations -> 1M drift.

This is permanent and cannot be corrected without a contract upgrade.

Why This Is Severe Despite No Fund Loss

  1. Breaks Core Promise: Protocol promises redemption mechanism

  2. Permanent Damage: Cannot self-heal, requires upgrade

  3. Silent Failure: No revert, just quietly breaks

  4. Compounds: Gets exponentially worse over time

  5. No Workaround: Users have no alternative redemption path

Affected Users

• Redemption creators: Receive collateral slower than expected • Position holders: May be unable to earmark when they should be able to • Protocol operators: Cannot rely on accurate accounting data • Liquidators: Indirectly affected as liquidations trigger the bug

Add the missing cumulativeEarmarked update to the _forceRepay() function, mirroring the correct implementation in repay():

This ensures that both the individual account state and the global state remain synchronized, preserving the critical invariant that cumulativeEarmarked == Σ(all account.earmarked values).

Proof of Concept

Proof of Concept

Paste this function inside the AlchemistV3.t.sol

##Test Output

Was this helpful?