56552 sc high liquidation fee misrouting in alchemistv3 doliquidation leads to theft of unclaimed yield liquidator fee stranded
Submitted on Oct 17th 2025 at 15:06:24 UTC by @dizaye for Audit Comp | Alchemix V3
Report ID: #56552
Report Type: Smart Contract
Report severity: High
Target: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/AlchemistV3.sol
Impacts:
Theft of unclaimed yield
Description
Brief/Intro
AlchemistV3’s liquidation path withholds the base liquidation fee from the amount sent to the Transmuter but then conditionally fails to pay that fee to the liquidator. When the condition fails, the fee remains stranded in the Alchemist contract instead of being paid out. This causes direct loss of liquidator compensation (unclaimed yield) and degrades liquidation incentives. The issue is reproducible on a mainnet fork with the live MYT vault.
Vulnerability Details
In AlchemistV3._doLiquidation(uint256,uint256,uint256), liquidation computes four values via calculateLiquidation:
grossCollateralToSeize (debt units, converted to MYT shares)
debtToBurn
baseFee (debt units, converted to MYT shares)
outsourcedFee (underlying units)
The function then:
Debits the account by the gross liquidation amount:
Sends the net amount (gross minus base fee) to the Transmuter:
Attempts to pay the base fee to the liquidator, but only if the account still has at least feeInYield remaining collateral:
Logical mismatch:
The account’s collateral is reduced by the gross seized amount (which includes the base fee) before paying the fee.
Since net was already sent to the Transmuter, the contract holds the fee’s MYT amount and should pay it unconditionally to the liquidator.
The “>= feeInYield” gate against the account’s post-seizure collateral incorrectly prevents paying the fee and strands the fee inside AlchemistV3 when the condition fails.
Consequence:
Liquidator fee (unclaimed yield) is withheld from the Transmuter but not paid to the liquidator, remaining in AlchemistV3. This violates the implied invariant that gross seized = net paid to Transmuter + fee paid to liquidator.
Impact Details
Direct loss for liquidators:
The fee that should be paid to the liquidator is withheld. Over time or in adverse market conditions, this can represent meaningful losses, undermining the liquidation incentive mechanism.
Systemic effect:
Underpayment of liquidators discourages participation, which can degrade system resilience and timely liquidations.
Why “Theft of unclaimed yield”:
The fee represents a claim on seized yield directed to the liquidator; failing to pay it is a direct misrouting of value, consistent with the “Theft of unclaimed yield” impact category.
References
Fee computation and transfers:
Proof of Concept
Proof of Concept
This PoC is runnable on a mainnet fork using the live MYT vault (Morpho Yearn OG WETH: 0xE89371eAaAC6D46d4C3ED23453241987916224FC). It demonstrates that during liquidation feeInYield is withheld from the Transmuter but not paid to the liquidator and remains in AlchemistV3.
File: PoC_AlchemistV3_LiquidationFeeStuck.t.sol
Step-by-step explanation
Deploy AlchemistV3 behind a TransparentUpgradeableProxy and initialize it with:
debtToken: locally deployed AlchemicTokenV3
underlying: WETH
transmuter: locally deployed Transmuter (setTransmutationFee=0 for clarity)
liquidatorFee: 10000 bps (100%) to maximize base fee visibility
myt: live MYT vault (0xE893…24FC) Shown in PoC setUp()
Seed MYT shares to the test address and deposit them into AlchemistV3; then mint debt to reach minimum collateralization:
deal(MYT, self, shares); alchemist.deposit(shares, self, 0)
alchemist.mint(tokenId, mintAmount, self)
Snapshot balances, liquidate, and compute deltas:
Pre balances: AlchemistV3 MYT, Transmuter MYT, Liquidator MYT
Call alchemist.liquidate(tokenId)
Post balances and deltas:
transmuterDelta == yieldAmount - feeInYield (net only)
liquidatorDelta == 0 (no fee paid)
alchemistPostMYT == alchemistPreMYT - (yieldAmount - feeInYield) ⇒ fee stranded in AlchemistV3
The PoC asserts the misrouting:
assertEq(transmuterDelta, yieldAmount - feeInYield)
assertEq(liquidatorDelta, 0)
assertEq(postAlcMyt, expectedPostAlc)
Run:
Output:
Recommended mitigation
In AlchemistV3._doLiquidation():
Replace the conditional fee payment gate with an unconditional transfer whenever feeInYield > 0:
From: if (feeInYield > 0 && account.collateralBalance >= feeInYield) { TokenUtils.safeTransfer(myt, msg.sender, feeInYield); }
To: if (feeInYield > 0) { TokenUtils.safeTransfer(myt, msg.sender, feeInYield); }
Gross seizure already debits the account by “net + fee”; the contract holds the fee’s MYT and must pay the liquidator regardless of the post-seizure residual account.collateralBalance.
Was this helpful?