HIGH severity: MYieldFee Can Be Forced Into Insolvency if Non Earning. The MYieldFee contract uses an internal index to track yield and fees, relying on the `latestRate` value to determine if it is earning. If the contract is removed from the earner whitelist but the index is not paused, the internal accounting continues to accrue yield that is never received, leading...

Vulnerable code:
// SPDX-License-Identifier: BUSL-1.1

pragma solidity 0.8.26;

import {
    AccessControlUpgradeable
} from "../../../lib/common/lib/openzeppelin-contracts-upgradeable/contracts/access/AccessControlUpgradeable.sol";

import { IERC20 } from "../../../lib/common/src/interfaces/IERC20.sol";

import { ContinuousIndexingMath } from "../../../lib/common/src/libs/ContinuousIndexingMath.sol";
import { UIntMath } from "../../../lib/common/src/libs/UIntMath.sol";

import { IndexingMath } from "../../libs/IndexingMath.sol";

import { IMExtension } from "../../interfaces/IMExtension.sol";
import { IMTokenLike } from "../../interfaces/IMTokenLike.sol";

import { IMYieldFee } from "./interfaces/IMYieldFee.sol";
import { IContinuousIndexing } from "./interfaces/IContinuousIndexing.sol";

import { MExtension } from "../../MExtension.sol";

abstract contract MYieldFeeStorageLayout {
    /// @custom:storage-location erc7201:M0.storage.MYieldFee
    struct MYieldFeeStorageStruct {
        // NOTE: Slot 1
        uint256 totalSupply;
        // NOTE: Slot 2
        uint112 totalPrincipal;
        uint128 latestIndex;
        uint16 feeRate;
        // NOTE: Slot 3
        address feeRecipient;
        uint40 latestUpdateTimestamp;
        uint32 latestRate;
        // NOTE: Slot 4
        mapping(address account => uint256 balance) balanceOf;
        // NOTE: Slot 5
        mapping(address account => uint112 principal) principalOf;
        // NOTE: Slot 6
        mapping(address account => address claimRecipient) claimRecipients;
    }

    // keccak256(abi.encode(uint256(keccak256("M0.storage.MYieldFee")) - 1)) & ~bytes32(uint256(0xff))
    bytes32 private constant _M_YIELD_FEE_STORAGE_LOCATION =
        0x9d728c135b5b2cd0ace61885d69c4f72215f10f82e77822fb7c6bf472237dd00;

    function _getMYieldFeeStorageLocation() internal pure returns (MYieldFeeStorageStruct storage $) {
        assembly {
            $.slot := _M_YIELD_FEE_STORAGE_LOCATION
        }
    }
}

/**
 * @title MYieldFee
 * @notice Upgradeable ERC20 Token contract for wrapping M into a non-rebasing token.
 *         Extended to take a fee from the yield generated by M.
 * @dev    All holders of this ERC20 token are earners.
 * @author M0 Labs
 */
