MEDIUM severity: relayTokens Calls Made By Forwarders May Fail. The `Router_Adapter` contract sends two separate messages to L2 forwarders: one to relay tokens and another to trigger their forwarding to L3. These messages may arrive out of order due to differing bridging mechanisms (e.g., CCTP for tokens, Arbitrum Inbox for messages). 
The root cause is the lack...

Vulnerable code:
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.0;

import { AdapterInterface } from "./interfaces/AdapterInterface.sol";
import { ForwarderInterface } from "./interfaces/ForwarderInterface.sol";
import { HubPoolInterface } from "../interfaces/HubPoolInterface.sol";

/**
 * @notice Contract containing logic to send messages from L1 to "L3" networks that do not have direct connections
 * with L1. L3's are defined as networks that connect to L1 indirectly via L2, and this contract sends
 * messages to those L3's by rerouting them via those L2's. This contract is called a "Router" because it uses
 * (i.e. delegatecall's) existing L2 adapter logic to send a message first from L1 to L2 and then from L2 to L3.
 * @dev Due to the constraints of the `SetCrossChainContracts` event as outlined in UMIP-157 and how the HubPool
 * delegatecalls adapters like this one, all messages relayed through this
 * adapter have target addresses on the L3's. However, these target addresses do not exist on L2 where all messages are
 * rerouted through. Therefore, this contract is designed to be used in tandem with "L2 Forwarder Adapters" which help
 * get the messages from L1 to L3 via L2's.
 * @dev Public functions calling external contracts do not guard against reentrancy because they are expected to be
 * called via delegatecall, which will execute this contract's logic within the context of the originating contract.
 * For example, the HubPool will delegatecall these functions, therefore its only necessary that the HubPool's methods
 * that call this contract's logic guard against reentrancy.
 * @custom:security-contact bugs@across.to
 */

