# 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**](https://immunefi.com/audit-competition/alchemix-v3-audit-competition)

* **Report ID:** #57692
* **Report Type:** Smart Contract
* **Report severity:** High
* **Target:** <https://github.com/alchemix-finance/v3-poc/blob/immunefi\\_audit/src/AlchemistV3.sol>
* **Impacts:**
  * Contract fails to deliver promised returns, but doesn't lose value

## 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:

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

     ```solidity
     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

```solidity
// 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"
        );
    }
}
```


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://reports.immunefi.com/alchemix-v3/57692-sc-high-alchemistv3-liquidation-fee-loss-vulnerability.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
