Immunefi Audit Competitions
Alchemix V3

57692 sc high alchemistv3 liquidation fee loss vulnerability

Submitted on Oct 28th 2025 at 08:26:49 UTC by @legion for Audit Comp | Alchemix V3

Description

Brief/Intro

liquidator incentive failure: The _doLiquidation function checks whether to pay the liquidator fee against the victim's remaining collateral balance after seizure, rather than validating against the seized amount. When a liquidation nearly depletes the victim's position, the fee which was already included in the seized collateral is never transferred to the liquidator, remaining trapped in the Alchemist contract.

Vulnerability Details

Liquidator fee withheld when collateral depleted

  • Summary: In _doLiquidation (lines 867-895), the liquidation fee payment logic contains a critical flaw:

    1. Line 867-868: amountLiquidated and feeInYield are calculated from calculateLiquidation, where grossCollateralToSeize = debtToBurn + fee (line 1292)

    2. Line 871: The victim's collateral balance is reduced by the entire seized amount:

      account.collateralBalance = account.collateralBalance > amountLiquidated 
          ? account.collateralBalance - amountLiquidated 
          : 0;
      
    3. Line 877: The fee transfer is conditioned on the post-seizure remaining balance:

      if (feeInYield > 0 && account.collateralBalance >= feeInYield) {
          TokenUtils.safeTransfer(myt, msg.sender, feeInYield);
      }
      

    The bug: The check uses account.collateralBalance after deducting the full seizure (which already includes the fee). In scenarios where liquidation consumes most/all of the victim's collateral, the remaining balance falls below feeInYield, causing the condition to fail. The fee was already seized from the user and is held by the Alchemist contract (line 874 only sends amountLiquidated - feeInYield to the transmuter), but it's never transferred to the liquidator.

Impact Details

Liquidators receive no compensation despite performing economically rational liquidations, creating a systemic disincentive that leaves under-collateralized positions unaddressed. Accumulated trapped fees represent protocol insolvency, and the lack of liquidation during volatile periods can cascade into bad debt.

Recommended: The math in calculateLiquidation guarantees that grossCollateralToSeize = debtToBurn + fee (line 1292), and amountLiquidated is derived directly from grossCollateralToSeize. The seized collateral is in the contract's custody, so the fee is always payable. The balance check adds no safety and only introduces this bug.

References

-src/AlchemistV3.sol#L852-L895 — _doLiquidation function containing the liquidation fee payment vulnerability.

-src/AlchemistV3.sol#L867-L868 — Calculation of amountLiquidated and feeInYield from calculateLiquidation results.

-src/AlchemistV3.sol#L871 — Victim's collateral balance reduced by the entire seized amount (including fee) -src/AlchemistV3.sol#L874 — Transfer of seized collateral minus fee to the transmuter.

-src/AlchemistV3.sol#L877 — Buggy fee payment check using victim's remaining collateral balance after seizure.

-src/AlchemistV3.sol#L1244-L1295 — calculateLiquidation function that computes grossCollateralToSeize including the fee.

-src/AlchemistV3.sol#L1290 — grossCollateralToSeize = debtToBurn + fee calculation ensuring fee is part of seized amount

Proof of Concept

Proof of Concept

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity 0.8.28;

import {IERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";

import {AlchemistV3Test} from "./AlchemistV3.t.sol";
import {AlchemistNFTHelper} from "./libraries/AlchemistNFTHelper.sol";
import {IMockYieldToken} from "./mocks/MockYieldToken.sol";
import {SafeERC20} from "../libraries/SafeERC20.sol";

/// @notice PoC demonstrating that `_doLiquidation` can fail to pay the liquidator fee
///         whenever the liquidation depletes the victim's collateral balance, leaving
///         the fee trapped inside the Alchemist contract.
contract AlchemistV3LiquidationFeePoC is AlchemistV3Test {
    function testLiquidatorFeeWithheldWhenCollateralDepleted() external {
        // Bootstrap the strategy for share price manipulation
        vm.startPrank(someWhale);
        IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale);
        vm.stopPrank();

        // Maintain global collateralization with a healthy auxiliary position
        vm.startPrank(yetAnotherExternalUser);
        SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount);
        alchemist.deposit(depositAmount, yetAnotherExternalUser, 0);
        vm.stopPrank();

        // Create the victim position
        vm.startPrank(address(0xbeef));
        SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18);
        alchemist.deposit(depositAmount, address(0xbeef), 0);
        uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT));
        uint256 maxBorrow = alchemist.getMaxBorrowable(tokenId);
        alchemist.mint(tokenId, maxBorrow, address(0xbeef));
        vm.stopPrank();

        // Increase liquidation fee significantly
        vm.prank(alOwner);
        alchemist.setLiquidatorFee(9500); // 95%

        // Search for the right degradation that triggers the bug
        uint256 initialSupply = IERC20(address(mockStrategyYieldToken)).totalSupply();
        IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialSupply);

        bool found = false;
        uint256 chosenBps = 0;

        // Iterate to find exact threshold
        for (uint256 bps = 590; bps <= 1150; bps += 1) {
            uint256 modifiedSupply = initialSupply + (initialSupply * bps) / 10_000;
            IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedSupply);

            (uint256 collateral, uint256 debt,) = alchemist.getCDP(tokenId);
            if (collateral == 0) continue;

            uint256 collateralValue = alchemist.totalValue(tokenId);
            uint256 globalCollatRatio = alchemist.normalizeUnderlyingTokensToDebt(alchemist.getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / alchemist.totalDebt();

            (uint256 liquidationAmount,, uint256 baseFee, uint256 outsourcedFee) = alchemist.calculateLiquidation(
                collateralValue,
                debt,
                alchemist.minimumCollateralization(),
                globalCollatRatio,
                alchemist.globalMinimumCollateralization(),
                alchemist.liquidatorFee()
            );

            if (liquidationAmount == 0 || baseFee == 0 || outsourcedFee > 0) {
                continue;
            }

            uint256 amountLiquidatedShares = alchemist.convertDebtTokensToYield(liquidationAmount);
            uint256 feeShares = alchemist.convertDebtTokensToYield(baseFee);
            uint256 remaining = collateral > amountLiquidatedShares ? collateral - amountLiquidatedShares : 0;

            // This is the bug condition
            if (feeShares > 0 && remaining < feeShares) {
                found = true;
                chosenBps = bps;
                break;
            }
        }

        require(found, "Could not find bug scenario");

        // Apply the chosen degradation
        uint256 chosenSupply = initialSupply + (initialSupply * chosenBps) / 10_000;
        IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(chosenSupply);

        // Capture state before liquidation
        uint256 liquidatorBalanceBefore = IERC20(address(vault)).balanceOf(externalUser);

        // Execute liquidation
        vm.prank(externalUser);
        (uint256 seizedShares, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenId);

        uint256 liquidatorBalanceAfter = IERC20(address(vault)).balanceOf(externalUser);

        // The BUG: fee is reported but liquidator didn't receive it
        assertGt(feeInYield, 0, "Fee should be non-zero");
        assertEq(feeInUnderlying, 0, "No outsourced fee");
        assertEq(
            liquidatorBalanceAfter,
            liquidatorBalanceBefore,
            "BUG CONFIRMED: Liquidator received nothing despite non-zero fee"
        );
    }
}