// solhint-disable-next-line contract-name-camelcase
contract Router_Adapter is AdapterInterface {
    // Adapter designed to relay messages from L1 to L2 addresses and delegatecalled by this contract to reroute
    // messages to L3 via the L2_TARGET.
    address public immutable L1_ADAPTER;
    // L2_TARGET is a "Forwarder" contract that will help relay messages from L1 to L3. Messages are "rerouted" through
    // the L2_TARGET.
    address public immutable L2_TARGET;
    // L2_CHAIN_ID is the chain ID of the network which is "in between" the L1 and L3. This network will contain the forwarder.
    uint256 public immutable L2_CHAIN_ID;
    // L3_CHAIN_ID is the chain ID of the network which contains the target spoke pool. This chain id is passed to the
    // forwarder contract so that it may determine the correct L2-L3 bridge to use to arrive at L3.
    uint256 public immutable L3_CHAIN_ID;
    // Interface of the Hub Pool. Used to query state related to L1-L2 token mappings.
    HubPoolInterface public immutable HUB_POOL;

    error RelayMessageFailed();
    error RelayTokensFailed(address l1Token);
    error L2RouteNotWhitelisted(address l1Token);

    /**
     * @notice Constructs new Adapter. This contract will re-route messages destined for an L3 to L2_TARGET via the L1_ADAPTER contract.
     * @param _l1Adapter Address of the adapter contract on mainnet which implements message transfers
     * and token relays to the L2 where _l2Target is deployed.
     * @param _l2Target Address of the L2 contract which receives the token and message relays in order to forward them to an L3.
     * @param _l2ChainId Chain ID of the network which contains the forwarder. It is the network which is intermediate in message
     * transmission from L1 to L3.
     * @param _l3ChainId Chain ID of the network which contains the spoke pool which corresponds to this adapter instance.
     * @param _hubPool Address of the hub pool deployed on L1.
     */
    constructor(
        address _l1Adapter,
        address _l2Target,
        uint256 _l2ChainId,
        uint256 _l3ChainId,
        HubPoolInterface _hubPool
    ) {
        L1_ADAPTER = _l1Adapter;
        L2_TARGET = _l2Target;
        L2_CHAIN_ID = _l2ChainId;
        L3_CHAIN_ID = _l3ChainId;
        HUB_POOL = _hubPool;
    }

    /**
     * @notice Send cross-chain message to a target on L2 which will forward messages to the intended remote target on an L3.
     * @param target Address of the remote contract which receives `message` after it has been forwarded by all intermediate
     * contracts.
     * @param message Data to send to `target`.
     * @dev The message passed into this function is wrapped into a `relayMessage` function call, which is then passed
     * to L2. The `L2_TARGET` contract implements AdapterInterface, so upon arrival on L2, the arguments to the L2 contract's
     * `relayMessage` call will be these `target` and `message` values. From there, the forwarder derives the next appropriate
     * method to send `message` to the following layers and ultimately to the target on L3.
     */
    function relayMessage(address target, bytes memory message) external payable override {
        bytes memory wrappedMessage = abi.encodeCall(ForwarderInterface.relayMessage, (target, L3_CHAIN_ID, message));
        (bool success, ) = L1_ADAPTER.delegatecall(
            abi.encodeCall(AdapterInterface.relayMessage, (L2_TARGET, wrappedMessage))
        );
        if (!success) revert RelayMessageFailed();
    }

    /**
     * @notice Bridge tokens to a target on L2 and follow up the token bridge with a call to continue bridging the sent tokens.
     * @param l1Token L1 token to deposit.
     * @param l3Token L3 token to receive.
     * @param amount Amount of L1 tokens to deposit and L3 tokens to receive.
     * @param target The address of the contract which should ultimately receive `amount` of `l3Token`.
     * @dev When sending tokens, we follow-up with a message describing the amount of tokens we wish to continue bridging.
     * This allows forwarders to know how much of some token to allocate to a certain target.
     */
    function relayTokens(
        address l1Token,
        address l3Token,
        uint256 amount,
        address target
    ) external payable override {
        // Fetch the address of the L2 token, as defined in the Hub Pool. This is to complete a proper token bridge from L1 to L2.
        address l2Token = HUB_POOL.poolRebalanceRoute(L2_CHAIN_ID, l1Token);
        // If l2Token is the zero address, then this means that the L2 token is not enabled as a pool rebalance route. This is similar
        // to the check in the hub pool contract found here: https://github.com/across-protocol/contracts/blob/a2afefecba57177a62be35be092516d0c106097e/contracts/HubPool.sol#L890
        if (l2Token == address(0)) revert L2RouteNotWhitelisted(l1Token);

        // Relay tokens to the forwarder.
        (bool success, ) = L1_ADAPTER.delegatecall(
            abi.encodeCall(AdapterInterface.relayTokens, (l1Token, l2Token, amount, L2_TARGET))
        );
        if (!success) revert RelayTokensFailed(l1Token);

        // Follow-up token relay with a message to continue the token relay on L2.
        bytes memory message = abi.encodeCall(
            ForwarderInterface.relayTokens,
            (l2Token, l3Token, amount, L3_CHAIN_ID, target)
        );
        (success, ) = L1_ADAPTER.delegatecall(abi.encodeCall(AdapterInterface.relayMessage, (L2_TARGET, message)));
        if (!success) revert RelayMessageFailed();
    }
}

// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.0;

import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import { ForwarderInterface } from "./interfaces/ForwarderInterface.sol";
import { AdapterInterface } from "./interfaces/AdapterInterface.sol";

/**
 * @title ForwarderBase
 * @notice This contract expects to receive messages and tokens from an authorized sender on L1 and forwards messages and tokens to spoke pool contracts on
 * L3. Messages are intended to originate from the hub pool. The motivating use case for this contract is to aid with sending messages from L1 to an L3, which
 * by definition is a network which does not have a direct connection with L1 but instead must communicate with that L1 via an L2. Each contract that extends
 * the ForwarderBase maintains a mapping of chain IDs to a bridge adapter addresses. For example, if this contract is deployed on Arbitrum, then this mapping
 * would send L3 chain IDs which roll up to Arbitrum to an adapter contract address deployed on Arbitrum which directly interfaces with the L3 token/message
 * bridge. In other words, this contract maintains a mapping of important contracts which helps transmit messages to the "next layer".
 * @custom:security-contact bugs@across.to
 */
