58387 sc high liquidator fee in the doliquidation function withheld when collateral is exhausted leading to seized fee trapped in protocol

Submitted on Nov 1st 2025 at 20:50:07 UTC by @Idealz for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #58387

  • Report Type: Smart Contract

  • Report severity: High

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

  • Impacts:

    • Permanent freezing of unclaimed yield

    • Protocol insolvency

Description

Brief/Intro

In _doLiquidation, the liquidator fee is computed and included in the amount of collateral to seize. The code deducts the full seized amount from the user, transfers the net seized minus fee to the transmuter, and then tries to transfer the fee to the liquidator only if the victim’s remaining collateral balance is at least the fee. Because the victim’s balance was already reduced by the seized amount (including the fee), the post-seizure remaining balance is often smaller than the fee, so the conditional fails and the liquidator gets nothing. _doLiquidation removes the entire seized amount from the account before forwarding the fee. The protocol keeps the fee trapped, liquidators are disincentivized from acting, bad debt can grow unchecked, and trapped fees represent hidden liabilities

Vulnerability Details

check code excerpt here https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L867-#L880

// compute amounts
amountLiquidated = convertDebtTokensToYield(liquidationAmount);
feeInYield = convertDebtTokensToYield(baseFee);

// update user balance and debt
account.collateralBalance = account.collateralBalance > amountLiquidated ? account.collateralBalance - amountLiquidated : 0;
_subDebt(accountId, debtToBurn);

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

Why this is wrong:

  • calculateLiquidation(...) returns grossCollateralToSeize = debtToBurn + fee (i.e., the fee is included in the seized collateral).

  • The code reduces account.collateralBalance by amountLiquidated (the full seized amount, which includes the fee). After this reduction, the fee portion of the seized collateral is not represented in the account’s remaining balance.

  • The conditional account.collateralBalance >= feeInYield asks whether the victim’s remaining balance is at least the fee. That is the wrong invariant: the correct check should not be against the victim’s post-seizure balance because the fee was already extracted as part of the seizure. In nearly all cases where a liquidation consumes most or all of the collateral, the post-seizure balance will be < fee and the fee transfer will be skipped, even though the fee was already taken from the user and sits in the contract.

  • Net effect: fee is seized but not forwarded, it stays in contract balances as an unforwarded protocol-held token

Impact Details

  • Permanent freezing of unclaimed yield: fee amounts taken from user collateral remain in contract and are not forwarded → effectively frozen until manual recovery.

  • Protocol insolvency: trapped fees accumulate as hidden protocol-held liabilities and reduce available funds; if many liquidations occur with trapped fees, the protocol’s ability to meet obligations deteriorates; under stress, this can cause insolvency

References

  • _doLiquidation (transfer & fee logic) — src: AlchemistV3.sol lines 852–895arrow-up-right

  • calculateLiquidation (fee included in grossCollateralToSeize)

Proof of Concept

Proof of Concept

Add this function test to the AlchemistV3.t.sol test contract

Run the test using forge test --match-test testLiquidatorFeeWithheldWhenCollateralDepleted_PoC -vvvv

Was this helpful?