The Transmuter::claimRedemption() function contains a rounding error in the calculation of badDebtRatio. When this value is incorrectly rounded down, it can cause scaledTransmuted to exceed the actual redeemable amount. As a result, the subsequent redemption call may revert due to insufficient funds. Furthermore, the AlchemistV3::redeem() function does not include protocol fees in the amountToRedeem calculation, which can also trigger unexpected reverts and lock user funds within the contract.
Vulnerability Details
As marked by the @> tags in the following code snippets:
@>1 The badDebtRatio is calculated using integer division that rounds down, making it smaller than the correct ratio.
@>2 When 1badDebtRatio > 1e181, the computed scaledTransmuted becomes larger than the actual redeemable amount, leading to an overestimation of available funds.
@>3 Consequently, amountToRedeem also exceeds the available collateral, causing claimRedemption() -> redeem() to revert during redemption. This results in funds being stuck within the contract. Even when the claimer is not the last participant in a redemption pool, rounding errors can still cause an overclaim, indirectly harming other redemption creators.
Additionally, in the AlchemistV3::redeem() function, the protocolFee is not included in the collRedeemed amount. When protocolFee > 0, the safeTransfer of feeCollateral may revert, also resulting in stuck funds.
Impact Details
The incorrect rounding of badDebtRatio can cause scaledTransmuted to exceed the actual redeemable balance.
As a result, the claimRedemption() call may revert due to insufficient funds, permanently locking user funds in the contract.
Furthermore, when protocolFee > 0, the missing fee inclusion in amountToRedeem can also cause redemption transactions to fail.
This issue may impact both individual users and the protocol’s liquidity pool, resulting in frozen redemptions and unclaimable assets.
// Transmuter::claimRedemption()
function claimRedemption(uint256 id) external {
// SNIP...
// Burn position NFT
_burn(id);
// Ratio of total synthetics issued by the alchemist / underlingying value of collateral stored in the alchemist
// If the system experiences bad debt we use this ratio to scale back the value of yield tokens that are transmuted
uint256 yieldTokenBalance = TokenUtils.safeBalanceOf(alchemist.myt(), address(this));
// Avoid divide by 0
uint256 denominator = alchemist.getTotalUnderlyingValue() + alchemist.convertYieldTokensToUnderlying(yieldTokenBalance) > 0 ? alchemist.getTotalUnderlyingValue() + alchemist.convertYieldTokensToUnderlying(yieldTokenBalance) : 1;
@>1 uint256 badDebtRatio = alchemist.totalSyntheticsIssued() * 10**TokenUtils.expectDecimals(alchemist.underlyingToken()) / denominator;
uint256 scaledTransmuted = amountTransmuted;
@>2 if (badDebtRatio > 1e18) {
@>2 scaledTransmuted = amountTransmuted * FIXED_POINT_SCALAR / badDebtRatio;
}
// If the contract has a balance of yield tokens from alchemist repayments then we only need to redeem partial or none from Alchemist earmarked
uint256 debtValue = alchemist.convertYieldTokensToDebt(yieldTokenBalance);
@>3 uint256 amountToRedeem = scaledTransmuted > debtValue ? scaledTransmuted - debtValue : 0;
@>3 if (amountToRedeem > 0) alchemist.redeem(amountToRedeem);
// SNIP...
}
// AlchemistV3::redeem()
function redeem(uint256 amount) external onlyTransmuter {
_earmark();
// SNIP...
// move only the net collateral + fee
uint256 collRedeemed = convertDebtTokensToYield(amount);
@> uint256 feeCollateral = collRedeemed * protocolFee / BPS;
uint256 totalOut = collRedeemed + feeCollateral;
// update locked collateral + collateral weight
uint256 old = _totalLocked;
_totalLocked = totalOut > old ? 0 : old - totalOut;
_collateralWeight += PositionDecay.WeightIncrement(totalOut > old ? old : totalOut, old);
TokenUtils.safeTransfer(myt, transmuter, collRedeemed);
@> TokenUtils.safeTransfer(myt, protocolFeeReceiver, feeCollateral);
_mytSharesDeposited -= collRedeemed + feeCollateral;
emit Redemption(redeemedDebtTotal);
}