56949 sc insight uncapped collateral transfer in redemption leads to accounting discrepancy enabling theft of user funds
Submitted on Oct 22nd 2025 at 04:12:22 UTC by @enoch for Audit Comp | Alchemix V3
Report ID: #56949
Report Type: Smart Contract
Report severity: Insight
Target: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/AlchemistV3.sol
Impacts:
Protocol insolvency
Permanent freezing of funds
Smart contract unable to operate due to lack of token funds
Description
Relevant Context
The AlchemistV3 contract manages user collateral deposits through the _mytSharesDeposited variable, which tracks the total yield tokens deposited by all users. When users create positions and mint debt, a portion of their collateral becomes "locked" based on the minimumCollateralization ratio. This locked amount is tracked globally in the _totalLocked variable and represents collateral that cannot be withdrawn due to loan-to-value (LTV) constraints.
During redemptions, the AlchemistV3#redeem function processes debt repayments by releasing locked collateral back to the transmuter. The function calculates the amount of collateral to release (collRedeemed) plus a protocol fee (feeCollateral), with their sum stored as totalOut. The function then updates _totalLocked by subtracting totalOut, and this update is properly capped to prevent underflow when totalOut exceeds the old locked amount.
Finding Description
The AlchemistV3#redeem function contains an accounting inconsistency in how it handles redemption amounts that exceed the available locked collateral. While the function correctly caps the decrease to _totalLocked at zero when totalOut > old, it proceeds to transfer the full uncapped collRedeemed and feeCollateral amounts and decrements _mytSharesDeposited by the full uncapped sum.
Specifically, when a redemption occurs where totalOut > _totalLocked:
The function correctly performs:
_totalLocked = totalOut > old ? 0 : old - totalOut(capping at zero)The function correctly caps the collateral weight:
_collateralWeight += PositionDecay.WeightIncrement(totalOut > old ? old : totalOut, old)However, the function then executes uncapped transfers:
TokenUtils.safeTransfer(myt, transmuter, collRedeemed)TokenUtils.safeTransfer(myt, protocolFeeReceiver, feeCollateral)
And performs an uncapped state update:
_mytSharesDeposited -= collRedeemed + feeCollateral
This creates a dangerous accounting discrepancy. When yield token prices decrease significantly, the debt-to-collateral conversion can result in redemption amounts that exceed the actual locked collateral.
Detailed Accounting Mismatch
The correct behavior when totalOut > _totalLocked should be to transfer out collateral and decrease _mytSharesDeposited by at most the available _totalLocked amount. However, the bug causes excessive collateral transfer and state reduction beyond what's available.
Expected behavior:
Only transfer out collateral up to the available
_totalLockedamountDecrease
_mytSharesDepositedby at most the old_totalLockedvalueExample: If
_totalLocked = 5,500e18andtotalOut = 6,000e18, only transfer 5,500e18 and decrease_mytSharesDepositedby 5,500e18
Actual buggy behavior:
Transfer out the full uncapped
totalOutamountDecrease
_mytSharesDepositedby the full uncapped amountExample: Transfer all 6,000e18 and decrease
_mytSharesDepositedby 6,000e18
Using concrete numbers, if before redemption:
_totalLocked= 5,500e18_mytSharesDeposited= 10,000e18
And redemption calculates totalOut = 6,000e18 due to price movements:
_totalLockedis correctly capped to 0But 6,000e18 tokens are transferred out (500e18 excess)
_mytSharesDepositedbecomes 4,000e18 (should be 4,500e18)Contract balance becomes 4,000e18 (should be 4,500e18)
This 500e18 excess transfer creates an immediate shortfall. The contract's physical token balance becomes insufficient to honor all user claims. The function correctly recognized that only 5,500e18 of locked collateral should be released (by capping _totalLocked at zero), but then proceeded to transfer 6,000e18 anyway. This excess comes directly from the general collateral pool, reducing all users' effective claim on the contract's assets.
The root cause is the assumption that totalOut will always be less than or equal to _totalLocked. However, adverse price movements in the underlying yield token violate this assumption, causing the accounting mismatch between global collateral tracking and actual available funds.
Impact
Users in the AlchemistV3 contract suffer direct loss equal to the excess collateral transferred during the buggy redemption. When later users attempt to withdraw their deposits, they cannot recover their full amounts as the contract's actual balance is lower than the sum of all user claims. The shortfall is socialized across depositors, with users who withdraw later bearing the loss as earlier users drain the insufficient funds.
Recommendation
Cap the actual token transfers and state updates to match the capped locked collateral amount. When totalOut exceeds the old _totalLocked value, proportionally reduce both collRedeemed and feeCollateral so their sum equals the old locked amount:
This ensures that when redemptions exceed available locked collateral, the function only transfers and accounts for the actual available amount, maintaining consistency between _mytSharesDeposited, _totalLocked, individual user balances, and the contract's physical token balance.
Proof of Concept
Proof of Concept
This proof of concept demonstrates how the accounting discrepancy causes loss of user funds through the following sequence:
Initial Setup: A user (whale) deposits 10,000e18 collateral and mints 5,000e18 debt with a 1.1x minimum collateralization ratio. This locks 5,500e18 of collateral (
5,000e18 * 1.1 = 5,500e18).Redemption Creation: The whale creates a redemption for the full 5,000e18 debt in the transmuter.
Price Shock: The yield token price decreases by 20% (simulated by increasing the total supply from
initialVaultSupplytoinitialVaultSupply * 1.2). This means each yield token is now worth less underlying value.Claim Redemption: After the maturity period, the whale claims the redemption. Due to the price decrease, the conversion from debt tokens to yield tokens results in
totalOut > _totalLocked. The contract correctly caps_totalLockedto zero but incorrectly transfers out excess collateral and decrements_mytSharesDepositedby more than the old locked amount.Accounting State After Redemption:
_mytSharesDeposited: ~4,000e18 (should be 4,500e18)Contract actual balance: ~4,000e18 (should be 4,500e18)
Whale's
collateralBalance: 4,500e18Shortfall created: 500e18
New Deposit: An external user deposits 1,000e18 of fresh collateral, bringing
_mytSharesDepositedto ~5,000e18 and contract balance to 5,000e18.First Withdrawal: The whale withdraws their full 4,500e18 balance, leaving only 500e18 in the contract.
Loss Materialized: The new user who deposited 1,000e18 can only withdraw 500e18 as that's all that remains. They lose 500e18, exactly equal to the excess amount transferred during the buggy redemption.
To reproduce this issue, add the following test to src/test/AlchemistV3.t.sol:
Run the test with: forge test --match-test test_poc_uncapped_total_collateral_decrease_in_redemption -vv
The test requires the following helper functions to be added to AlchemistV3.sol for querying internal state:
Was this helpful?