contract MYieldFee is IContinuousIndexing, IMYieldFee, AccessControlUpgradeable, MYieldFeeStorageLayout, MExtension {
    /* ============ Variables ============ */

    /// @inheritdoc IMYieldFee
    uint16 public constant ONE_HUNDRED_PERCENT = 10_000;

    /// @inheritdoc IMYieldFee
    bytes32 public constant FEE_MANAGER_ROLE = keccak256("FEE_MANAGER_ROLE");

    /// @inheritdoc IMYieldFee
    bytes32 public constant CLAIM_RECIPIENT_MANAGER_ROLE = keccak256("CLAIM_RECIPIENT_MANAGER_ROLE");

    /* ============ Initializer ============ */

    /**
     * @notice Initializes the generic M Yield Fee extension token.
     * @param name             The name of the token (e.g. "M Yield Fee").
     * @param symbol           The symbol of the token (e.g. "MYF").
     * @param mToken           The address of an M Token.
     * @param swapFacility     The address of the Swap Facility.
     * @param feeRate_         The fee rate, in bps, that will be taken from the yield generated by M.
     * @param feeRecipient_    The address of the recipient of the yield fee.
     * @param admin            The address administrating the M extension. Can grant and revoke roles.
     * @param feeManager       The address managing the fee rate and recipient.
     * @param claimRecipientManager The address managing claim recipients for accounts.
     */
    function initialize(
        string memory name,
        string memory symbol,
        address mToken,
        address swapFacility,
        uint16 feeRate_,
        address feeRecipient_,
        address admin,
        address feeManager,
        address claimRecipientManager
    ) public virtual initializer {
        if (admin == address(0)) revert ZeroAdmin();
        if (feeManager == address(0)) revert ZeroFeeManager();
        if (claimRecipientManager == address(0)) revert ZeroClaimRecipientManager();

        __MExtension_init(name, symbol, mToken, swapFacility);

        _grantRole(DEFAULT_ADMIN_ROLE, admin);
        _grantRole(FEE_MANAGER_ROLE, feeManager);
        _grantRole(CLAIM_RECIPIENT_MANAGER_ROLE, claimRecipientManager);

        _setFeeRate(feeRate_);
        _setFeeRecipient(feeRecipient_);

        _getMYieldFeeStorageLocation().latestIndex = ContinuousIndexingMath.EXP_SCALED_ONE;
    }

    /* ============ Interactive Functions ============ */

    /// @inheritdoc IMYieldFee
    function claimYieldFor(address account) external returns (uint256) {
        if (account == address(0)) revert ZeroAccount();

        uint256 yield_ = accruedYieldOf(account);

        if (yield_ == 0) return 0;

        MYieldFeeStorageStruct storage $ = _getMYieldFeeStorageLocation();

        // NOTE: No change in principal, only the balance is updated to include the newly claimed yield.
        unchecked {
            $.balanceOf[account] += yield_;
            $.totalSupply += yield_;
        }

        address claimRecipient_ = claimRecipientFor(account);

        // Emit the appropriate `YieldClaimed` and `Transfer` events, depending on the claim override recipient
        emit YieldClaimed(account, claimRecipient_, yield_);
        emit Transfer(address(0), claimRecipient_, yield_);

        if (claimRecipient_ == account) return yield_;

        // Distribute the yield to the claim recipient.
        _update(account, claimRecipient_, yield_);

        return yield_;
    }

    /// @inheritdoc IMYieldFee
    function claimFee() public returns (uint256) {
        uint256 yieldFee_ = totalAccruedFee();

        if (yieldFee_ == 0) return 0;

        address recipient_ = _getMYieldFeeStorageLocation().feeRecipient;

        emit FeeClaimed(msg.sender, recipient_, yieldFee_);

        // NOTE: Round up to allow claiming the whole amount of yield fee.
        _mint(recipient_, yieldFee_, IndexingMath.getPrincipalAmountRoundedUp(yieldFee_, currentIndex()));

        return yieldFee_;
    }

    /// @inheritdoc IMExtension
    function enableEarning() external override {
        if (isEarningEnabled()) revert EarningIsEnabled();

        // NOTE: update the index to store the latest state, current index won't accrue since `latestRate` is 0.
        emit EarningEnabled(updateIndex());

        IMTokenLike(mToken()).startEarning();
    }

    /// @inheritdoc IMExtension
    function disableEarning() external override {
        if (!isEarningEnabled()) revert EarningIsDisabled();

        // NOTE: update the index to store the latest state.
        emit EarningDisabled(updateIndex());

        // NOTE: `latestRate` is set to 0 to indicate that earning is disabled.
        delete _getMYieldFeeStorageLocation().latestRate;

        IMTokenLike(mToken()).stopEarning(address(this));
    }

    /// @inheritdoc IContinuousIndexing
    function updateIndex() public virtual returns (uint128 currentIndex_) {
        // NOTE: Read the current M token rate adjusted by fee rate split.
        uint32 rate_ = earnerRate();

        MYieldFeeStorageStruct storage $ = _getMYieldFeeStorageLocation();

        if ($.latestUpdateTimestamp == block.timestamp && $.latestRate == rate_) return $.latestIndex;

        // NOTE: `currentIndex()` depends on `_latestRate`, so only update it after this.
        $.latestIndex = currentIndex_ = currentIndex();
        $.latestRate = rate_;
        $.latestUpdateTimestamp = _latestEarnerRateAccrualTimestamp();

        emit IndexUpdated(currentIndex_, rate_);
    }

    /// @inheritdoc IMYieldFee
    function setFeeRate(uint16 feeRate_) external onlyRole(FEE_MANAGER_ROLE) {
        _setFeeRate(feeRate_);

        // NOTE: Update the index to store the new adjusted rate.
        if (isEarningEnabled()) updateIndex();
    }

    /// @inheritdoc IMYieldFee
    function setFeeRecipient(address feeRecipient_) external onlyRole(FEE_MANAGER_ROLE) {
        // Claim fee for the previous fee recipient.
        claimFee();

        _setFeeRecipient(feeRecipient_);
    }

    /// @inheritdoc IMYieldFee
    function setClaimRecipient(
        address account,
        address claimRecipient
    ) external onlyRole(CLAIM_RECIPIENT_MANAGER_ROLE) {
        if (account == address(0)) revert ZeroAccount();
        if (claimRecipient == address(0)) revert ZeroClaimRecipient();

        MYieldFeeStorageStruct storage $ = _getMYieldFeeStorageLocation();

        if ($.claimRecipients[account] == claimRecipient) return;

        // Optionally consider claiming yield for the previous claim recipient.
        // claimYieldFor(account);

        $.claimRecipients[account] = claimRecipient;

        emit ClaimRecipientSet(account, claimRecipient);
    }

    /* ============ External/Public view functions ============ */

    /// @inheritdoc IMYieldFee
    function accruedYieldOf(address account) public view returns (uint256) {
        MYieldFeeStorageStruct storage $ = _getMYieldFeeStorageLocation();
        return _getAccruedYield($.balanceOf[account], $.principalOf[account], currentIndex());
    }

    /// @inheritdoc IERC20
    function balanceOf(address account) public view override returns (uint256) {
        return _getMYieldFeeStorageLocation().balanceOf[account];
    }

    /// @inheritdoc IMYieldFee
    function balanceWithYieldOf(address account) external view returns (uint256) {
        unchecked {
            return balanceOf(account) + accruedYieldOf(account);
        }
    }

    /// @inheritdoc IMYieldFee
    function principalOf(address account) public view returns (uint112) {
        return _getMYieldFeeStorageLocation().principalOf[account];
    }

    /// @inheritdoc IContinuousIndexing
    function currentIndex() public view virtual override(IContinuousIndexing, MExtension) returns (uint128) {
        MYieldFeeStorageStruct storage $ = _getMYieldFeeStorageLocation();

        if (!isEarningEnabled()) return $.latestIndex;

        // NOTE: Safe to use unchecked here, since `block.timestamp` is always greater than `latestUpdateTimestamp`.
        unchecked {
            return
                // NOTE: Cap the index to `type(uint128).max` to prevent overflow in present value math.
                UIntMath.bound128(
                    ContinuousIndexingMath.multiplyIndicesDown(
                        $.latestIndex,
                        ContinuousIndexingMath.getContinuousIndex(
                            ContinuousIndexingMath.convertFromBasisPoints($.latestRate),
                            uint32(_latestEarnerRateAccrualTimestamp() - $.latestUpdateTimestamp)
                        )
                    )
                );
        }
    }

    /// @inheritdoc IMYieldFee
    function earnerRate() public view virtual returns (uint32) {
        return UIntMath.safe32((uint256(ONE_HUNDRED_PERCENT - feeRate()) * _currentEarnerRate()) / ONE_HUNDRED_PERCENT);
    }

    /// @inheritdoc IMExtension
    function isEarningEnabled() public view override returns (bool) {
        return _getMYieldFeeStorageLocation().latestRate != 0;
    }

    /// @inheritdoc IContinuousIndexing
    function latestIndex() public view returns (uint128) {
        return _getMYieldFeeStorageLocation().latestIndex;
    }

    /// @inheritdoc IContinuousIndexing
    function latestRate() public view returns (uint32) {
        return _getMYieldFeeStorageLocation().latestRate;
    }

    /// @inheritdoc IContinuousIndexing
    function latestUpdateTimestamp() public view returns (uint40) {
        return _getMYieldFeeStorageLocation().latestUpdateTimestamp;
    }

    /// @inheritdoc IMYieldFee
    function projectedTotalSupply() public view returns (uint256) {
        return IndexingMath.getPresentAmountRoundedUp(_getMYieldFeeStorageLocation().totalPrincipal, currentIndex());
    }

    /// @inheritdoc IMYieldFee
    function totalAccruedYield() public view returns (uint256) {
        MYieldFeeStorageStruct storage $ = _getMYieldFeeStorageLocation();
        return _getAccruedYield($.totalSupply, $.totalPrincipal, currentIndex());
    }

    /// @inheritdoc IMYieldFee
    function totalAccruedFee() public view returns (uint256) {
        uint256 mBalance_ = _mBalanceOf(address(this));
        uint256 projectedTotalSupply_ = projectedTotalSupply();

        unchecked {
            return mBalance_ > projectedTotalSupply_ ? mBalance_ - projectedTotalSupply_ : 0;
        }
    }

    /// @inheritdoc IMYieldFee
    function totalPrincipal() public view returns (uint112) {
        return _getMYieldFeeStorageLocation().totalPrincipal;
    }

    /// @inheritdoc IERC20
    function totalSupply() public view returns (uint256) {
        return _getMYieldFeeStorageLocation().totalSupply;
    }

    /// @inheritdoc IMYieldFee
    function feeRate() public view returns (uint16) {
        return _getMYieldFeeStorageLocation().feeRate;
    }

    /// @inheritdoc IMYieldFee
    function feeRecipient() public view returns (address) {
        return _getMYieldFeeStorageLocation().feeRecipient;
    }

    /// @inheritdoc IMYieldFee
    function claimRecipientFor(address account) public view returns (address) {
        address recipient_ = _getMYieldFeeStorageLocation().claimRecipients[account];

        // If no claim recipient is set, return the account itself.
        return recipient_ == address(0) ? account : recipient_;
    }

    /* ============ Internal Interactive Functions ============ */

    /**
     * @dev   Mints `amount` tokens to `recipient`.
     * @param recipient The address that will receive tokens.
     * @param amount    The amount of tokens to mint.
     */
    function _mint(address recipient, uint256 amount) internal override {
        _mint(recipient, amount, IndexingMath.getPrincipalAmountRoundedDown(amount, currentIndex()));
    }

    /**
     * @dev   Mints `amount` tokens to `recipient` with a specified principal.
     * @param recipient The address that will receive tokens.
     * @param amount    The amount of tokens to mint.
     * @param principal The principal amount to be associated with the minted tokens.
     */
    function _mint(address recipient, uint256 amount, uint112 principal) internal {
        MYieldFeeStorageStruct storage $ = _getMYieldFeeStorageLocation();

        // NOTE: Can be `unchecked` because the max amount of  M is never greater than `type(uint240).max`.
        //       Can be `unchecked` because UIntMath.safe112 is used for principal addition safety for `totalPrincipal`
        unchecked {
            $.balanceOf[recipient] += amount;
            $.totalSupply += amount;

            $.totalPrincipal = UIntMath.safe112(uint256($.totalPrincipal) + principal);
            // No need for `UIntMath.safe112`, `principalOf[recipient]` cannot be greater than `totalPrincipal`.
            $.principalOf[recipient] += principal;
        }

        emit Transfer(address(0), recipient, amount);
    }

    /**
     * @dev   Burns `amount` tokens from `account`.
     * @param account The address whose account balance will be decremented.
     * @param amount  The present amount of tokens to burn.
     */
    function _burn(address account, uint256 amount) internal override {
        MYieldFeeStorageStruct storage $ = _getMYieldFeeStorageLocation();

        // Slightly overestimate the principal amount to be burned and use safe value to avoid underflow in unchecked block
        uint112 fromPrincipal_ = $.principalOf[account];
        uint112 principal_ = IndexingMath.getSafePrincipalAmountRoundedUp(amount, currentIndex(), fromPrincipal_);

        // NOTE: Can be `unchecked` because `_revertIfInsufficientBalance` is used.
        //       Can be `unchecked` because safety adjustment to `principal_` is applied above, and
        //       `principalOf[account]` cannot be greater than `totalPrincipal`.
        unchecked {
            $.balanceOf[account] -= amount;
            $.totalSupply -= amount;

            $.principalOf[account] = fromPrincipal_ - principal_;
            $.totalPrincipal -= principal_;
        }

        emit Transfer(account, address(0), amount);
    }

    /**
     * @dev   Internal ERC20 transfer function that needs to be implemented by the inheriting contract.
     * @param sender    The sender's address.
     * @param recipient The recipient's address.
     * @param amount    The amount to be transferred.
     */
    function _update(address sender, address recipient, uint256 amount) internal override {
        MYieldFeeStorageStruct storage $ = _getMYieldFeeStorageLocation();

        // Slightly overestimate the principal amount to be moved on transfer
        uint112 fromPrincipal_ = $.principalOf[sender];
        uint112 principal_ = IndexingMath.getSafePrincipalAmountRoundedUp(amount, currentIndex(), fromPrincipal_);

        // NOTE: Can be `unchecked` because we check for insufficient sender balance above.
        //       Can be `unchecked` because safety adjustment to `principal_` is applied above, and
        unchecked {
            $.balanceOf[sender] -= amount;
            $.balanceOf[recipient] += amount;

            $.principalOf[sender] = fromPrincipal_ - principal_;
            $.principalOf[recipient] += principal_;
        }
    }

    /**
     * @notice Sets the yield fee rate, in bps, that will be taken from the yield generated by M.
     * @dev    Reverts if the yield fee rate is greater than ONE_HUNDRED_PERCENT.
     * @dev    Returns early if the yield fee rate is the same as the current one.
     * @param  feeRate_ The yield fee rate.
     */
    function _setFeeRate(uint16 feeRate_) internal {
        if (feeRate_ > ONE_HUNDRED_PERCENT) revert FeeRateTooHigh(feeRate_, ONE_HUNDRED_PERCENT);

        MYieldFeeStorageStruct storage $ = _getMYieldFeeStorageLocation();

        if ($.feeRate == feeRate_) return;

        $.feeRate = feeRate_;

        emit FeeRateSet(feeRate_);
    }

    /**
     * @notice Sets the yield fee recipient that will receive part of the yield generated by M.
     * @dev    Reverts if the yield fee recipient is address zero.
     * @dev    Returns early if the yield fee recipient is the same as the current one.
     * @param  feeRecipient_ The yield fee recipient address.
     */
    function _setFeeRecipient(address feeRecipient_) internal {
        if (feeRecipient_ == address(0)) revert ZeroFeeRecipient();

        MYieldFeeStorageStruct storage $ = _getMYieldFeeStorageLocation();

        if ($.feeRecipient == feeRecipient_) return;

        $.feeRecipient = feeRecipient_;

        emit FeeRecipientSet(feeRecipient_);
    }

    /* ============ Internal View/Pure Functions ============ */

    /**
     * @dev   Returns the timestamp when the earner rate was last accrued to accounts.
     *        For L1: returns the current `block.timestamp` as the rate accrues continuously.
     *        For L2: returns the `latestUpdateTimestamp` from the M token when the index was propagated from L1.
     *        Can be overridden by the inheriting contract (for EVM L2 contracts with index propagation).
     *        MUST return the current block timestamp for an M extension token deployed on the mainnet.
     *        MUST return spoke M token's `latestUpdateTimestamp` for an M extension token deployed on a spoke chain.
     * @return The current block timestamp.
     */
    function _latestEarnerRateAccrualTimestamp() internal view virtual returns (uint40) {
        return uint40(block.timestamp);
    }

    /**
     * @dev    Returns the current earner rate.
     *         Needs to be overridden by the inheriting contract.
     *         MUST return M token's earner rate for an M extension token deployed on the mainnet.
     *         MUST return a rate oracle's earner rate for an M extension token deployed on a spoke chain.
     * @return The current earner rate.
     */
    function _currentEarnerRate() internal view virtual returns (uint32) {
        // NOTE: The behavior of M is known, so we can safely retrieve the earner rate.
        return IMTokenLike(mToken()).earnerRate();
    }

    /**
     * @dev    Compute the yield given a balance, principal and index.
     * @param  balance   The current balance of the account.
     * @param  principal The principal of the account.
     * @param  index     The current index.
     * @return The yield accrued since the last claim.
     */
    function _getAccruedYield(uint256 balance, uint112 principal, uint128 index) internal pure returns (uint256) {
        uint256 balanceWithYield_ = IndexingMath.getPresentAmountRoundedDown(principal, index);
        unchecked {
            return balanceWithYield_ > balance ? balanceWithYield_ - balance : 0;
        }
    }
}


Vulnerability type: CWE-664|CWE-372