58274 sc high liquidation fee logic in doliquidation strands liquidator rewards when balance is exhausted freezing funds
Submitted on Oct 31st 2025 at 22:00:27 UTC by @Codexstar for Audit Comp | Alchemix V3
- Report ID: #58274
- 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 funds
Description
Brief / Intro
AlchemistV3._doLiquidation subtracts the entire liquidation amount—including the liquidator fee—from a borrower’s collateral balance before attempting to transfer the fee. If the seizure zeroes out the balance, the subsequent >= feeInYield check fails and the fee is never paid out. Every full-collateral liquidation therefore strands the liquidator reward inside the contract, freezing protocol funds and eroding liquidation incentives.
Vulnerability Details
-
calculateLiquidationreturnsgrossCollateralToSeize = debtToBurn + fee(seesrc/AlchemistV3.sol:1244-1290)._doLiquidationconverts this to yield tokens asamountLiquidated, whilefeeInYieldis calculated from the samebaseFee(src/AlchemistV3.sol:858-868). -
The borrower’s collateral balance is immediately reduced by the full
amountLiquidated:account.collateralBalance = account.collateralBalance > amountLiquidated ? account.collateralBalance - amountLiquidated : 0;src/AlchemistV3.sol:867-872 -
Only after the balance update does the contract try to pay the liquidator:
if (feeInYield > 0 && account.collateralBalance >= feeInYield) { TokenUtils.safeTransfer(myt, msg.sender, feeInYield); }src/AlchemistV3.sol:877-879 -
Because
amountLiquidatedalready includes the fee, any liquidation that seizes the full collateral sets the balance to zero, causingaccount.collateralBalance >= feeInYieldto fail. The fee remains trapped inside the Alchemist contract. No other code path compensates the liquidator or removes the frozen funds.
Impact Details
- Liquidators lose their fee in common scenarios (e.g., badly undercollateralized accounts). The stuck reward removes the incentive to liquidate, threatening protocol solvency.
- The trapped MYT is permanently frozen—there is no redemption mechanism once the transfer fails.
- Example: collateral balance 100 MYT,
amountLiquidated = 100,feeInYield = 5. After the balance is zeroed, the fee transfer condition fails, and 5 MYT remains stranded in the contract.
References
- Balance update that consumes the entire
amountLiquidated:src/AlchemistV3.sol:867-872 - Fee transfer guarded by depleted balance check:
src/AlchemistV3.sol:877-879 - Fee included in
grossCollateralToSeize:src/AlchemistV3.sol:1244-1290
Proof of Concept
Proof of Concept
- Open a borrower position with 100 MYT collateral and enough debt for liquidation to seize the full balance (e.g., collateralization well below the lower bound with a 5% liquidator fee).
- Call
alchemist.liquidate(accountId). - Observe post-call state:
account.collateralBalanceis set to0becauseamountLiquidatedequals the full collateral.- The transmuter receives
amountLiquidated - feeInYield(e.g., 95 MYT). - The liquidator receives nothing;
account.collateralBalance >= feeInYieldis false.
- Query
IERC20(myt).balanceOf(address(alchemist))to confirm the 5 MYT fee remains inside the contract with no exit path, demonstrating permanent fund freezing.
Runnable Foundry Test
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {AlchemistV3Test} from "../AlchemistV3.t.sol";
import {IMockYieldToken} from "../mocks/MockYieldToken.sol";
import {AlchemistNFTHelper} from "../libraries/AlchemistNFTHelper.sol";
import {SafeERC20} from "../../libraries/SafeERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract LiquidatorFeePoC is AlchemistV3Test {
using SafeERC20 for IERC20;
uint256 private constant TARGET_LIQUIDATOR_FEE_BPS = 9_500;
uint256 private constant BORROW_BPS = 7_000;
uint256 private constant SUPPLY_BPS = 6_500;
function test_poc_liquidator_fee_not_paid() external {
(uint256 feeYield, uint256 baseFeeDebt, uint256 liquidatorDelta, uint256 collateralRemaining) =
_executeScenario(BORROW_BPS, SUPPLY_BPS);
emit log_named_uint("Borrow BPS", BORROW_BPS);
emit log_named_uint("Supply BPS", SUPPLY_BPS);
emit log_named_uint("Base fee (debt)", baseFeeDebt);
emit log_named_uint("Fee owed (yield)", feeYield);
emit log_named_uint("Liquidator received (yield)", liquidatorDelta);
emit log_named_uint("Collateral remaining (shares)", collateralRemaining);
assertEq(liquidatorDelta, 0, "fee unexpectedly paid");
assertGt(feeYield, 0, "fee should be positive");
assertEq(collateralRemaining, 0, "collateral should be fully seized");
}
function _executeScenario(uint256 borrowBps, uint256 supplyFactorBps)
internal
returns (uint256 feeInYield, uint256 baseFeeDebt, uint256 liquidatorDelta, uint256 collateralRemaining)
{
(uint256 tokenId, uint256 baseFeeDebtLocal, uint256 plannedLiquidation) =
_preparePosition(borrowBps, supplyFactorBps);
(uint256 collateralShares,,) = alchemist.getCDP(tokenId);
require(plannedLiquidation <= collateralShares, "invalid liquidation plan");
uint256 balanceBefore = IERC20(address(vault)).balanceOf(externalUser);
vm.startPrank(externalUser);
(, uint256 feeYield,) = alchemist.liquidate(tokenId);
vm.stopPrank();
uint256 balanceAfter = IERC20(address(vault)).balanceOf(externalUser);
(, , uint256 collateralLeft) = alchemist.getCDP(tokenId);
return (feeYield, baseFeeDebtLocal, balanceAfter - balanceBefore, collateralLeft);
}
function _preparePosition(uint256 borrowBps, uint256 supplyFactorBps)
internal
returns (uint256 tokenId, uint256 baseFeeDebt, uint256 amountLiquidated)
{
address borrower = yetAnotherExternalUser;
address helper = anotherExternalUser;
deal(address(vault), borrower, depositAmount * 2000);
deal(address(vault), helper, depositAmount * 2000);
vm.startPrank(borrower);
SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max);
alchemist.deposit(depositAmount, borrower, 0);
tokenId = AlchemistNFTHelper.getFirstTokenId(borrower, address(alchemistNFT));
uint256 maxBorrow = alchemist.getMaxBorrowable(tokenId);
uint256 borrowAmount = (maxBorrow * borrowBps) / 10_000;
alchemist.mint(tokenId, borrowAmount, borrower);
SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount / 10_000);
alchemist.deposit(depositAmount / 10_000, borrower, tokenId);
vm.stopPrank();
vm.startPrank(helper);
SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 500);
alchemist.deposit(depositAmount * 500, helper, 0);
vm.stopPrank();
vm.startPrank(alOwner);
alchemist.setGlobalMinimumCollateralization(alchemist.minimumCollateralization());
alchemist.setLiquidatorFee(TARGET_LIQUIDATOR_FEE_BPS);
vm.stopPrank();
uint256 initialSupply = IERC20(address(mockStrategyYieldToken)).totalSupply();
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialSupply);
uint256 strategyBalance = IERC20(mockVaultCollateral).balanceOf(mockStrategyYieldToken);
uint256 cappedBps = supplyFactorBps > 10_000 ? 10_000 : supplyFactorBps;
uint256 targetBalance = (strategyBalance * cappedBps) / 10_000;
if (targetBalance < strategyBalance) {
IMockYieldToken(mockStrategyYieldToken).siphon(strategyBalance - targetBalance);
}
(uint256 collateralShares, uint256 debt,) = alchemist.getCDP(tokenId);
uint256 globalCollat =
alchemist.normalizeUnderlyingTokensToDebt(alchemist.getTotalUnderlyingValue()) * 1e18 / alchemist.totalDebt();
(uint256 liquidationAmount,, uint256 baseFeeDebtLocal,) = alchemist.calculateLiquidation(
alchemist.convertYieldTokensToUnderlying(collateralShares),
debt,
alchemist.minimumCollateralization(),
globalCollat,
alchemist.globalMinimumCollateralization(),
alchemist.liquidatorFee()
);
baseFeeDebt = baseFeeDebtLocal;
amountLiquidated = alchemist.convertDebtTokensToYield(liquidationAmount);
}
}
Run with:
forge test --match-test test_poc_liquidator_fee_not_paid -vv