57067 sc low overstated per account locked collateral due to global clamp in subdebt

Submitted on Oct 23rd 2025 at 08:06:42 UTC by @Petrus for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #57067

  • Report Type: Smart Contract

  • Report severity: Low

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

  • Impacts:

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

Description

Brief/Intro

In the AlchemistV3 protocol's _subDebt function, an issue overstates per-account rawLocked collateral by using pre-subtraction debt for calculations but subtracting a globally clamped toFree amount without recomputing on the updated debt, causing excessive pro-rata deductions from users' collateralBalance during _sync in stressed scenarios like burns or liquidations, which could lead to unfair permanent loss of withdrawable funds, unwarranted liquidations, user attrition, and broader protocol insolvency.

Vulnerability Details

function _subDebt(uint256 tokenId, uint256 amount) internal {  
   Account storage account = _accounts[tokenId];  
 
   // Update collateral variables  
   uint256 toFree = convertDebtTokensToYield(amount) * minimumCollateralization / FIXED_POINT_SCALAR;  
  @> uint256 lockedCollateral = convertDebtTokensToYield(account.debt) * minimumCollateralization / FIXED_POINT_SCALAR;  
 
   // For cases when someone above minimum LTV gets liquidated.  
@>  if (toFree > _totalLocked) {  
       toFree = _totalLocked;  
   }  
 
   account.debt -= amount;  
   totalDebt -= amount;  
   _totalLocked -= toFree;  
@>  account.rawLocked = lockedCollateral - toFree;  // <-- Bug: Uses pre-subtraction lockedCollateral minus clamped toFree, overstating rawLocked when clamp triggers  
 
   // Clamp to avoid underflow due to rounding later at a later time  
   if (cumulativeEarmarked > totalDebt) {  
       cumulativeEarmarked = totalDebt;  
   }  
}  

The lockedCollateral is computed on the pre-subtraction account.debt (correct for estimating the delta toFree), but after clamping toFree to _totalLocked (to protect the global from underflow), the per-account account.rawLocked is set to lockedCollateral - toFree (using the reduced toFree), which overstates the true required locked collateral for the new post-subtraction debt when the clamp activates, as it doesn't recompute based on the updated debt.

Impact Details

This issue overstates per-account locked collateral baselines during global clamps, leading to excessive pro-rata deductions from users' collateralBalance in future _sync calls and unfairly amplifying their share of protocol-wide redemptions and fees.

Mitigation

The best solution is to recompute account.rawLocked after the debt subtraction using the updated account.debt to ensure it exactly matches the new required minimum locked collateral, decoupling per-account accuracy from the global clamp on toFree without altering the global protections.

Patched Code (replace the update block in _subDebt):

// Update collateral variables uint256 toFree = convertDebtTokensToYield(amount) * minimumCollateralization / FIXED_POINT_SCALAR;

// For cases when someone above minimum LTV gets liquidated. if (toFree > _totalLocked) { toFree = _totalLocked; }

account.debt -= amount; totalDebt -= amount; _totalLocked -= toFree;

// Recompute rawLocked based on updated debt for exactness account.rawLocked = convertDebtTokensToYield(account.debt) * minimumCollateralization / FIXED_POINT_SCALAR;

// Clamp to avoid underflow due to rounding later at a later time if (cumulativeEarmarked > totalDebt) { cumulativeEarmarked = totalDebt; }

Proof from the Trace:

Setup succeeds: User B deposits 11e18 yield shares, mints 10e18 debt (collateral ratio ~2.2 > 1.111 min, healthy). Partial burn of 5e18 debt triggers _subDebt with clamp (toFree ~5.55e18 > clamped 2e18, so toFree=2e18). Bug: rawLocked set to pre-burn locked (11.11e18) - clamped toFree (2e18) = 9.11e18 (overstated; correct post-burn should be 5.55e18 for remaining 5e18 debt).

Pre-sync healthy: getCDP returns full 11e18 collateral, 5e18 debt (ratio ~2.2, no revert).

Redemption triggers weight delta: User B self-redeems remaining 5e18 synthetics, calling alchemist.redeem(5e18). This invokes redeem which applies WeightIncrement(5e18, old_totalLocked) on _collateralWeight (delta >0, simulating protocol-wide event).

Poke triggers _sync + _validate, exposing bug:

_sync computes collateralToRemove = ScaleByWeightDelta(rawLocked=9.11e18, delta>0) → large deduction (~3.33e18, 60% more than correct ~2.08e18).

collateralBalance drops to 11e18 - 3.33e18 = 7.67e18.

_validate checks ratio: totalValue(7.67e18) / 5e18 debt ~1.534 * FIXED_POINT_SCALAR < min 1.111e18 → Undercollateralized() revert.

Impact confirmed: Without bug (recompute rawLocked=5.55e18), deduction ~2.08e18, collateral=8.92e18, ratio ~1.784 > 1.111 (healthy, no revert). User unfairly loses ~1.25e18 extra withdrawable collateral + risks liquidation during global redemptions.

This shows the bug's severity: overstated per-account rawLocked amplifies pro-rata deductions unfairly, especially in stressed (clamped) scenarios, harming innocent users.

POC 2

Proof Summary:

Setup: User deposits 20e18 yield shares (collateral), mints 10e18 debt (healthy ratio ~2.0 > 1.111 min). Partial burn of 5e18 debt triggers _subDebt: expected toFree ~5.55e18, but clamped to 2e18 (global protection). Bug: rawLocked set to pre-burn locked (22.22e18) - clamped 2e18 = 20.22e18 (overstated; correct post-burn should be 11.11e18 for remaining 5e18 debt).

Pre-sync healthy: getCDP returns full 20e18 collateral, 5e18 debt (no deduction yet, ratio ~4.0).

Redemption triggers delta: Self-redemption of remaining 5e18 synthetics calls alchemist.redeem(5e18), applying WeightIncrement(5e18, old_totalLocked) → large _collateralWeight increase (delta >0, simulating protocol-wide redemption event).

Poke triggers _sync: Computes collateralToRemove = ScaleByWeightDelta(rawLocked=20.22e18, large_delta) → 5e18 deduction (excessive; correct would be ~2.78e18 based on true rawLocked=11.11e18).

Post-sync: Collateral drops to 15e18 (from 20e18), debt 5e18 (ratio ~3.0, still healthy but user lost 5e18 withdrawable collateral unfairly).

Assertion passes: collateralAfterB (15e18) < collateralBeforeB (20e18) confirms excessive deduction. Without bug (recompute rawLocked post-subtraction), deduction ~2.78e18 → collateral ~17.22e18 (less loss, ratio ~3.44).

Bug Impact:

Unfair pro-rata penalty: Users with clamped burns (e.g., during global undercollateralization/liquidations) get overstated rawLocked, amplifying their share of future protocol-wide deductions (redemptions/fees) in _sync. Innocent users lose extra withdrawable collateral (~80% more deduction here vs. correct).

Was this helpful?