58270 sc critical incorrect handling of debt cover in redeem can affect early liquidation and incorrectly sync accounts
Submitted on Oct 31st 2025 at 21:17:05 UTC by @zeroK for Audit Comp | Alchemix V3
- Report ID: #58270
- Report Type: Smart Contract
- Report severity: Critical
- Target: https://github.com/alchemix-finance/v3-poc/blob/immunefi\\_audit/src/AlchemistV3.sol
- Impacts:
- Protocol insolvency
- Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
- Theft of unclaimed yield
Description
Brief/Intro
When users initiate a redemption claim by invoking the claimRedemption function, the contract first checks whether the Transmuter holds enough MYT tokens to fully pay the staker without requiring additional tokens from AlchemistV3. If the balance is insufficient, it calls the redeem() function with the difference between the requested amount and the transmuter’s current balance, inside redeem(), the first step is invoking _earmark(), which updates lastTransmuterTokenBalance to the current balance. However, this update causes both coverDebt and deltaYield to always be zero in the subsequent lines of the function. This behavior directly impacts the _survivalCumulative and redemptionWeight variables, both of which play a crucial role in the _sync() process when invoked for other users, additionally, because totalDebt does not decrease as expected, this can increase the likelihood of liquidation since the _doLiquidate() function checks whether the current collateralization ratio is below the minimum threshold:
if (alchemistCurrentCollateralization < alchemistMinimumCollateralization)
since alchemistCurrentCollateralization heavily depends on totalDebt, an inflated debt value makes liquidation more likely, while collateral and MYT are being correctly transferred, invoking _earmark() too early causes inaccurate accounting of coverDebt, leading to higher debt persistence and potential unnecessary liquidations.
Vulnerability Details
the function claimRedemption implemented as below, it checks if current myt balance in transmuter is enough to pay the claimer, if not, redeem function gets invoke:
function claimRedemption(uint256 id) external {
StakingPosition storage position = _positions[id];
if (position.maturationBlock == 0) {
revert PositionNotFound();
}
if (position.startBlock == block.number) {
revert PrematureClaim();
}
uint256 transmutationTime = position.maturationBlock - position.startBlock;
uint256 blocksLeft = position.maturationBlock > block.number ? position.maturationBlock - block.number : 0;
uint256 rounded = position.amount * blocksLeft / transmutationTime + (position.amount * blocksLeft % transmutationTime == 0 ? 0 : 1);
uint256 amountNottransmuted = blocksLeft > 0 ? rounded : 0;
uint256 amountTransmuted = position.amount - amountNottransmuted;
if (_requireOwned(id) != msg.sender) {
revert CallerNotOwner();
}
// Burn position NFT
_burn(id);
// Ratio of total synthetics issued by the alchemist / underlingying value of collateral stored in the alchemist
// If the system experiences bad debt we use this ratio to scale back the value of yield tokens that are transmuted
uint256 yieldTokenBalance = TokenUtils.safeBalanceOf(alchemist.myt(), address(this));
// Avoid divide by 0
uint256 denominator = alchemist.getTotalUnderlyingValue() + alchemist.convertYieldTokensToUnderlying(yieldTokenBalance) > 0 ? alchemist.getTotalUnderlyingValue() + alchemist.convertYieldTokensToUnderlying(yieldTokenBalance) : 1;
uint256 badDebtRatio = alchemist.totalSyntheticsIssued() * 10**TokenUtils.expectDecimals(alchemist.underlyingToken()) / denominator;
uint256 scaledTransmuted = amountTransmuted;
if (badDebtRatio > 1e18) {
scaledTransmuted = amountTransmuted * FIXED_POINT_SCALAR / badDebtRatio;
}
// If the contract has a balance of yield tokens from alchemist repayments then we only need to redeem partial or none from Alchemist earmarked
uint256 debtValue = alchemist.convertYieldTokensToDebt(yieldTokenBalance);
uint256 amountToRedeem = scaledTransmuted > debtValue ? scaledTransmuted - debtValue : 0;
if (amountToRedeem > 0) alchemist.redeem(amountToRedeem);
uint256 totalYield = alchemist.convertDebtTokensToYield(scaledTransmuted);
// Cap to what we actually hold now (handles redeem() rounding shortfalls).
uint256 balAfterRedeem = TokenUtils.safeBalanceOf(alchemist.myt(), address(this));
uint256 distributable = totalYield <= balAfterRedeem ? totalYield : balAfterRedeem;
// Split distributable amount. Round fee down; claimant gets the remainder.
uint256 feeYield = distributable * transmutationFee / BPS;
uint256 claimYield = distributable - feeYield;
uint256 syntheticFee = amountNottransmuted * exitFee / BPS;
uint256 syntheticReturned = amountNottransmuted - syntheticFee;
// Remove untransmuted amount from the staking graph
if (blocksLeft > 0) _updateStakingGraph(-position.amount.toInt256() * BLOCK_SCALING_FACTOR / transmutationTime.toInt256(), blocksLeft);
TokenUtils.safeTransfer(alchemist.myt(), msg.sender, claimYield);
TokenUtils.safeTransfer(alchemist.myt(), protocolFeeReceiver, feeYield);
TokenUtils.safeTransfer(syntheticToken, msg.sender, syntheticReturned);
TokenUtils.safeTransfer(syntheticToken, protocolFeeReceiver, syntheticFee);
// Burn remaining synths that were not returned
TokenUtils.safeBurn(syntheticToken, amountTransmuted);
alchemist.reduceSyntheticsIssued(amountTransmuted);
alchemist.setTransmuterTokenBalance(TokenUtils.safeBalanceOf(alchemist.myt(), address(this)));
totalLocked -= position.amount;
emit PositionClaimed(msg.sender, claimYield, syntheticReturned);
delete _positions[id];
}
then the redeem function invokes _earmark at beginning of execution which in return updates the lastTransmuterTokenBalance :
/// @inheritdoc IAlchemistV3Actions
function redeem(uint256 amount) external onlyTransmuter {
_earmark();
uint256 liveEarmarked = cumulativeEarmarked;
if (amount > liveEarmarked) amount = liveEarmarked;
// observed transmuter pre-balance -> potential cover
uint256 transmuterBal = TokenUtils.safeBalanceOf(myt, address(transmuter));
uint256 deltaYield = transmuterBal > lastTransmuterTokenBalance ? transmuterBal - lastTransmuterTokenBalance : 0;
uint256 coverDebt = convertYieldTokensToDebt(deltaYield);
// cap cover so we never consume beyond remaining earmarked
uint256 coverToApplyDebt = amount + coverDebt > liveEarmarked ? (liveEarmarked - amount) : coverDebt;
uint256 redeemedDebtTotal = amount + coverToApplyDebt;
// Apply redemption weights/decay to the full amount that left the earmarked bucket
if (liveEarmarked != 0 && redeemedDebtTotal != 0) {
uint256 survival = ((liveEarmarked - redeemedDebtTotal) << 128) / liveEarmarked;
_survivalAccumulator = _mulQ128(_survivalAccumulator, survival);
_redemptionWeight += PositionDecay.WeightIncrement(redeemedDebtTotal, cumulativeEarmarked);
}
// earmarks are reduced by the full redeemed amount (net + cover)
cumulativeEarmarked -= redeemedDebtTotal;
// global borrower debt falls by the full redeemed amount
totalDebt -= redeemedDebtTotal;
lastRedemptionBlock = block.number;
// consume the observed cover so it can't be reused
if (deltaYield != 0) {
uint256 usedYield = convertDebtTokensToYield(coverToApplyDebt);
lastTransmuterTokenBalance = transmuterBal > usedYield ? transmuterBal - usedYield : transmuterBal;
}
// move only the net collateral + fee
uint256 collRedeemed = convertDebtTokensToYield(amount);
uint256 feeCollateral = collRedeemed * protocolFee / BPS;
uint256 totalOut = collRedeemed + feeCollateral;
// update locked collateral + collateral weight
uint256 old = _totalLocked;
_totalLocked = totalOut > old ? 0 : old - totalOut;
_collateralWeight += PositionDecay.WeightIncrement(totalOut > old ? old : totalOut, old);
TokenUtils.safeTransfer(myt, transmuter, collRedeemed);
TokenUtils.safeTransfer(myt, protocolFeeReceiver, feeCollateral);
_mytSharesDeposited -= collRedeemed + feeCollateral;
emit Redemption(redeemedDebtTotal);
}
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;
}
due to early invoking of _earmark, the coverDebt will always be 0, because current transmuter balance == lastTransmuterTokenBalance, this way the coverDebt will never be added to total redeemedDebtTotal which used to decrease totalDebt which affect liquidation process and it never get used in calculation of _survivalAccumulator and _redemptionWeight which affect users when they invoke _sync() function, we can follow the steps below to understand how the issue occur:
- alice claims 1000 alUSD which equal to 1000 MYT token, currently transmuter holds 500 myt which lead to call
redeemwith 500 myt tokens. - after redeem get invoked which in returns _earmark invokes directly, this will set the lastTransmuterTokenBalance == myt.balanceOf(transmuter).
- liveEarmark equals to 900 tokens.
- due to early invoking the _earmark, the deltaYield equal to zero which mean coverDebt = 0 too.
- now
coverToApplyDebtequals to zero (since amount always smaller or equal to liveEarmark), the coverToApplyDebt should be equal to 400 tokens because500 + 500 > 900 --> 900 - 500but these 400 tokens as cover will be ignored due to early invocation of _earmark. - this will affect the calculation of both
_survivalAccumulatorand_redemptionWeightand the total amount to remove from the totalDebt as shown below:
if (liveEarmarked != 0 && redeemedDebtTotal != 0) {
uint256 survival = ((liveEarmarked - redeemedDebtTotal) << 128) / liveEarmarked;
_survivalAccumulator = _mulQ128(_survivalAccumulator, survival);
_redemptionWeight += PositionDecay.WeightIncrement(redeemedDebtTotal, cumulativeEarmarked);
}
// earmarks are reduced by the full redeemed amount (net + cover)
cumulativeEarmarked -= redeemedDebtTotal;
// global borrower debt falls by the full redeemed amount
totalDebt -= redeemedDebtTotal;
lastRedemptionBlock = block.number;
// consume the observed cover so it can't be reused
if (deltaYield != 0) {
uint256 usedYield = convertDebtTokensToYield(coverToApplyDebt);
lastTransmuterTokenBalance = transmuterBal > usedYield ? transmuterBal - usedYield : transmuterBal;
}
this will directly affect _sync function behavior and _doLiquidation:
/// @dev Update the user's earmarked and redeemed debt amounts.
function _sync(uint256 tokenId) internal {
Account storage account = _accounts[tokenId];
// Collateral to remove from redemptions and fees
uint256 collateralToRemove = PositionDecay.ScaleByWeightDelta(account.rawLocked, _collateralWeight - account.lastCollateralWeight);
account.collateralBalance -= collateralToRemove;
// Redemption survival now and at last sync
// Survival is the amount of earmark that is left after a redemption
uint256 redemptionSurvivalOld = PositionDecay.SurvivalFromWeight(account.lastAccruedRedemptionWeight);
if (redemptionSurvivalOld == 0) redemptionSurvivalOld = ONE_Q128;
uint256 redemptionSurvivalNew = PositionDecay.SurvivalFromWeight(_redemptionWeight);
// Survival during current sync window
uint256 survivalRatio = _divQ128(redemptionSurvivalNew, redemptionSurvivalOld);
// User exposure at last sync used to calculate newly earmarked debt pre redemption
uint256 userExposure = account.debt > account.earmarked ? account.debt - account.earmarked : 0;
uint256 earmarkRaw = PositionDecay.ScaleByWeightDelta(userExposure, _earmarkWeight - account.lastAccruedEarmarkWeight);
// Earmark survival at last sync
// Survival is the amount of unearmarked debt left after an earmark
uint256 earmarkSurvival = PositionDecay.SurvivalFromWeight(account.lastAccruedEarmarkWeight);
if (earmarkSurvival == 0) earmarkSurvival = ONE_Q128;
// Decay snapshot by what was redeemed from last sync until now
uint256 decayedRedeemed = _mulQ128(account.lastSurvivalAccumulator, survivalRatio);
// What was added to the survival accumulator in the current sync window
uint256 survivalDiff = _survivalAccumulator > decayedRedeemed ? _survivalAccumulator - decayedRedeemed : 0;
// Unwind accumulated earmarked at last sync
uint256 unredeemedRatio = _divQ128(survivalDiff, earmarkSurvival);
// Portion of earmark that remains after applying the redemption. Scaled back from 128.128
uint256 earmarkedUnredeemed = _mulQ128(userExposure, unredeemedRatio);
if (earmarkedUnredeemed > earmarkRaw) earmarkedUnredeemed = earmarkRaw;
// Old earmarks that survived redemptions in the current sync window
uint256 exposureSurvival = _mulQ128(account.earmarked, survivalRatio);
// What was redeemed from the newly earmark between last sync and now
uint256 redeemedFromEarmarked = earmarkRaw - earmarkedUnredeemed;
// Total overall earmarked to adjust user debt
uint256 redeemedTotal = (account.earmarked - exposureSurvival) + redeemedFromEarmarked;
account.earmarked = exposureSurvival + earmarkedUnredeemed;
account.debt = account.debt >= redeemedTotal ? account.debt - redeemedTotal : 0;
// Update locked collateral
account.rawLocked = convertDebtTokensToYield(account.debt) * minimumCollateralization / FIXED_POINT_SCALAR;
// Advance account checkpoint
account.lastCollateralWeight = _collateralWeight;
account.lastAccruedEarmarkWeight = _earmarkWeight;
account.lastAccruedRedemptionWeight = _redemptionWeight;
// Snapshot G for this account
account.lastSurvivalAccumulator = _survivalAccumulator;
}
function _doLiquidation(uint256 accountId, uint256 collateralInUnderlying, uint256 repaidAmountInYield)
internal
returns (uint256 amountLiquidated, uint256 feeInYield, uint256 feeInUnderlying)
{
Account storage account = _accounts[accountId];
(uint256 liquidationAmount, uint256 debtToBurn, uint256 baseFee, uint256 outsourcedFee) = calculateLiquidation(
collateralInUnderlying,
account.debt,
minimumCollateralization,
normalizeUnderlyingTokensToDebt(_getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / totalDebt,
globalMinimumCollateralization,
liquidatorFee
);
amountLiquidated = convertDebtTokensToYield(liquidationAmount);
feeInYield = convertDebtTokensToYield(baseFee);
// update user balance and debt
account.collateralBalance = account.collateralBalance > amountLiquidated ? account.collateralBalance - amountLiquidated : 0;
_subDebt(accountId, debtToBurn);
// send liquidation amount - fee to transmuter
TokenUtils.safeTransfer(myt, transmuter, amountLiquidated - feeInYield);
// send base fee to liquidator if available
if (feeInYield > 0 && account.collateralBalance >= feeInYield) {
TokenUtils.safeTransfer(myt, msg.sender, feeInYield);
}
// Handle outsourced fee from vault
if (outsourcedFee > 0) {
uint256 vaultBalance = IFeeVault(alchemistFeeVault).totalDeposits();
if (vaultBalance > 0) {
uint256 feeBonus = normalizeDebtTokensToUnderlying(outsourcedFee);
feeInUnderlying = vaultBalance > feeBonus ? feeBonus : vaultBalance;
IFeeVault(alchemistFeeVault).withdraw(msg.sender, feeInUnderlying);
}
}
emit Liquidated(accountId, msg.sender, amountLiquidated + repaidAmountInYield, feeInYield, feeInUnderlying);
return (amountLiquidated + repaidAmountInYield, feeInYield, feeInUnderlying);
}
function calculateLiquidation(
uint256 collateral,
uint256 debt,
uint256 targetCollateralization,
uint256 alchemistCurrentCollateralization,
uint256 alchemistMinimumCollateralization,
uint256 feeBps
) public pure returns (uint256 grossCollateralToSeize, uint256 debtToBurn, uint256 fee, uint256 outsourcedFee) {
if (debt >= collateral) {
outsourcedFee = (debt * feeBps) / BPS;
// fully liquidate debt if debt is greater than collateral
return (collateral, debt, 0, outsourcedFee);
}
if (alchemistCurrentCollateralization < alchemistMinimumCollateralization) {
outsourcedFee = (debt * feeBps) / BPS;
// fully liquidate debt in high ltv global environment
return (debt, debt, 0, outsourcedFee);
}
//... no need for other lines
in this case we send out 500 tokens and we decrease the collateral later equal to this amount but the debt decreases by 500 instead of 900 tokens in debt.
Impact Details
Provide a detailed breakdown of possible losses from an exploit, especially if there are funds at risk. This illustrates the severity of the vulnerability, but it also provides the best possible case for you to be paid the correct amount. Make sure the selected impact is within the program’s list of in-scope impacts and matches the impact you selected.
References
https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/Transmuter.sol#L191-L233 https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L1040C1-L1075C23
Proof of Concept
Proof of Concept
run the test below in alchemistV3.t.sol:
function test_noCoverDebt() public{
//PHASE 1: deposit and mint tokens
uint256 amount = 100e18;
vm.startPrank(address(0xbeef));
SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18);
alchemist.deposit(amount, address(0xbeef), 0);
// a single position nft would have been minted to 0xbeef
uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT));
alchemist.mint(tokenIdFor0xBeef, (amount / 2), address(0xbeef));
vm.stopPrank();
// PHASE 2: create a position so that the _earmark updates all state variables
vm.startPrank(address(0xdad));
SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18);
transmuterLogic.createRedemption(50e18);
vm.stopPrank();
// PHASE 3: transfer asset to transmutor and calculate the deltaYield
vm.roll(block.number + 2_630_000);
deal(address(alchemist.myt()), address(this), 100 ether);
console.log("balance before transfer :", IERC20(alchemist.myt()).balanceOf(address(transmuterLogic)));
IERC20(alchemist.myt()).transfer(address(transmuterLogic), 50e18);
console.log("balance after transfer:", IERC20(alchemist.myt()).balanceOf(address(transmuterLogic)));
alchemist.poke(tokenIdFor0xBeef);
console.log("lastTransmuter balance saved: ",alchemist.lastTransmuterTokenBalance());
uint lastTrasnmuterBal = alchemist.lastTransmuterTokenBalance();
uint256 transmuterBal = IERC20(alchemist.myt()).balanceOf(address(transmuterLogic));
uint256 deltaYield = transmuterBal > lastTrasnmuterBal ? transmuterBal - lastTrasnmuterBal : 0;
console.log("delta yield :", deltaYield);
uint covDebt = alchemist.convertYieldTokensToDebt(deltaYield);
console.log("cover debt : :", covDebt);
console.log("--------- END OF POC --------- ");
}