In multiple places throughout the code base, fee transfers are skipped entirely when the remaining amount to pay from is less than the calculated fee.
Vulnerability Details
When calling _liquidate(), _forceRepay() is first being called, which only transfers the fee to the protocolFeeReceiver if the remaining collateral is larger than the calculated owed fee.
if (account.collateralBalance > protocolFeeTotal) {
account.collateralBalance -= protocolFeeTotal;
// Transfer the protocol fee to the protocol fee receiver
TokenUtils.safeTransfer(myt, protocolFeeReceiver, protocolFeeTotal);
}
A similar pattern also exists in _doLiquidation() where feeInYield is supposed to be transferred to msg.sender:
This means that if the remaining collateral is smaller than the calculated fee, no fee is taken at all, not even the remaining collateral (which would be a partial amount of the calculated fee).
Impact Details
The impacts of this behavior include:
A loss of fees generated by the protocol
A broken incentive mechanism for liquidations, where users might not be incentivized to liquidate certain positions because it would result in them not receiving liquidation fees, potentially leading to those positions eventually going underwater, resulting in undercollateralization and protocol loss of funds.
The creation of a paradoxical situation where a lower fee rate could actually lead to more fees taken and a higher user collateral could actually mean more fees taken from the user.
Suggestion
Use the remaining collateral to partially pay the calculated fees. For example, in the first case, do something like this:
Proof of Concept
The following function should be added to AlchemistV3.t.sol. It demonstrates that the protocolFeeReceiver receives no fees from the liquidation, but when the ProtocolFee parameter is changed to 0.5%, the protocolFeeReceiver suddenly receives fees, and the test fails.
function test_ForceRepay_Skips_ProtocolFee_When_RemainingCollateralTooSmall() external { console.log("Step 1: Setup protocol fee and raise deposit caps");
if (account.collateralBalance > protocolFeeTotal) {
// Transfer the protocol fee to the protocol fee receiver
TokenUtils.safeTransfer(myt, protocolFeeReceiver, account.collateralBalance);
account.collateralBalance = 0;
}
// Get the actual admin address from the deployed contract
address trueAdmin = alchemist.admin();
console.log("Local test admin:", admin);
console.log("Alchemist admin():", trueAdmin);
// Use the actual admin for privileged actions
vm.startPrank(trueAdmin);
alchemist.setDepositCap(type(uint256).max);
alchemist.setProtocolFee(5000); // 50%. When changed to 0.5%, the test fails with "Protocol fee was unexpectedly paid during _forceRepay"
vm.stopPrank();
console.log("Step 2: User deposits and borrows near liquidation threshold");
vm.startPrank(someWhale);
IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale);
vm.stopPrank();
vm.startPrank(address(0xbeef));
SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18);
alchemist.deposit(depositAmount, address(0xbeef), 0);
uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT));
// Borrow right at the minimum collateralization bound
uint256 borrowAmount = alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization;
alchemist.mint(tokenIdFor0xBeef, borrowAmount, address(0xbeef));
vm.stopPrank();
(uint256 collateralBefore, uint256 debtBefore, uint256 earmarkedBefore) = alchemist.getCDP(tokenIdFor0xBeef);
console.log("Initial collateral:", collateralBefore);
console.log("Initial debt:", debtBefore);
console.log("Initial earmarked:", earmarkedBefore);
console.log("Step 3: Create redemption to earmark ~90% of debt");
vm.startPrank(address(0xdad));
SafeERC20.safeApprove(address(alToken), address(transmuterLogic), (debtBefore * 9) / 10);
transmuterLogic.createRedemption((debtBefore * 9) / 10);
vm.stopPrank();
// Let earmarking schedule advance to make earmark materialize
vm.roll(block.number + 5_256_000);
(collateralBefore, debtBefore, earmarkedBefore) = alchemist.getCDP(tokenIdFor0xBeef);
console.log("After redemption creation:");
console.log(" Collateral:", collateralBefore);
console.log(" Debt:", debtBefore);
console.log(" Earmarked:", earmarkedBefore);
console.log("Step 4: Drop MYT price by inflating yield token supply");
uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply();
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply);
uint256 modifiedVaultSupply = (initialVaultSupply * 1000 / 10_000) + initialVaultSupply; // 1000 bps = 10%
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply);
console.log("Modified vault supply:", modifiedVaultSupply);
console.log("Step 5: Attempt liquidation (forceRepay triggered)");
// Balance of MYT (the yield token) at the fee receiver before
uint256 pfBefore = IERC20(address(vault)).balanceOf(protocolFeeReceiver);
vm.startPrank(externalUser);
(uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef);
vm.stopPrank();
uint256 pfAfter = IERC20(address(vault)).balanceOf(protocolFeeReceiver);
(uint256 collateralAfter, uint256 debtAfter, uint256 earmarkedAfter) = alchemist.getCDP(tokenIdFor0xBeef);
uint256 debtAfterInYield = alchemist.convertDebtTokensToYield(debtAfter);
uint256 debtBeforeInYield = alchemist.convertDebtTokensToYield(debtBefore);
console.log("After liquidation:");
console.log(" Collateral:", collateralAfter);
console.log(" Debt:", debtAfter);
console.log(" Earmarked:", earmarkedAfter);
console.log(" Debt (in yield):", debtAfterInYield);
console.log(" Prev Debt (in yield):", debtBeforeInYield);
console.log(" FeeInYield:", feeInYield);
console.log(" FeeInUnderlying:", feeInUnderlying);
console.log(" Protocol Fee Receiver Before:", pfBefore);
console.log(" Protocol Fee Receiver After:", pfAfter);
console.log("Step 6: Assertions");
// The key condition — no protocol fee should be paid during forceRepay path
vm.assertEq(pfAfter, pfBefore, "Protocol fee was unexpectedly paid during _forceRepay");
vm.assertTrue(collateralAfter > 1, "Expected some collateral to remain after liquidation");
// Sanity: liquidation or repay reduced debt or collateral
vm.assertTrue(debtAfter <= debtBefore, "Debt should not increase");
vm.assertTrue(collateralAfter <= collateralBefore, "Collateral should not increase");
// Sanity: earmarked should have decreased after forceRepay/liquidation path
vm.assertTrue(earmarkedAfter <= earmarkedBefore, "Earmarked should not increase");
console.log("Test completed successfully: partial liquidation/repay left collateral on the account");