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 V3arrow-up-right

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

}

}

Was this helpful?