57036 sc high unconditional debt reduction before protocol fee check in force repayment

Submitted on Oct 22nd 2025 at 21:41:18 UTC by @w3llyc4de20Ik2nn1 for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #57036

  • Report Type: Smart Contract

  • Report severity: High

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

  • Impacts:

    • Theft of unclaimed yield

Description

Brief/Intro

The fee transfer is conditional, but the debt repayment is not. This creates an inconsistency where debt is reduced without the protocol fee being collected.

Vulnerability Details


function _forceRepay(uint256 accountId, uint256 amount) internal returns (uint256) { 
   if (amount == 0) { 
       return 0; 
   } 
   _checkForValidAccountId(accountId); 
   Account storage account = _accounts[accountId]; 
 
   // Query transmuter and earmark global debt 
   _earmark(); 
 
   // Sync current user debt before deciding how much is available to be repaid 
   _sync(accountId); 
 
   uint256 debt; 
 
   // Burning yieldTokens will pay off all types of debt 
   _checkState((debt = account.debt) > 0); 
 
   uint256 credit = amount > debt ? debt : amount; 
   uint256 creditToYield = convertDebtTokensToYield(credit); 
 @>  _subDebt(accountId, credit);  // Debt reduced here unconditionally 
 
   // Repay debt from earmarked amount of debt first 
   uint256 earmarkToRemove = credit > account.earmarked ? account.earmarked : credit; 
   account.earmarked -= earmarkToRemove; 
 
   creditToYield = creditToYield > account.collateralBalance ? account.collateralBalance : creditToYield; 
 @>  account.collateralBalance -= creditToYield; // <-- Collateral deducted for repayment 

 
 
   @>  uint256 protocolFeeTotal = creditToYield * protocolFee / BPS; // <-- Fee computed on full creditToYield 

  
 
   emit ForceRepay(accountId, amount, creditToYield, protocolFeeTotal); 
 
  @> if (account.collateralBalance > protocolFeeTotal) { 
       account.collateralBalance -= protocolFeeTotal; // <-- Fee conditional on *remaining* collateral 

 
       // Transfer the protocol fee to the protocol fee receiver 
       TokenUtils.safeTransfer(myt, protocolFeeReceiver, protocolFeeTotal); 
   } 
 
   if (creditToYield > 0) { 
       // Transfer the repaid tokens from the account to the transmuter. 
       TokenUtils.safeTransfer(myt, address(transmuter), creditToYield); 
   } 
   return creditToYield; 
} 

The _subDebt(accountId, credit) call reduces the account's debt unconditionally early in the function, but the subsequent protocol fee calculation and transfer are conditional on account.collateralBalance > protocolFeeTotal; when the fee cannot be paid due to insufficient remaining collateral, it is silently skipped without adjusting the already-reduced debt.

Impact Details

This allows debt to be force-repaid without collecting the intended protocol fee, resulting in uncollected revenue for the protocol during liquidations involving earmarked debt.

Soln

To fix the uncollected protocol fee issue in _forceRepay, compute and deduct the protocol fee from collateral before reducing debt (reversing the order), clamp the fee to available collateral, and only proceed with debt reduction and transfers if the full repayment amount (including fee) can be covered, ensuring fees are always collected or the repayment is proportionally adjusted.

References

https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L738

Proof of Concept

Proof of Concept

Put the test in the src/test/AlchemistV3.t.sol

Run the test with: forge test --match-test testForceRepaySkipsProtocolFeeWhenInsufficientCollateral -vvvv

Proof from Execution Trace

Setup confirms earmarked position at risk: User deposits 100e18 yield shares, mints ~90e18 debt (max borrowable under ~1.11 collateralization). Full redemption created/matured for the debt amount, fully earmarking it (preDebt = 90e18).

Price drop triggers liquidation via forceRepay: ~5.6% yield token devaluation makes position undercollateralized (collateral value drops to ~95.04e18 underlying). Liquidation calls _forceRepay on full earmarked debt.

Unconditional debt reduction: _subDebt clears all 90e18 debt (postDebt = 0), as expected.

Conditional fee skip: protocolFeeTotal computed as ~19.008e18 (20% of 95.04e18 creditToYield). But post-repayment collateral (~4.009e18 remaining) < fee, so the if (account.collateralBalance > protocolFeeTotal) branch skips deduction/transfer. Protocol receiver balance unchanged (0 before/after).

Impact evident: Full debt forgiven (revenue loss to protocol avoided), but no fee collected despite 20% rate. Liquidator gets only ~0.95e18 repayment fee (1% of repaid yield), not protocol fee.

This directly demonstrates the inconsistency: debt reduced unconditionally, fee applied conditionally—skipping revenue in edge cases (devaluation during earmark) where collateral value < (repaid yield + fee). No manipulation; interacts with live setup via standard flows (deposit/mint/redemption/price update/liquidate).

Was this helpful?