Copy // SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {AlchemistV3Test} from "./AlchemistV3.t.sol";
import {SafeERC20} from "../libraries/SafeERC20.sol";
import {IERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {IMockYieldToken} from "./mocks/MockYieldToken.sol";
import {AlchemistNFTHelper} from "./libraries/AlchemistNFTHelper.sol";
contract TestBug_LiquidatorFeeNotPaidWhenFeeEqualsSurplus_FullBurn is AlchemistV3Test {
function testBug_LiquidatorFeeNotPaidWhenFeeEqualsSurplus() external {
// Arrange: set liquidator fee to 100% of surplus
vm.startPrank(alOwner);
alchemist.setLiquidatorFee(10_000);
vm.stopPrank();
// Fund whale and deposit for yetAnotherExternalUser to keep global collateralization healthy
uint256 amount = 200_000e18;
vm.startPrank(someWhale);
IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale);
vm.stopPrank();
vm.startPrank(yetAnotherExternalUser);
SafeERC20.safeApprove(address(vault), address(alchemist), amount);
alchemist.deposit(amount, yetAnotherExternalUser, 0);
vm.stopPrank();
// Create the victim position: deposit and mint close to max
vm.startPrank(address(0xbeef));
SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18);
alchemist.deposit(amount, address(0xbeef), 0);
uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT));
// Mint up to the limit (min collateralization), so any price drop pushes into liquidation
alchemist.mint(
tokenIdFor0xBeef,
(alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR) / minimumCollateralization,
address(0xbeef)
);
vm.stopPrank();
// Induce undercollateralization by reducing price (increase share supply in mock)
uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply();
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply);
uint256 modifiedVaultSupply = ((initialVaultSupply * 590) / 10_000) + initialVaultSupply; // ~+5.9%
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply);
// Compute expected liquidation values
(, uint256 prevDebt, ) = alchemist.getCDP(tokenIdFor0xBeef);
uint256 collateralInDebt = alchemist.totalValue(tokenIdFor0xBeef);
uint256 alchemistCurrentCollat = (alchemist.normalizeUnderlyingTokensToDebt(
alchemist.getTotalUnderlyingValue()
) * FIXED_POINT_SCALAR) / alchemist.totalDebt();
(uint256 liquidationAmountDebt, uint256 debtToBurn, uint256 baseFeeDebt, ) = alchemist.calculateLiquidation(
collateralInDebt,
prevDebt,
alchemist.minimumCollateralization(),
alchemistCurrentCollat,
alchemist.globalMinimumCollateralization(),
10_000 // 100% fee on surplus
);
// Sanity: expect non-zero base fee; debtToBurn ~ prevDebt (allow rounding tolerance)
assertGt(baseFeeDebt, 0, "base fee > 0");
assertApproxEqAbs(debtToBurn, prevDebt, 1e9, "full debt burn");
// Act: liquidate
vm.startPrank(externalUser);
uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser));
(uint256 assetsSent, uint256 feeInYield, ) = alchemist.liquidate(tokenIdFor0xBeef);
uint256 liquidatorPostTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser));
vm.stopPrank();
// Assert: feeInYield is reported non-zero, but NOT actually paid to the liquidator due to post-deduction balance check
assertGt(feeInYield, 0, "expected non-zero feeInYield");
assertEq(liquidatorPostTokenBalance, liquidatorPrevTokenBalance, "liquidator did not receive base fee tokens");
// And all seized tokens went to transmuter only (assetsSent is the liquidation amount in yield)
// i.e., fee got stranded inside the Alchemist instead of being paid out
assertApproxEqAbs(
assetsSent,
alchemist.convertDebtTokensToYield(liquidationAmountDebt),
1,
"all seized yield forwarded (no base fee paid)"
);
}
}