58522 sc high earmark consumes excess cover inflating cumulativeearmarked

Submitted on Nov 3rd 2025 at 00:36:13 UTC by @OxPhantom for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #58522

  • Report Type: Smart Contract

  • Report severity: High

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

  • Impacts:

    • Permanent freezing of funds

    • Protocol insolvency

Description

Brief/Intro

_earmark() advances the transmuter balance pointer (lastTransmuterTokenBalance) to the full current balance every call, regardless of how much of the observed balance delta is actually applied as cover to reduce the earmark amount. This prematurely “consumes” future cover, causing unnecessary growth of cumulativeEarmarked, reducing liveUnearmarked, and throttling subsequent earmarks even when the transmuter still holds sufficient MYT to offset future maturations.

Vulnerability Details

Global earmarking computes the matured amount via Transmuter.queryGraph() and offsets it by a “cover” derived from the increase in transmuter MYT balance since the last earmark. However, _earmark() then updates the pointer lastTransmuterTokenBalance to the entire current transmuter balance instead of only accounting for the portion of cover that was actually applied. Any residual, unused cover from the observed delta is effectively discarded for future _earmark() calls.

Relevant flow in AlchemistV3._earmark():

By contrast, redeem() advances the pointer only by the cover actually consumed (converted back to yield), which preserves any residual cover for later:

Because _earmark() over-advances the pointer, future earmarks do not see the still-available MYT as cover, so amount remains higher than it should be, and cumulativeEarmarked increases unnecessarily. This reduces liveUnearmarked = totalDebt - cumulativeEarmarked, which then clamps future earmarks and distorts weight/survival accounting that depends on these buckets.

Note: Transmuter.claimRedemption() eventually calls setTransmuterTokenBalance() to resync the pointer to the actual transmuter MYT balance after transfers; however, that does not retroactively restore the prematurely “consumed” cover from prior _earmark() calls.

Impact Details

  • Inflates cumulativeEarmarked, shrinking liveUnearmarked = totalDebt - cumulativeEarmarked and clamping subsequent _earmark() amounts.

  • Skews weighting/decay attribution that relies on correct earmarked vs unearmarked bucket sizes, impacting fairness of redemption distribution.

References

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

Proof of Concept

Proof of Concept

to run the PoC you can copy paste this code in the IntegrationTest.t.sol test file and run forge test --mt test_transmutterBalance_earmark_PoC

In this PoC, 0xBeef mints 90 alUSD and fully repays his debt. The Transmuter therefore holds 90 yield tokens. Then, 0xDad also mints 90 alUSD and creates a redemption for 45 alUSD. The Transmuter has enough balance, so after the earmark, there is no cumulative earmark — however, the Transmuter’s final balance is set to 90 yield tokens.

After that, 0xDad creates another redemption for 22.5 alUSD. After the earmark, the cumulative earmark is set to 22.5 even though the Transmuter still has enough balance.

Was this helpful?