58466 sc high liquidation fee payment failure due to redundant wrong collateral check

Submitted on Nov 2nd 2025 at 14:13:42 UTC by @omarAli001 for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #58466

  • Report Type: Smart Contract

  • Report severity: High

  • Target: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/AlchemistV3.sol

  • Impacts:

    • Smart contract unable to operate due to lack of token funds

Description

Summary

A redundant collateral balance check in the liquidation process prevents liquidators from receiving their rightful fees even the fee is reduced from liquidated user, when a user's collateral balance becomes low or zero after liquidation(liquidatedAmount + fee) reduction. This creates a scenario where liquidators perform work but don't get his fee which is already reduced from liquidated user, potentially disincentivizing necessary liquidations.

Description

The issue occurs in the _doLiquidation() function where there's inconsistent handling of the liquidation fee: let's go to step by step in code i describe complete issue, i write some note on code with @audit tag please it also. the _doLiquidation call calculateLiquidation which return this value (liquidationAmount, debtToBurn, baseFee, outsourcedFFee) the liquidationAmount include base amount to get liqudate + fee that should be transfer to liquidator.

    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);
        }

        if (alchemistCurrentCollateralization < alchemistMinimumCollateralization) {
            outsourcedFee = (debt * feeBps) / BPS;
            // fully liquidate debt in high ltv global environment
            return (debt, debt, 0, outsourcedFee);
        }

        // fee is taken from surplus = collateral - debt
        uint256 surplus = collateral > debt ? collateral - debt : 0;

@>        fee = (surplus * feeBps) / BPS;

        // collateral remaining for margin‐restore calc
        uint256 adjCollat = collateral - fee;

        // compute m*d  (both plain units)
        uint256 md = (targetCollateralization * debt) / FIXED_POINT_SCALAR;

        // if md <= adjCollat, nothing to liquidate
        if (md <= adjCollat) {
            return (0, 0, fee, 0);
        }

        // numerator = md - adjCollat
        uint256 num = md - adjCollat;

        // denom = m - 1  =>  (targetCollateralization - FIXED_POINT_SCALAR)/FIXED_POINT_SCALAR
        uint256 denom = targetCollateralization - FIXED_POINT_SCALAR;

        // debtToBurn = (num * FIXED_POINT_SCALAR) / denom
        debtToBurn = (num * FIXED_POINT_SCALAR) / denom;

        // gross collateral seize = net + fee
    // @audit the function return `debtToBurn + fee` as a amount to get liqudate
@>        grossCollateralToSeize = debtToBurn + fee;
    }



function _doLiquidation(uint256 accountId, uint256 collateralInUnderlying, uint256 repaidAmountInYield)
        internal
        returns (uint256 amountLiquidated, uint256 feeInYield, uint256 feeInUnderlying)
    {
        Account storage account = _accounts[accountId];

        (uint256 liquidationAmount, uint256 debtToBurn, uint256 baseFee, uint256 outsourcedFee) = calculateLiquidation(
            collateralInUnderlying,
            account.debt,
            minimumCollateralization,
            normalizeUnderlyingTokensToDebt(_getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / totalDebt,
            globalMinimumCollateralization,
            liquidatorFee
        );

        amountLiquidated = convertDebtTokensToYield(liquidationAmount);
        feeInYield = convertDebtTokensToYield(baseFee);

        // update user balance and debt
        account.collateralBalance = account.collateralBalance > amountLiquidated ? account.collateralBalance - amountLiquidated : 0;
        _subDebt(accountId, debtToBurn);
        TokenUtils.safeTransfer(myt, transmuter, amountLiquidated - feeInYield);
// @audit amountLiquidated is the amount that should be reduce from user collateral and get liquidate, feeInYield is the fee amount that
// should be transfer to liquidator for liquidation, now the function reduce the amountLiquidated(amount liquidate + fee) from user collateral 
// then only transfer amountLiquidated - feeInYield to transmuter and sufficient feeInYield amount is left that should be transfer to liquidator, now if after reduction user collateral become 0 below wrong redundent check prevent from transfering liquidation fee to liquidator.

        // send base fee to liquidator if available
        if (feeInYield > 0 && account.collateralBalance >= feeInYield) {
            TokenUtils.safeTransfer(myt, msg.sender, feeInYield);
        }

        // Handle outsourced fee from vault
        if (outsourcedFee > 0) {
            uint256 vaultBalance = IFeeVault(alchemistFeeVault).totalDeposits();
            if (vaultBalance > 0) {
                uint256 feeBonus = normalizeDebtTokensToUnderlying(outsourcedFee);
                feeInUnderlying = vaultBalance > feeBonus ? feeBonus : vaultBalance;
                IFeeVault(alchemistFeeVault).withdraw(msg.sender, feeInUnderlying);
            }
        }

        emit Liquidated(accountId, msg.sender, amountLiquidated + repaidAmountInYield, feeInYield, feeInUnderlying);
        return (amountLiquidated + repaidAmountInYield, feeInYield, feeInUnderlying);
    }

since the calculateLiquidation function return liquidationAmount which include both the liqudate amount + fee of liquidator function correclty reduce the both value as amountLiquidate from user collateral and the the function only transfer the base amount to transmuter TokenUtils.safeTransfer(myt, transmuter, amountLiquidated - feeInYield);. this mean the fee is in the contract and should be transfer to liquidator, but the redundet check if (feeInYield > 0 && account.collateralBalance >= feeInYield) will prevent from transfering fee to liquidator if user collateral become 0 when the amountLiquidated get substract from him. this redundent check will lead to not transfering fee to liquidator and that amount will be stay in contract. liquidator will loss the fee that it should be received.

Scenario Analysis:-

Normal Case (works correctly):

  • User collateral: 100 MYT

  • amountLiquidated: 10 MYT (8 principal + 2 fee)

  • After liquidation: 90 MYT remaining

  • Transmuter receives: 8 MYT

  • Liquidator receives: 2 MYT

Problem Case (fee payment fails):

  • User collateral: 10 MYT

  • amountLiquidated: 10 MYT (8 principal + 2 fee)

  • After liquidation: 0 MYT remaining

  • Transmuter receives: 8 MYT

  • Liquidator receives: 0 MYT

Impact

  • Liquidators not receiving their rightful fees for performing liquidations

  • Financial loss for liquidators who expend gas and effort without compensation

  • Reduced incentive for liquidators to monitor and liquidate undercollateralized positions

Severity level med:- the reason i choose the med severity level and impact (Smart contract unable to operate due to lack of token funds) because the fee that is reduced and not transfer to liquidator is lack of completing the operation and this is happening because of wrong check and lack of enough extra token amount requiring due to wrong check.

Mitigation Steps

remove the second part of if check.

Proof of Concept

Proof of Concept

Was this helpful?