abstract contract ForwarderBase is UUPSUpgradeable, ForwarderInterface {
    // Address that can relay messages using this contract and also upgrade this contract.
    address public crossDomainAdmin;

    // Map from a destination chain ID to the address of an adapter contract which interfaces with the L2-L3 bridge. The destination chain ID corresponds to
    // the network ID of an L3. These chain IDs are used as the key in this mapping because network IDs are enforced to be unique. Since we require the chain
    // ID to be sent along with a message or token relay, ForwarderInterface's relay functions include an extra field, `destinationChainId`, when compared to the
    // relay functions of `AdapterInterface`.
    mapping(uint256 => address) chainAdapters;

    event ChainAdaptersUpdated(uint256 indexed destinationChainId, address l2Adapter);
    event SetXDomainAdmin(address indexed crossDomainAdmin);

    error InvalidCrossDomainAdmin();
    error InvalidChainAdapter();
    error RelayMessageFailed();
    error RelayTokensFailed(address baseToken);
    // Error which is triggered when there is no adapter set in the `chainAdapters` mapping.
    error UninitializedChainAdapter();

    /*
     * @dev Cross domain admin permissioning is implemented specifically for each L2 that this contract is deployed on, so this base contract
     * simply prescribes this modifier to protect external functions using that L2's specific admin permissioning logic.
     */
    modifier onlyAdmin() {
        _requireAdminSender();
        _;
    }

    /**
     * @notice Constructs the Forwarder contract.
     * @dev _disableInitializers() restricts anybody from initializing the implementation contract, which if not done,
     * may disrupt the proxy if another EOA were to initialize it.
     */
    constructor() {
        _disableInitializers();
    }

    /**
     * @notice Initializes the forwarder contract.
     * @param _crossDomainAdmin L1 address of the contract which can send root bundles/messages to this forwarder contract.
     */
    function __Forwarder_init(address _crossDomainAdmin) public onlyInitializing {
        __UUPSUpgradeable_init();
        _setCrossDomainAdmin(_crossDomainAdmin);
    }

    /**
     * @notice Sets a new cross domain admin for this contract.
     * @param _newCrossDomainAdmin L1 address of the new cross domain admin.
     */
    function setCrossDomainAdmin(address _newCrossDomainAdmin) external onlyAdmin {
        if (_newCrossDomainAdmin == address(0)) revert InvalidCrossDomainAdmin();
        _setCrossDomainAdmin(_newCrossDomainAdmin);
        emit SetXDomainAdmin(_newCrossDomainAdmin);
    }

    /**
     * @notice Maps a new destination chain ID to an adapter contract which facilitates bridging to that chain.
     * @param _destinationChainId The chain ID of the target network.
     * @param _l2Adapter Contract address of the adapter which interfaces with the L2-L3 bridge.
     * @dev Actual bridging logic is delegated to the adapter contract so that the forwarder can function irrespective of the "flavor" of
     * L3 (e.g. ArbitrumOrbit, OpStack, etc.).
     */
    function updateAdapter(uint256 _destinationChainId, address _l2Adapter) external onlyAdmin {
        if (_l2Adapter == address(0)) revert InvalidChainAdapter();
        chainAdapters[_destinationChainId] = _l2Adapter;
        emit ChainAdaptersUpdated(_destinationChainId, _l2Adapter);
    }

    /**
     * @notice Relays a specified message to a contract on L3. This contract assumes that `target` exists on the L3 and can properly
     * receive the function being called.
     * @param target The address of the spoke pool contract that will receive the input message.
     * @param destinationChainId The chain ID of the network which contains `target`.
     * @param message The data to execute on the target contract.
     */
    function relayMessage(
        address target,
        uint256 destinationChainId,
        bytes memory message
    ) external payable override onlyAdmin {
        address adapter = chainAdapters[destinationChainId];
        if (adapter == address(0)) revert UninitializedChainAdapter();

        // The forwarder assumes that `target` exists on the following network.
        (bool success, ) = adapter.delegatecall(abi.encodeCall(AdapterInterface.relayMessage, (target, message)));
        if (!success) revert RelayMessageFailed();
        emit MessageForwarded(target, destinationChainId, message);
    }

    /**
     * @notice Relays `amount` of a token to a contract on L3. Importantly, this contract assumes that `target` exists on L3.
     * @param baseToken This layer's address of the token to send.
     * @param destinationChainToken The next layer's address of the token to send.
     * @param amount The amount of the token to send.
     * @param destinationChainId The chain ID of the network which contains `target`.
     * @param target The address of the contract that which will *ultimately* receive the tokens. For most cases, this is the spoke pool contract on L3.
     * @dev While `relayMessage` also assumes that `target` is correct, this function has the potential of deleting funds if `target` is incorrectly set.
     * This should be guarded by the logic of the Hub Pool on L1, since the Hub Pool will always set `target` to the L3 spoke pool per UMIP-157.
     */
    function relayTokens(
        address baseToken,
        address destinationChainToken,
        uint256 amount,
        uint256 destinationChainId,
        address target
    ) external payable override onlyAdmin {
        address adapter = chainAdapters[destinationChainId];
        if (adapter == address(0)) revert UninitializedChainAdapter();

        (bool success, ) = adapter.delegatecall(
            abi.encodeCall(AdapterInterface.relayTokens, (baseToken, destinationChainToken, amount, target))
        );
        if (!success) revert RelayTokensFailed(baseToken);
        emit TokensForwarded(baseToken, destinationChainToken, amount, destinationChainId, target);
    }

    // Function to be overridden in order to authenticate that messages sent to this contract originated
    // from the expected account.
    function _requireAdminSender() internal virtual;

    // We also want to restrict who can upgrade this contract. The same admin that can relay messages through this
    // contract can upgrade this contract.
    function _authorizeUpgrade(address) internal virtual override onlyAdmin {}

    function _setCrossDomainAdmin(address _newCrossDomainAdmin) internal {
        if (_newCrossDomainAdmin == address(0)) revert InvalidCrossDomainAdmin();
        crossDomainAdmin = _newCrossDomainAdmin;
        emit SetXDomainAdmin(_newCrossDomainAdmin);
    }

    // Reserve storage slots for future versions of this base contract to add state variables without
    // affecting the storage layout of child contracts. Decrement the size of __gap whenever state variables
    // are added. This is at bottom of contract to make sure it's always at the end of storage.
    uint256[1000] private __gap;
}

// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.0;

import "./interfaces/AdapterInterface.sol";

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "../external/interfaces/CCTPInterfaces.sol";
import "../libraries/CircleCCTPAdapter.sol";
import { ArbitrumInboxLike as ArbitrumL1InboxLike, ArbitrumL1ERC20GatewayLike } from "../interfaces/ArbitrumBridge.sol";

/**
 * @notice Contract containing logic to send messages from L1 to Arbitrum.
 * @dev Public functions calling external contracts do not guard against reentrancy because they are expected to be
 * called via delegatecall, which will execute this contract's logic within the context of the originating contract.
 * For example, the HubPool will delegatecall these functions, therefore its only necessary that the HubPool's methods
 * that call this contract's logic guard against reentrancy.
 */

// solhint-disable-next-line contract-name-camelcase
contract Arbitrum_Adapter is AdapterInterface, CircleCCTPAdapter {
    using SafeERC20 for IERC20;

    // Amount of ETH allocated to pay for the base submission fee. The base submission fee is a parameter unique to
    // retryable transactions; the user is charged the base submission fee to cover the storage costs of keeping their
    // ticket’s calldata in the retry buffer. (current base submission fee is queryable via
    // ArbRetryableTx.getSubmissionPrice). ArbRetryableTicket precompile interface exists at L2 address
    // 0x000000000000000000000000000000000000006E.
    uint256 public constant L2_MAX_SUBMISSION_COST = 0.01e18;

    // L2 Gas price bid for immediate L2 execution attempt (queryable via standard eth*gasPrice RPC)
    uint256 public constant L2_GAS_PRICE = 5e9; // 5 gWei

    // Native token expected to be sent in L2 message. Should be 0 for only use case of this constant, which
    // includes is sending messages from L1 to L2.
    uint256 public constant L2_CALL_VALUE = 0;

    // Gas limit for L2 execution of a cross chain token transfer sent via the inbox.
    uint32 public constant RELAY_TOKENS_L2_GAS_LIMIT = 300_000;
    // Gas limit for L2 execution of a message sent via the inbox.
    uint32 public constant RELAY_MESSAGE_L2_GAS_LIMIT = 2_000_000;

    address public constant L1_DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;

    // This address on L2 receives extra ETH that is left over after relaying a message via the inbox.
    address public immutable L2_REFUND_L2_ADDRESS;

    // Inbox system contract to send messages to Arbitrum. Token bridges use this to send tokens to L2.
    // https://github.com/OffchainLabs/nitro-contracts/blob/f7894d3a6d4035ba60f51a7f1334f0f2d4f02dce/src/bridge/Inbox.sol
    ArbitrumL1InboxLike public immutable L1_INBOX;

    // Router contract to send tokens to Arbitrum. Routes to correct gateway to bridge tokens. Internally this
    // contract calls the Inbox.
    // Generic gateway: https://github.com/OffchainLabs/token-bridge-contracts/blob/main/contracts/tokenbridge/ethereum/gateway/L1ArbitrumGateway.sol
    ArbitrumL1ERC20GatewayLike public immutable L1_ERC20_GATEWAY_ROUTER;

    /**
     * @notice Constructs new Adapter.
     * @param _l1ArbitrumInbox Inbox helper contract to send messages to Arbitrum.
     * @param _l1ERC20GatewayRouter ERC20 gateway router contract to send tokens to Arbitrum.
     * @param _l2RefundL2Address L2 address to receive gas refunds on after a message is relayed.
     * @param _l1Usdc USDC address on L1.
     * @param _cctpTokenMessenger TokenMessenger contract to bridge via CCTP.
     */
    constructor(
        ArbitrumL1InboxLike _l1ArbitrumInbox,
        ArbitrumL1ERC20GatewayLike _l1ERC20GatewayRouter,
        address _l2RefundL2Address,
        IERC20 _l1Usdc,
        ITokenMessenger _cctpTokenMessenger
    ) CircleCCTPAdapter(_l1Usdc, _cctpTokenMessenger, CircleDomainIds.Arbitrum) {
        L1_INBOX = _l1ArbitrumInbox;
        L1_ERC20_GATEWAY_ROUTER = _l1ERC20GatewayRouter;
        L2_REFUND_L2_ADDRESS = _l2RefundL2Address;
    }

    /**
     * @notice Send cross-chain message to target on Arbitrum.
     * @notice This contract must hold at least getL1CallValue() amount of ETH to send a message via the Inbox
     * successfully, or the message will get stuck.
     * @param target Contract on Arbitrum that will receive message.
     * @param message Data to send to target.
     */
    function relayMessage(address target, bytes memory message) external payable override {
        uint256 requiredL1CallValue = _contractHasSufficientEthBalance(RELAY_MESSAGE_L2_GAS_LIMIT);

        L1_INBOX.createRetryableTicket{ value: requiredL1CallValue }(
            target, // destAddr destination L2 contract address
            L2_CALL_VALUE, // l2CallValue call value for retryable L2 message
            L2_MAX_SUBMISSION_COST, // maxSubmissionCost Max gas deducted from user's L2 balance to cover base fee
            L2_REFUND_L2_ADDRESS, // excessFeeRefundAddress maxgas * gasprice - execution cost gets credited here on L2
            L2_REFUND_L2_ADDRESS, // callValueRefundAddress l2Callvalue gets credited here on L2 if retryable txn times out or gets cancelled
            RELAY_MESSAGE_L2_GAS_LIMIT, // maxGas Max gas deducted from user's L2 balance to cover L2 execution
            L2_GAS_PRICE, // gasPriceBid price bid for L2 execution
            message // data ABI encoded data of L2 message
        );

        emit MessageRelayed(target, message);
    }

    /**
     * @notice Bridge tokens to Arbitrum.
     * @notice This contract must hold at least getL1CallValue() amount of ETH to send a message via the Inbox
     * successfully, or the message will get stuck.
     * @param l1Token L1 token to deposit.
     * @param l2Token L2 token to receive.
     * @param amount Amount of L1 tokens to deposit and L2 tokens to receive.
     * @param to Bridge recipient.
     */
    function relayTokens(
        address l1Token,
        address l2Token, // l2Token is unused for Arbitrum.
        uint256 amount,
        address to
    ) external payable override {
        // Check if this token is USDC, which requires a custom bridge via CCTP.
        if (_isCCTPEnabled() && l1Token == address(usdcToken)) {
            _transferUsdc(to, amount);
        }
        // If not, we can use the Arbitrum gateway
        else {
            uint256 requiredL1CallValue = _contractHasSufficientEthBalance(RELAY_TOKENS_L2_GAS_LIMIT);

            // Approve the gateway, not the router, to spend the hub pool's balance. The gateway, which is different
            // per L1 token, will temporarily escrow the tokens to be bridged and pull them from this contract.
            address erc20Gateway = L1_ERC20_GATEWAY_ROUTER.getGateway(l1Token);
            IERC20(l1Token).safeIncreaseAllowance(erc20Gateway, amount);

            // `outboundTransfer` expects that the caller includes a bytes message as the last param that includes the
            // maxSubmissionCost to use when creating an L2 retryable ticket: https://github.com/OffchainLabs/arbitrum/blob/e98d14873dd77513b569771f47b5e05b72402c5e/packages/arb-bridge-peripherals/contracts/tokenbridge/ethereum/gateway/L1GatewayRouter.sol#L232
            bytes memory data = abi.encode(L2_MAX_SUBMISSION_COST, "");

            // Note: Legacy routers don't have the outboundTransferCustomRefund method, so default to using
            // outboundTransfer(). Legacy routers are used for the following tokens that are currently enabled:
            // - DAI: the implementation of `outboundTransfer` at the current DAI custom gateway
            //        (https://etherscan.io/address/0xD3B5b60020504bc3489D6949d545893982BA3011#writeContract) sets the
            //        sender as the refund address so the aliased HubPool should receive excess funds. Implementation here:
            //        https://github.com/makerdao/arbitrum-dai-bridge/blob/11a80385e2622968069c34d401b3d54a59060e87/contracts/l1/L1DaiGateway.sol#L109
            if (l1Token == L1_DAI) {
                // This means that the excess ETH to pay for the L2 transaction will be sent to the aliased
                // contract address on L2, which we'd have to retrieve via a custom adapter, the Arbitrum_RescueAdapter.
                // To do so, in a single transaction: 1) setCrossChainContracts to Arbitrum_RescueAdapter, 2) relayMessage
                // with function data = abi.encode(amountToRescue), 3) setCrossChainContracts back to this adapter.
                L1_ERC20_GATEWAY_ROUTER.outboundTransfer{ value: requiredL1CallValue }(
                    l1Token,
                    to,
                    amount,
                    RELAY_TOKENS_L2_GAS_LIMIT,
                    L2_GAS_PRICE,
                    data
                );
            } else {
                L1_ERC20_GATEWAY_ROUTER.outboundTransferCustomRefund{ value: requiredL1CallValue }(
                    l1Token,
                    L2_REFUND_L2_ADDRESS,
                    to,
                    amount,
                    RELAY_TOKENS_L2_GAS_LIMIT,
                    L2_GAS_PRICE,
                    data
                );
            }
        }
        emit TokensRelayed(l1Token, l2Token, amount, to);
    }

    /**
     * @notice Returns required amount of ETH to send a message via the Inbox.
     * @param l2GasLimit L2 gas limit for the message.
     * @return amount of ETH that this contract needs to hold in order for relayMessage to succeed.
     */
    function getL1CallValue(uint32 l2GasLimit) public pure returns (uint256) {
        return L2_MAX_SUBMISSION_COST + L2_GAS_PRICE * l2GasLimit;
    }

    function _contractHasSufficientEthBalance(uint32 l2GasLimit) internal view returns (uint256) {
        uint256 requiredL1CallValue = getL1CallValue(l2GasLimit);
        require(address(this).balance >= requiredL1CallValue, "Insufficient ETH balance");
        return requiredL1CallValue;
    }
}


Vulnerability type: CWE-691|CWE-362