57330 sc critical resolverepaymentfee returns initial fee when fee is greater collateral balance
Submitted on Oct 25th 2025 at 09:49:55 UTC by @Josh4324 for Audit Comp | Alchemix V3
Report ID: #57330
Report Type: Smart Contract
Report severity: Critical
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
The _resolveRepaymentFee function in the AlchemistV3 contract calculates and returns a repayment fee based on the repaid yield amount, but it always returns and emits the full calculated fee even when it exceeds the account's remaining collateral balance. In such cases, only the available balance is deducted, leading to mismatches between returned/emitted values and actual deductions. This can cause misleading events, and over-transfers in upstream functions, resulting in fund theft from the funds in the alchemist contract.
Vulnerability Details
function _liquidate(uint256 accountId) internal returns (uint256 amountLiquidated, uint256 feeInYield, uint256 feeInUnderlying) {
// Query transmuter and earmark global debt
_earmark();
// Sync current user debt before deciding how much needs to be liquidated
_sync(accountId);
Account storage account = _accounts[accountId];
// Early return if no debt exists
if (account.debt == 0) {
return (0, 0, 0);
}
// In the rare scenario where 1 share is worth 0 underlying asset
if (IVaultV2(myt).convertToAssets(1e18) == 0) {
return (0, 0, 0);
}
// Calculate initial collateralization ratio
uint256 collateralInUnderlying = totalValue(accountId);
uint256 collateralizationRatio = collateralInUnderlying * FIXED_POINT_SCALAR / account.debt;
// If account is healthy, nothing to liquidate
if (collateralizationRatio > collateralizationLowerBound) {
return (0, 0, 0);
}
// Try to repay earmarked debt if it exists
uint256 repaidAmountInYield = 0;
if (account.earmarked > 0) {
repaidAmountInYield = _forceRepay(accountId, account.earmarked);
}
// If debt is fully cleared, return with only the repaid amount, no liquidation needed, caller receives repayment fee
if (account.debt == 0) {
@> feeInYield = _resolveRepaymentFee(accountId, repaidAmountInYield);
@> TokenUtils.safeTransfer(myt, msg.sender, feeInYield);
return (repaidAmountInYield, feeInYield, 0);
}
// Recalculate ratio after any repayment to determine if further liquidation is needed
collateralInUnderlying = totalValue(accountId);
collateralizationRatio = collateralInUnderlying * FIXED_POINT_SCALAR / account.debt;
if (collateralizationRatio <= collateralizationLowerBound) {
// Do actual liquidation
return _doLiquidation(accountId, collateralInUnderlying, repaidAmountInYield);
} else {
// Since only a repayment happened, send repayment fee to caller
@> feeInYield = _resolveRepaymentFee(accountId, repaidAmountInYield);
@> TokenUtils.safeTransfer(myt, msg.sender, feeInYield);
return (repaidAmountInYield, feeInYield, 0);
}
}When the calculated fee (repaidAmountInYield * repaymentFee / BPS) is greater than the account's collateralBalance:
The deduction is capped: Only the collateral balance is subtracted. However, the function returns and emits the full uncapped fee.
This discrepancy means the _liquidate function receives an overstated fee value, which is used for transfer to the liquidator. Since transfers pull from the contract's total MYT balance, this can over-transfer, stealing from other users.
Impact Details
Over-transfers steals funds from other users.
References
https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/AlchemistV3.sol#L900
Proof of Concept
Proof of Concept
Copy the test below into src/test/AlchemistV3.t.sol Run forge test --mt testFlow -vvvv
The test below shows that the _liquidate function over transfer funds which inlcudes other users funds in order to pay the liquidator its repayment fee.
Was this helpful?