56628 sc high liquidate does not update mytsharesdeposited that is reduced by fees

Submitted on Oct 18th 2025 at 15:52:45 UTC by @farismaulana for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #56628

  • Report Type: Smart Contract

  • Report severity: High

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

  • Impacts:

    • Permanent freezing of funds

    • Protocol insolvency

Description

Brief/Intro

at _liquidate the MYT would get sent into transmuter, protocolFeeReceiver and the caller depending on the case. however the transfer into protocolFeeReceiver and caller does not update _mytSharesDeposited making each time earmark used for repayment inside _liquidate it would reduce the actual MYT holding of Alchemist contract but not reflected in _mytSharesDeposited

Vulnerability Details

in the function _liquidate we can see that MYT that act as fee get sent outside the contract:

        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);
        }

there are no issue if the receiver is Transmuter contract as it would correctly handle the _mytSharesDeposited at later via redeem.

the issue is that sending MYT as feeInYield and as part of protocol fee inside _forceRepay . as we can see there are no _mytSharesDeposited update after the transfer is done.

Impact Details

given how _mytSharesDeposited is used in _getTotalUnderlyingValue which is also used in _doLiquidation function, over course of many liquidation happening:

  1. calculateLiquidation would overestimate the alchemistCurrentCollateralization potentially making the check if alchemistCurrentCollateralization < alchemistMinimumCollateralization returning false while it should be true. preventing the detection if the whole contract is undercollateralized and thus preventing full liquidation of any position.

  2. failing to detect global undercollateralization would makes protocol prone to insolvency

  3. inflated _mytSharesDeposited would cause deposit cap check an issue

  4. an issue with withdraw function because of the unsynced shares deposited vs collateralBalance

References

https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L819-L842

Proof of Concept

Proof of Concept

add this test to src/test/AlchemistV3.t.sol. this test is duplicate of testLiquidate_Undercollateralized_Position_With_Earmarked_Debt_Sufficient_Repayment_With_Protocol_Fee , adding a few assert at the bottom of the test:

run with forge test --mt testModify_Liquidate_Undercollateralized_Position_With_Earmarked_Debt_Sufficient_Repayment_With_Protocol_Fee

this prove that the MYT to underlying value that is stored in contract state is inflated if compared to actual MYT to underlying value from contract balance

Was this helpful?