# 57483 sc medium fees could be skipped when there is not enough collateral

**Submitted on Oct 26th 2025 at 15:52:02 UTC by @a16 for** [**Audit Comp | Alchemix V3**](https://immunefi.com/audit-competition/alchemix-v3-audit-competition)

* **Report ID:** #57483
* **Report Type:** Smart Contract
* **Report severity:** Medium
* **Target:** <https://github.com/alchemix-finance/v3-poc/blob/immunefi\\_audit/src/AlchemistV3.sol>
* **Impacts:**
  * Theft of unclaimed yield

## Description

## Brief/Intro

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:

```
    if (feeInYield > 0 && account.collateralBalance >= feeInYield) {
        TokenUtils.safeTransfer(myt, msg.sender, feeInYield);
    }
```

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:

1. A loss of fees generated by the protocol
2. 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.
3. 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:

```
    if (account.collateralBalance > protocolFeeTotal) {
        // Transfer the protocol fee to the protocol fee receiver
        TokenUtils.safeTransfer(myt, protocolFeeReceiver, account.collateralBalance);
        account.collateralBalance = 0;
    }
```

## 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");

```
// 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");
```

}


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://reports.immunefi.com/alchemix-v3/57483-sc-medium-fees-could-be-skipped-when-there-is-not-enough-collateral.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
