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 V3arrow-up-right

  • 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

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

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

  1. 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()

  2. 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)

  3. 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

  4. The PoC asserts the misrouting:

    • assertEq(transmuterDelta, yieldAmount - feeInYield)

    • assertEq(liquidatorDelta, 0)

    • assertEq(postAlcMyt, expectedPostAlc)

Run:

Output:

Recommended mitigation

  • In AlchemistV3._doLiquidation()arrow-up-right:

    • 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?