57970 sc high forcerepay leaves cumulativeearmarked stale
Submitted on Oct 29th 2025 at 17:32:25 UTC by @winnerz for Audit Comp | Alchemix V3
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 globalcumulativeEarmarkedintended to equal E. For any flow that decreases an account’s earmark by x,cumulativeEarmarkedmust also decrease by x to preserve E =cumulativeEarmarked._forceRepaydecreases the user’s earmark but does not decrease the global tally:src/AlchemistV3.sol:762account.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) andsrc/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
_forceRepayremoves x from a user, E becomes (E − x) whilecumulativeEarmarkedremains 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; seesrc/AlchemistV3.sol:1119–1128, especially:cumulativeEarmarked += amount;(src/AlchemistV3.sol:1128)WeightIncrement(amount, liveUnearmarked)whereliveUnearmarked = totalDebt − cumulativeEarmarked
Because
cumulativeEarmarkedis overstated after_forceRepay,liveUnearmarkedis 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
cumulativeEarmarkedreducesliveUnearmarked = totalDebt − cumulativeEarmarkedand 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
_forceRepayper‑account decrement only: src/AlchemistV3.sol:762repay()per‑account and global decrement: src/AlchemistV3.sol:523-526redeem()global decrement: src/AlchemistV3.sol:613
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
Deploy local system: ERC4626 vault (MYT), AlchemistV3, Transmuter, Position NFT.
User A: deposit 10k MYT shares, mint ~8k–8.2k debt.
Create and mature a Transmuter redemption; call alchemist.poke(A) to apply earmarks.
Record pre: A.earmarked, cumulativeEarmarked (cE), Transmuter MYT balance.
Tighten collateral limits; call alchemist.liquidate(A) → triggers _forceRepay.
Record post: A.earmarked = 0; cE unchanged; Transmuter MYT balance increased.
User B: deposit 10k, mint ~6k; alchemist.poke(B) to apply earmarks.
Assert: cE > (A.earmarked + B.earmarked) = B.earmarked.
Output
PoC 1 asserts A’s earmark becomes 0 while
cumulativeEarmarkedremains unchanged.PoC 2 prints:
Additional trace cues
_forceRepaypath showsForceRepay(accountId, amount, creditToYield, protocolFeeTotal)emitted, followed by a transfer ofcreditToYieldMYT to the Transmuter; afterwards,A.earmarked = 0whilecumulativeEarmarkedis unchanged.
Was this helpful?