57970 sc high forcerepay leaves cumulativeearmarked stale

Submitted on Oct 29th 2025 at 17:32:25 UTC by @winnerz for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #57970

  • 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

When liquidation triggers _forceRepay, the protocol reduces the user’s account.earmarked but doesn't decrease the global cumulativeEarmarked. This violates the invariant that the global earmark equals the sum of all account earmarks. Hence, subsequent calculations that depend on cumulativeEarmarked (e.g., liveUnearmarked = totalDebt - cumulativeEarmarked and weight updates) operate on an inflated value.

Vulnerability Details

  • Invariant: Let E = sum over all accounts of account.earmarked. The contract maintains a global cumulativeEarmarkedintended to equal E. For any flow that decreases an account’s earmark by x, cumulativeEarmarked must also decrease by x to preserve E = cumulativeEarmarked.

  • _forceRepay decreases the user’s earmark but does not decrease the global tally:

    • src/AlchemistV3.sol:762 account.earmarked -= earmarkToRemove;

    • No corresponding cumulativeEarmarked -= ... in _forceRepay.

  • In contrast, the other flows preserve the invariant:

    • repay() decreases both the user earmark and the global tally: src/AlchemistV3.sol:523 (account) and src/AlchemistV3.sol:526 (global).

          account.earmarked -= earmarkToRemove;
    
    
          uint256 earmarkPaidGlobal = cumulativeEarmarked > earmarkToRemove ? earmarkToRemove : cumulativeEarmarked;
          cumulativeEarmarked -= earmarkPaidGlobal;
    • redeem() decreases the global earmark by the redeemed amount: src/AlchemistV3.sol:613.

  • After _forceRepay removes x from a user, E becomes (E − x) while cumulativeEarmarked remains E. The global variable is now overstated by x until a later redemption happens to reduce it. This is observable system-wide and persists across blocks.

Downstream effects (where the overstated global is used)

  • _earmark() computes new earmarks and weights using global values; see src/AlchemistV3.sol:1119–1128, especially:

    • cumulativeEarmarked += amount; (src/AlchemistV3.sol:1128)

    • WeightIncrement(amount, liveUnearmarked) where liveUnearmarked = totalDebt − cumulativeEarmarked

  • Because cumulativeEarmarked is overstated after _forceRepay, liveUnearmarked is understated, and weight updates can be mis‑scaled. This alters the timing/proportions of subsequent earmarks/redemptions across users.

Impact Details

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

  • The PoC demonstrates a deterministic, system‑wide accounting inconsistency (global earmark > sum of per‑account earmarks) in core logic that persists until later redemptions modify the global tally.

  • The overstated cumulativeEarmarked reduces liveUnearmarked = totalDebt − cumulativeEarmarked and mis‑scales the denominators used by weight updates in _earmark(), altering the timing/proportions of subsequent earmarks/redemptions. This does not claim theft/insolvency/freezing in this report; the issue is a correctness failure in how “promised” earmark/redemption accounting is computed over time.

References

Mitigation

In _forceRepay, after reducing the user’s earmark by earmarkToRemove, also reduce the global earmark by the same amount (saturating at zero) to restore consistency:

This aligns _forceRepay with repay() and redeem() and preserves the global/per‑account earmark invariant.

Proof of Concept

Proof of Concept

Set-up

  • Foundry, solc 0.8.28, EVM cancun. No RPC/fork required.

  • Test: src/test/AlchemistV3_AuditChecks.t.sol Commands

  • Both PoCs:

  • PoC 1: ForceRepay clears user earmark but not global cumulativeEarmarked

  • PoC 2: After ForceRepay on A, global > sum of account earmarks

workflow

  1. Deploy local system: ERC4626 vault (MYT), AlchemistV3, Transmuter, Position NFT.

  2. User A: deposit 10k MYT shares, mint ~8k–8.2k debt.

  3. Create and mature a Transmuter redemption; call alchemist.poke(A) to apply earmarks.

  4. Record pre: A.earmarked, cumulativeEarmarked (cE), Transmuter MYT balance.

  5. Tighten collateral limits; call alchemist.liquidate(A) → triggers _forceRepay.

  6. Record post: A.earmarked = 0; cE unchanged; Transmuter MYT balance increased.

  7. User B: deposit 10k, mint ~6k; alchemist.poke(B) to apply earmarks.

  8. Assert: cE > (A.earmarked + B.earmarked) = B.earmarked.

Output

  • PoC 1 asserts A’s earmark becomes 0 while cumulativeEarmarked remains unchanged.

  • PoC 2 prints:

Additional trace cues

  • _forceRepay path shows ForceRepay(accountId, amount, creditToYield, protocolFeeTotal) emitted, followed by a transfer of creditToYield MYT to the Transmuter; afterwards, A.earmarked = 0 while cumulativeEarmarked is unchanged.

Was this helpful?