58070 sc high forced repay accounting lets borrowers erase debt without paying equivalent assets protocol deficit insolvency

Submitted on Oct 30th 2025 at 12:28:06 UTC by @manvi for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #58070

  • Report Type: Smart Contract

  • Report severity: High

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

  • Impacts:

    • Protocol insolvency

    • Smart contract unable to operate due to lack of token funds

    • Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

Description

Brief/Intro

I have analyzed the liquidation / forced-repay flow in AlchemistV3.sol and observed an accounting mismatch during liquidation, user debt is reduced by the full "earmarked" credit, but the actual asset (MYT) payment is capped by available convertible collateral.

This causes debtReduced > MYT paid (a "free write-off").

Repeatedly exploiting this drives the system toward a deficit and can render redemptions/liquidations underfunded (protocol insolvency).

Vulnerability Details

I analyzed Alchemix v3's liquidation path and observed that during _forceRepay(tokenId, account.earmarked) the protocol reduces borrower debt by the full earmarked credit, while the MYT actually paid to the transmuter is effectively capped by the account's available collateral (via a convertDebtTokensToYield(earmarked) -> yield path).

This mismatch creates a "free write-off" region where debtReduced > MYT paid, allowing debt to be erased without a corresponding asset outflow - direct value leakage and potential protocol insolvency if exploited at scale.

The issue appears in the liquidation / forced-repayment flow:

Liquidation calls a force-repay using earmarked debt credit.

The debt side is reduced by the full account.earmarked.

The asset side (MYT paid into the transmuter) is limited by the collateral/convertible-yield envelope, i.e., min(convertDebtTokensToYield(earmarked), collateral_as_yield).

When convertDebtTokensToYield(earmarked) > collateral_as_yield, the debt reduction exceeds the asset payment, producing a net free write-off.

Root Cause:

AlchemistV3.liquidate(uint256) calls _forceRepay(accountId, account.earmarked)

_forceRepay — full debt decrement vs. capped payment

Missing: scaling debtCredit down to the amount actually paid or reverting when payment < required.

How it can be exploited

When the system state makes convertDebtTokensToYield(earmarked) > collateralAsYield(tokenId), liquidation:

Subtracts the entire earmarked from account.debt, but

Transfers only canPay MYT (the smaller, collateral-bounded amount) to the transmuter.

My PoC records this directly:

debtReduced = debtBefore - debtAfter

mytPaid = mytAfter - mytBefore

and debtReduced > mytPaid holds.

Impact Details

Primary impact: Protocol insolvency / value extraction

An attacker can repeatedly push their position into the "bad region" where earmarked credit overstates realizable payment. Each liquidation/forced-repay step shrinks their debt more than the protocol receives in MYT, effectively extracting value.

This accumulates as a systemic deficit: liabilities are reduced, but assets are not increased proportionally.

As the deficit grows, the protocol will eventually fail to honor redemptions/liquidations (insufficient balances), which is captured by "Smart contract unable to operate due to lack of token funds."

Concrete loss path (per liquidation):

If earmarked = E, convertDebtTokensToYield(E) = Y, and collateralYield = C, then

Debt reduction = E

Asset paid = min(Y, C)

Free write-off (per step) = E - min(Y, C) (in debt units, economically convertible to assets over time)

With repeated positioning and re-earmarking (see PoC), the attacker can farm this gap.

References

Asset (primary): AlchemistV3.sol (liquidation -> forced repay path)

Accounting sink / visibility: Transmuter.sol (receives MYT and reveals that paid < debtReduced)

Positions / earmarks context: AlchemistV3Position.sol

Proof of Concept

I created a self-contained Foundry test at:

Primary test case that demonstrates the issue:

(For quick sanity, I also expose narrower variants like testBatch_Liquidate_Undercollateralized_Position_And_Skip_Zero_Ids() which pass and exercise the same liquidation/force-repay path.)

What my PoC Does

I first amplify global earmarks by minting large debt on two dummy borrowers so that the global earmark pool is high.

I then create a target borrower (0xbeef) with tiny collateral but high debt right at the collateralization boundary.

I "poke" a few times to redistribute earmarks so that the per-account earmarked credit for 0xbeef grows larger than the borrower's realizable collateral (in yield units).

I record pre-state (collBefore, debtBefore, earmarkedBefore, and wantYield = convertDebtTokensToYield(earmarkedBefore)), then I call alchemist.liquidate(tokenId).

Post-liquidation, I measure how much debt actually went down vs. how much MYT was actually transferred to the transmuter.

The key assertion the PoC proves is:

i.e., debt is written off by the full earmarked credit, while the asset payment is only the min of what can be realized from collateral/yield -0 creating a free write-off gap.

My POC file Content :

Run My POC:

My Console Output :

Notes and Inferences

In a correct system, the value of debt removed must be covered by the value of assets transferred (in the same unit domain).

My test explicitly sets up the state so that:

This forces liquidation to:

Reduce debt by the full earmarked amount (credit-side accounting), but

Transfer only what the collateral can actually cover (asset-side accounting).

My PoC captures both sides and asserts the mismatch:

This is a direct accounting invariant violation and demonstrates a realizable free write-off during liquidation.

Was this helpful?