57954 sc high lackf of tracking of excess cover in earmark function leads to permanent loss of cover value and stuck user positions
Submitted on Oct 29th 2025 at 15:16:51 UTC by @Tadev for Audit Comp | Alchemix V3
Report ID: #57954
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
Description
Brief/Intro
The _earmark function is responsible for earmarking debt for redemptions. It is defined as follows:
/// @dev Earmarks the debt for redemption.
function _earmark() internal {
if (totalDebt == 0) return;
if (block.number <= lastEarmarkBlock) return;
// Yield the transmuter accumulated since last earmark (cover)
uint256 transmuterCurrentBalance = TokenUtils.safeBalanceOf(myt, address(transmuter));
uint256 transmuterDifference = transmuterCurrentBalance > lastTransmuterTokenBalance ? transmuterCurrentBalance - lastTransmuterTokenBalance : 0;
uint256 amount = ITransmuter(transmuter).queryGraph(lastEarmarkBlock + 1, block.number);
// Proper saturating subtract in DEBT units
uint256 coverInDebt = convertYieldTokensToDebt(transmuterDifference);
amount = amount > coverInDebt ? amount - coverInDebt : 0;
lastTransmuterTokenBalance = transmuterCurrentBalance;
uint256 liveUnearmarked = totalDebt - cumulativeEarmarked;
if (amount > liveUnearmarked) amount = liveUnearmarked;
if (amount > 0 && liveUnearmarked != 0) {
// Previous earmark survival
uint256 previousSurvival = PositionDecay.SurvivalFromWeight(_earmarkWeight);
if (previousSurvival == 0) previousSurvival = ONE_Q128;
// Fraction of unearmarked debt being earmarked now in UQ128.128
uint256 earmarkedFraction = _divQ128(amount, liveUnearmarked);
_survivalAccumulator += _mulQ128(previousSurvival, earmarkedFraction);
_earmarkWeight += PositionDecay.WeightIncrement(amount, liveUnearmarked);
cumulativeEarmarked += amount;
}
lastEarmarkBlock = block.number;
}We can see that it calculates coverInDebt using transmuterDifference. This coverInDebt value is used to offset earmarking. This means the protocol doesn't earmark debt for the value of coverInDebt. The amount to be earmarked is computed as follows:
The problem arises in the case where coverInDebt > amount. amount will be 0, but the extra cover, i.e. coverInDebt - amount is not stored anywhere for future _earmark call.
This is problematic because it means that cumulativeEarmarked will be inflated over time, leading to user funds being stuck because debt is over-earmarked while it shouldn't.
Vulnerability Details
The provided POC highlights very clearly the vulnerability.
Basically, the lack of tracking of excess cover (coverInDebt - amount in _earmark function) leads to incorrect earmarking.
Let's imagine the following scenario:
A few users set a position with collateral and debt
A user creates a small redemption in the transmuter
During the redemption period, a user repays his debt with MYT tokens, which are sent to the transmuter
Next
_earmarkcall for any reason: the amount to be earmarked is reduced to 0 thanks tocoverInDebtwhich is biggerSome time later, another
_earmarkcall: no offset can happen, astransmuterDifferenceis 0. But there is still MYT tokens that could be used for offsetting earmarked debt. The result is thatcumulativeEarmarkedwill be increased while it is not necessary
Impact Details
The impact of this issue is high as it leads to over-earmarking over time. This means user debt will be stuck and users might be unable to fully withdraw their collateral.
Proof of Concept
Proof of Concept
Please copy paste the following test in AlchemistV3.t.sol file:
The output of this test is:
This PoC demonstrates the bug by:
Setting up a position with collateral and debt
Creating a small redemption (50e18) in the transmuter
Providing excess MYT tokens (200e18) to the transmuter - much more than needed. This simulates
repaycalls.Move forward and first earmark: The excess cover (150e18 worth) reduces the earmark to 0, but the unused part of the excess cover is not tracked anywhere
Move forward and second earmark: earmarking happens entirely as if the previous excess cover never existed
Was this helpful?