MEDIUM severity: Wrapped $M V1 Is Unsupported. The SwapFacility and UniswapV3SwapAdapter contracts assume that `wrap()` and `unwrap()` on Wrapped $M V1 mint and burn exact token amounts. However, the deployed version incurs rounding errors, violating this assumption. The cause is imprecise arithmetic in the token's wrapping logic. When these fun...

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

pragma solidity 0.8.26;

import { IERC20 } from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "../../lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
import {
    AccessControlUpgradeable
} from "../../lib/common/lib/openzeppelin-contracts-upgradeable/contracts/access/AccessControlUpgradeable.sol";
import { Lock } from "../../lib/universal-router/contracts/base/Lock.sol";

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

import { ISwapFacility } from "./interfaces/ISwapFacility.sol";
import { IRegistrarLike } from "./interfaces/IRegistrarLike.sol";
import { IUniswapV3SwapAdapter } from "./interfaces/IUniswapV3SwapAdapter.sol";

/**
 * @title  Swap Facility
 * @notice A contract responsible for swapping between $M Extensions.
 * @author M0 Labs
 */
contract SwapFacility is ISwapFacility, AccessControlUpgradeable, Lock {
    using SafeERC20 for IERC20;

    bytes32 public constant EARNERS_LIST_IGNORED_KEY = "earners_list_ignored";
    bytes32 public constant EARNERS_LIST_NAME = "earners";
    bytes32 public constant M_SWAPPER_ROLE = keccak256("M_SWAPPER_ROLE");

    /// @inheritdoc ISwapFacility
    /// @custom:oz-upgrades-unsafe-allow state-variable-immutable
    address public immutable mToken;

    /// @inheritdoc ISwapFacility
    /// @custom:oz-upgrades-unsafe-allow state-variable-immutable
    address public immutable registrar;

    /// @inheritdoc ISwapFacility
    address public immutable swapAdapter;

    /**
     * @notice Constructs SwapFacility Implementation contract
     * @dev    Sets immutable storage.
     * @param  mToken_      The address of $M token.
     * @param  registrar_   The address of Registrar.
     * @param  swapAdapter_ The address of Uniswap swap adapter.
     */
    constructor(address mToken_, address registrar_, address swapAdapter_) {
        _disableInitializers();

        if ((mToken = mToken_) == address(0)) revert ZeroMToken();
        if ((registrar = registrar_) == address(0)) revert ZeroRegistrar();
        if ((swapAdapter = swapAdapter_) == address(0)) revert ZeroSwapAdapter();
    }

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

    /**
     * @notice Initializes SwapFacility Proxy.
     * @param  admin Address of the SwapFacility admin.
     */
    function initialize(address admin) external initializer {
        _grantRole(DEFAULT_ADMIN_ROLE, admin);
    }

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

    /// @inheritdoc ISwapFacility
    function swap(address extensionIn, address extensionOut, uint256 amount, address recipient) external isNotLocked {
        // NOTE: Amount and recipient validation is performed in Extensions.
        _revertIfNotApprovedExtension(extensionIn);
        _revertIfNotApprovedExtension(extensionOut);

        _swap(extensionIn, extensionOut, amount, recipient);

        emit Swapped(extensionIn, extensionOut, amount, recipient);
    }

    /// @inheritdoc ISwapFacility
    function swapInM(address extensionOut, uint256 amount, address recipient) external isNotLocked {
        // NOTE: Amount and recipient validation is performed in Extensions.
        _revertIfNotApprovedExtension(extensionOut);

        _swapInM(extensionOut, amount, recipient);
    }

    /// @inheritdoc ISwapFacility
    function swapInMWithPermit(
        address extensionOut,
        uint256 amount,
        address recipient,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external isNotLocked {
        _revertIfNotApprovedExtension(extensionOut);

        try IMTokenLike(mToken).permit(msgSender(), address(this), amount, deadline, v, r, s) {} catch {}

        _swapInM(extensionOut, amount, recipient);
    }

    /// @inheritdoc ISwapFacility
    function swapInMWithPermit(
        address extensionOut,
        uint256 amount,
        address recipient,
        uint256 deadline,
        bytes calldata signature
    ) external isNotLocked {
        _revertIfNotApprovedExtension(extensionOut);

        try IMTokenLike(mToken).permit(msgSender(), address(this), amount, deadline, signature) {} catch {}

        _swapInM(extensionOut, amount, recipient);
    }

    /// @inheritdoc ISwapFacility
    function swapOutM(address extensionIn, uint256 amount, address recipient) external isNotLocked {
        // NOTE: Amount and recipient validation is performed in Extensions.
        _revertIfNotApprovedExtension(extensionIn);
        _revertIfNotApprovedSwapper(msgSender());

        _swapOutM(extensionIn, amount, recipient);
    }

    /// @inheritdoc ISwapFacility
    function swapInToken(
        address tokenIn,
        uint256 amountIn,
        address extensionOut,
        uint256 minAmountOut,
        address recipient,
        bytes calldata path
    ) external isNotLocked {
        _revertIfNotApprovedExtension(extensionOut);

        // Transfer input token to SwapFacility for future transfer to Swap Adapter.
        IERC20(tokenIn).safeTransferFrom(msgSender(), address(this), amountIn);

        // Approve Swap Adapter to spend input token.
        IERC20(tokenIn).forceApprove(swapAdapter, amountIn);

        // Swap input token for base token in Uniswap pool
        uint256 amountOut = IUniswapV3SwapAdapter(swapAdapter).swapIn(
            tokenIn,
            amountIn,
            minAmountOut,
            address(this),
            path
        );

        address baseToken = IUniswapV3SwapAdapter(swapAdapter).baseToken();
        // If extensionOut is baseToken, transfer to the recipient directly
        if (extensionOut == baseToken) {
            IERC20(baseToken).transfer(recipient, amountOut);
        } else {
            // Otherwise, swap the baseToken to extensionOut
            _swap(baseToken, extensionOut, amountOut, recipient);
        }

        emit Swapped(tokenIn, extensionOut, amountOut, recipient);
    }

    /// @inheritdoc ISwapFacility
    function swapOutToken(
        address extensionIn,
        uint256 amountIn,
        address tokenOut,
        uint256 minAmountOut,
        address recipient,
        bytes calldata path
    ) external isNotLocked {
        _revertIfNotApprovedExtension(extensionIn);

        address baseToken = IUniswapV3SwapAdapter(swapAdapter).baseToken();
        if (extensionIn == baseToken) {
            // If extensionIn is baseToken (Wrapped $M), transfer it to SwapFacility
            IERC20(baseToken).safeTransferFrom(msgSender(), address(this), amountIn);
        } else {
            uint256 balanceBefore = IERC20(baseToken).balanceOf(address(this));

            // Otherwise, swap the extensionIn to baseToken
            _swap(extensionIn, baseToken, amountIn, address(this));

            // Calculate amountIn as the difference in balance to account for rounding errors
            amountIn = IERC20(baseToken).balanceOf(address(this)) - balanceBefore;
        }

        // Approve Swap Adapter to spend baseToken (Wrapped $M).
        IERC20(baseToken).forceApprove(swapAdapter, amountIn);

        // Swap baseToken in Uniswap pool for output token
        uint256 amountOut = IUniswapV3SwapAdapter(swapAdapter).swapOut(
            tokenOut,
            amountIn,
            minAmountOut,
            recipient,
            path
        );

        emit Swapped(extensionIn, tokenOut, amountOut, recipient);
    }

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

    /// @inheritdoc ISwapFacility
    function msgSender() public view returns (address) {
        return _getLocker();
    }

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

    /**
     * @notice Swaps one $M Extension to another.
     * @param  extensionIn  The address of the $M Extension to swap from.
     * @param  extensionOut The address of the $M Extension to swap to.
     * @param  amount       The amount to swap.
     * @param  recipient    The address to receive the swapped $M Extension tokens.
     */
    function _swap(address extensionIn, address extensionOut, uint256 amount, address recipient) private {
        uint256 balanceBefore = _mBalanceOf(address(this));

        // Recipient parameter is ignored in the MExtension, keeping it for backward compatibility.
        IMExtension(extensionIn).unwrap(address(this), amount);

        // NOTE: Calculate amount as $M Token balance difference
        //       to account for rounding errors.
        amount = _mBalanceOf(address(this)) - balanceBefore;

        IERC20(mToken).approve(extensionOut, amount);
        IMExtension(extensionOut).wrap(recipient, amount);
    }

    /**
     * @notice Swaps $M token to $M Extension.
     * @param  extensionOut The address of the M Extension to swap to.
     * @param  amount       The amount of $M token to swap.
     * @param  recipient    The address to receive the swapped $M Extension tokens.
     */
    function _swapInM(address extensionOut, uint256 amount, address recipient) private {
        IERC20(mToken).transferFrom(msgSender(), address(this), amount);
        IERC20(mToken).approve(extensionOut, amount);
        IMExtension(extensionOut).wrap(recipient, amount);

        emit SwappedInM(extensionOut, amount, recipient);
    }

    /**
     * @notice Swaps $M Extension to $M token.
     * @param  extensionIn The address of the $M Extension to swap from.
     * @param  amount      The amount of $M Extension tokens to swap.
     * @param  recipient   The address to receive $M tokens.
     */
    function _swapOutM(address extensionIn, uint256 amount, address recipient) private {
        uint256 balanceBefore = _mBalanceOf(address(this));

        // Recipient parameter is ignored in the MExtension, keeping it for backward compatibility.
        IMExtension(extensionIn).unwrap(address(this), amount);

        // NOTE: Calculate amount as $M Token balance difference
        //       to account for rounding errors.
        amount = _mBalanceOf(address(this)) - balanceBefore;
        IERC20(mToken).transfer(recipient, amount);

        emit SwappedOutM(extensionIn, amount, recipient);
    }

    /**
     * @dev    Returns the M Token balance of `account`.
     * @param  account The account being queried.
     * @return balance The M Token balance of the account.
     */
    function _mBalanceOf(address account) internal view returns (uint256) {
        return IMTokenLike(mToken).balanceOf(account);
    }

    /* ============ Private View/Pure Functions ============ */

    /**
     * @dev   Reverts if `extension` is not an approved earner.
     * @param extension Address of an extension.
     */
    function _revertIfNotApprovedExtension(address extension) private view {
        if (!_isApprovedEarner(extension)) revert NotApprovedExtension(extension);
    }

    /**
     * @dev   Reverts if `account` is not an approved M token swapper.
     * @param account Address of an extension.
     */
    function _revertIfNotApprovedSwapper(address account) private view {
        if (!hasRole(M_SWAPPER_ROLE, account)) revert NotApprovedSwapper(account);
    }

    /**
     * @dev    Checks if the given extension is an approved earner.
     * @param  extension Address of the extension to check.
     * @return True if the extension is an approved earner, false otherwise.
     */
    function _isApprovedEarner(address extension) private view returns (bool) {
        return
            IRegistrarLike(registrar).get(EARNERS_LIST_IGNORED_KEY) != bytes32(0) ||
            IRegistrarLike(registrar).listContains(EARNERS_LIST_NAME, extension);
    }
}

// SPDX-License-Identifier: BUSL-1.1

pragma solidity 0.8.26;

import { IERC20 } from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "../../lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
import { AccessControl } from "../../lib/openzeppelin-contracts/contracts/access/AccessControl.sol";

import { IUniswapV3SwapAdapter } from "./interfaces/IUniswapV3SwapAdapter.sol";
import { IV3SwapRouter } from "./interfaces/uniswap/IV3SwapRouter.sol";

/**
 * @title  Uniswap V3 Swap Adapter
 * @author M0 Labs
 *         MetaStreet Foundation
 *         Adapted from https://github.com/metastreet-labs/metastreet-usdai-contracts/blob/main/src/swapAdapters/UniswapV3SwapAdapter.sol
 */
contract UniswapV3SwapAdapter is IUniswapV3SwapAdapter, AccessControl {
    using SafeERC20 for IERC20;

    /// @notice Fee for Uniswap V3 swap router (0.01%)
    uint24 internal constant UNISWAP_V3_FEE = 100;

    /// @notice Path address size
    uint256 internal constant PATH_ADDR_SIZE = 20;

    /// @notice Path fee size
    uint256 internal constant PATH_FEE_SIZE = 3;

    /// @notice Path next offset
    uint256 internal constant PATH_NEXT_OFFSET = PATH_ADDR_SIZE + PATH_FEE_SIZE;

    address public immutable swapRouter;

    address public immutable baseToken;

    mapping(address token => bool whitelisted) public whitelistedTokens;

    /**
     * @notice Constructs UniswapV3SwapAdapter contract
     * @param  baseToken_ The address of base token.
     * @param  swapRouter_ The address of the Uniswap V3 swap router.
     * @param  admin The address of the admin.
     * @param  tokens The list of whitelisted tokens.
     */
    constructor(address baseToken_, address swapRouter_, address admin, address[] memory tokens) {
        if ((baseToken = baseToken_) == address(0)) revert ZeroBaseToken();
        if ((swapRouter = swapRouter_) == address(0)) revert ZeroSwapRouter();

        _grantRole(DEFAULT_ADMIN_ROLE, admin);

        for (uint256 i; i < tokens.length; i++) {
            _whitelistToken(tokens[i], true);
        }
    }

    /// @inheritdoc IUniswapV3SwapAdapter
    function swapIn(
        address inputToken,
        uint256 inputAmount,
        uint256 minBaseAmount,
        address recipient,
        bytes calldata path
    ) external returns (uint256 baseAmount) {
        _revertIfNotWhitelistedToken(inputToken);
        _revertIfZeroAmount(inputAmount);
        _revertIfInvalidSwapInPath(inputToken, path);
        _revertIfZeroRecipient(recipient);

        // Transfer token input from sender to this contract
        IERC20(inputToken).safeTransferFrom(msg.sender, address(this), inputAmount);

        address swapRouter_ = swapRouter;

        // Approve the router to spend token input
        IERC20(inputToken).forceApprove(swapRouter_, inputAmount);

        // Swap token input for base token
        if (path.length == 0) {
            IV3SwapRouter.ExactInputSingleParams memory params = IV3SwapRouter.ExactInputSingleParams({
                tokenIn: inputToken,
                tokenOut: baseToken,
                fee: UNISWAP_V3_FEE,
                recipient: recipient,
                amountIn: inputAmount,
                amountOutMinimum: minBaseAmount,
                sqrtPriceLimitX96: 0
            });

            baseAmount = IV3SwapRouter(swapRouter_).exactInputSingle(params);
        } else {
            IV3SwapRouter.ExactInputParams memory params = IV3SwapRouter.ExactInputParams({
                path: path,
                recipient: msg.sender,
                amountIn: inputAmount,
                amountOutMinimum: minBaseAmount
            });

            baseAmount = IV3SwapRouter(swapRouter_).exactInput(params);
        }
    }

    /// @inheritdoc IUniswapV3SwapAdapter
    function swapOut(
        address outputToken,
        uint256 baseAmount,
        uint256 minOutputAmount,
        address recipient,
        bytes calldata path
    ) external returns (uint256 outputAmount) {
        _revertIfNotWhitelistedToken(outputToken);
        _revertIfZeroAmount(baseAmount);
        _revertIfInvalidSwapOutPath(outputToken, path);
        _revertIfZeroRecipient(recipient);

        // Transfer token input from sender to this contract
        IERC20(baseToken).safeTransferFrom(msg.sender, address(this), baseAmount);

        // Approve the router to spend base token
        IERC20(baseToken).forceApprove(swapRouter, baseAmount);

        // Swap base token for token output
        if (path.length == 0) {
            IV3SwapRouter.ExactInputSingleParams memory params = IV3SwapRouter.ExactInputSingleParams({
                tokenIn: baseToken,
                tokenOut: outputToken,
                fee: UNISWAP_V3_FEE,
                recipient: recipient,
                amountIn: baseAmount,
                amountOutMinimum: minOutputAmount,
                sqrtPriceLimitX96: 0
            });

            outputAmount = IV3SwapRouter(swapRouter).exactInputSingle(params);
        } else {
            IV3SwapRouter.ExactInputParams memory params = IV3SwapRouter.ExactInputParams({
                path: path,
                recipient: recipient,
                amountIn: baseAmount,
                amountOutMinimum: minOutputAmount
            });

            outputAmount = IV3SwapRouter(swapRouter).exactInput(params);
        }
    }

    /// @inheritdoc IUniswapV3SwapAdapter
    function whitelistToken(address token, bool isWhitelisted) external onlyRole(DEFAULT_ADMIN_ROLE) {
        _whitelistToken(token, isWhitelisted);
    }

    function _whitelistToken(address token, bool isWhitelisted) private {
        if (token == address(0)) revert ZeroToken();
        whitelistedTokens[token] = isWhitelisted;

        emit TokenWhitelisted(token, isWhitelisted);
    }

    /**
     * @notice Decode input and output tokens
     * @param  path Swap path
     * @return tokenInput Address of the input token
     * @return tokenOutput Address if the output token
     */
    function _decodeInputAndOutputTokens(
        bytes calldata path
    ) internal pure returns (address tokenInput, address tokenOutput) {
        // Validate path format
        if (
            (path.length < PATH_ADDR_SIZE + PATH_FEE_SIZE + PATH_ADDR_SIZE) ||
            ((path.length - PATH_ADDR_SIZE) % PATH_NEXT_OFFSET != 0)
        ) {
            revert InvalidPathFormat();
        }

        tokenInput = address(bytes20(path[:PATH_ADDR_SIZE]));

        // Calculate position of output token
        uint256 numHops = (path.length - PATH_ADDR_SIZE) / PATH_NEXT_OFFSET;
        uint256 outputTokenIndex = numHops * PATH_NEXT_OFFSET;

        tokenOutput = address(bytes20(path[outputTokenIndex:outputTokenIndex + PATH_ADDR_SIZE]));
    }

    /**
     * @dev   Reverts if not whitelisted token.
     * @param token Address of a token.
     */
    function _revertIfNotWhitelistedToken(address token) internal view {
        if (!whitelistedTokens[token]) revert NotWhitelistedToken(token);
    }

    /**
     * @dev   Reverts if `recipient` is address(0).
     * @param recipient Address of a recipient.
     */
    function _revertIfZeroRecipient(address recipient) internal pure {
        if (recipient == address(0)) revert ZeroRecipient();
    }

    /**
     * @dev   Reverts if `amount` is equal to 0.
     * @param amount Amount of token.
     */
    function _revertIfZeroAmount(uint256 amount) internal pure {
        if (amount == 0) revert ZeroAmount();
    }

    /**
     * @notice Reverts if the swap path is invalid for swapping in.
     * @param  tokenInput Address of the input token.
     * @param  path Swap path.
     */
    function _revertIfInvalidSwapInPath(address tokenInput, bytes calldata path) internal view {
        if (path.length != 0) {
            (address tokenInput_, address tokenOutput) = _decodeInputAndOutputTokens(path);
            if (tokenInput_ != tokenInput || tokenOutput != baseToken) revert InvalidPath();
        }
    }

    /**
     * @notice Reverts if the swap path is invalid for swapping out.
     * @param  tokenOutput Address of the output token.
     * @param  path Swap path.
     */
    function _revertIfInvalidSwapOutPath(address tokenOutput, bytes calldata path) internal view {
        if (path.length != 0) {
            (address tokenInput, address tokenOutput_) = _decodeInputAndOutputTokens(path);
            if (tokenInput != baseToken || tokenOutput_ != tokenOutput) revert InvalidPath();
        }
    }
}


Vulnerability type: CWE-682|CWE-1339