HIGH severity: Incorrect equivalence assumption on chain IDs and prover domain IDs may lead to loss of solver funds. The protocol incorrectly assumes that chain IDs are equivalent to prover domain IDs in HyperProver.sol and MetaProver.sol, using them interchangeably when formatting prover calls. This is a flawed assumption because cross-chain frameworks like Hyperlane and Metalayer use distinct domain ID systems t...

Vulnerable code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {IMessageRecipient} from "@hyperlane-xyz/core/contracts/interfaces/IMessageRecipient.sol";
import {TypeCasts} from "@hyperlane-xyz/core/contracts/libs/TypeCasts.sol";
import {MessageBridgeProver} from "./MessageBridgeProver.sol";
import {Semver} from "../libs/Semver.sol";
import {IMailbox, IPostDispatchHook} from "@hyperlane-xyz/core/contracts/interfaces/IMailbox.sol";

/**
 * @title HyperProver
 * @notice Prover implementation using Hyperlane's cross-chain messaging system
 * @dev Processes proof messages from Hyperlane mailbox and records proven intents
 */
contract HyperProver is IMessageRecipient, MessageBridgeProver, Semver {
    using TypeCasts for bytes32;

    /**
     * @notice Struct for unpacked data from _data parameter
     * @dev Only contains fields decoded from the _data parameter
     */
    struct UnpackedData {
        bytes32 sourceChainProver; // Address of prover on source chain
        bytes metadata; // Metadata for Hyperlane message
        address hookAddr; // Address of post-dispatch hook
    }

    /**
     * @notice Struct for Hyperlane dispatch parameters
     * @dev Consolidates message dispatch parameters to reduce stack usage
     */
    struct DispatchParams {
        uint32 destinationDomain; // Hyperlane domain ID
        bytes32 recipientAddress; // Recipient address encoded as bytes32
        bytes messageBody; // Encoded message body with intent hashes and claimants
        bytes metadata; // Additional metadata for the message
        IPostDispatchHook hook; // Post-dispatch hook contract
    }

    /**
     * @notice Constant indicating this contract uses Hyperlane for proving
     */
    string public constant PROOF_TYPE = "Hyperlane";

    /**
     * @notice Address of local Hyperlane mailbox
     */
    address public immutable MAILBOX;

    /**
     * @param mailbox Address of local Hyperlane mailbox
     * @param portal Address of Portal contract
     * @param provers Array of trusted prover addresses (as bytes32 for cross-VM compatibility)
     */
    constructor(
        address mailbox,
        address portal,
        bytes32[] memory provers
    ) MessageBridgeProver(portal, provers, 0) {
        if (mailbox == address(0)) revert MessengerContractCannotBeZeroAddress();
        MAILBOX = mailbox;
    }

    /**
     * @notice Handles incoming Hyperlane messages containing proof data
     * @dev Processes batch updates to proven intents from valid sources
     * @param origin Origin chain ID from the source chain
     * @param sender Address that dispatched the message on source chain
     * @param messageBody Encoded array of intent hashes and claimants
     */
    function handle(
        uint32 origin,
        bytes32 sender,
        bytes calldata messageBody
    ) public payable only(MAILBOX) {
        // Verify origin and sender are valid
        if (origin == 0) revert MessageOriginChainDomainIDCannotBeZero();

        // Validate sender is not zero
        if (sender == bytes32(0)) revert MessageSenderCannotBeZeroAddress();

        _handleCrossChainMessage(sender, messageBody);
    }

    /**
     * @notice Implementation of message dispatch for Hyperlane
     * @dev Called by base prove() function after common validations
     * @param domainID Domain ID of the source chain
     * @param encodedProofs Encoded (intentHash, claimant) pairs as bytes
     * @param data Additional data for message formatting
     * @param fee Fee amount for message dispatch
     */
    function _dispatchMessage(
        uint64 domainID,
        bytes calldata encodedProofs,
        bytes calldata data,
        uint256 fee
    ) internal override {
        // Parse incoming data into a structured format for processing
        UnpackedData memory unpacked = _unpackData(data);

        // Prepare parameters for cross-chain message dispatch using a struct
        // to reduce stack usage and improve code maintainability
        DispatchParams memory params = _formatHyperlaneMessage(
            domainID,
            encodedProofs,
            unpacked
        );

        // Send the message through Hyperlane mailbox using params from the struct
        // Note: Some Hyperlane versions have different dispatch signatures.
        // This matches the expected signature for testing.
        IMailbox(MAILBOX).dispatch{value: fee}(
            params.destinationDomain,
            params.recipientAddress,
            params.messageBody,
            params.metadata,
            params.hook
        );
    }

    /**
     * @notice Calculates the fee required for Hyperlane message dispatch
     * @dev Queries the Mailbox contract for accurate fee estimation
     * @param domainID Domain ID of the source chain
     * @param encodedProofs Encoded (intentHash, claimant) pairs as bytes
     * @param data Additional data for message formatting
     * @return Fee amount required for message dispatch
     */
    function fetchFee(
        uint64 domainID,
        bytes calldata encodedProofs,
        bytes calldata data
    ) public view override returns (uint256) {
        // Decode structured data from the raw input
        UnpackedData memory unpacked = _unpackData(data);

        // Process fee calculation using the decoded struct
        // This architecture separates decoding from core business logic
        return _fetchFee(domainID, encodedProofs, unpacked);
    }

    /**
     * @notice Decodes the raw cross-chain message data into a structured format
     * @dev Parses ABI-encoded parameters into the UnpackedData struct
     * @param data Raw message data containing source chain information
     * @return unpacked Structured representation of the decoded parameters
     */
    function _unpackData(
        bytes calldata data
    ) internal pure returns (UnpackedData memory unpacked) {
        unpacked = abi.decode(data, (UnpackedData));
    }

    /**
     * @notice Internal function to calculate the fee with pre-decoded data
     * @param domainID Domain ID of the source chain
     * @param encodedProofs Encoded (intentHash, claimant) pairs as bytes
     * @param unpacked Struct containing decoded data from data parameter
     * @return Fee amount required for message dispatch
     */
    function _fetchFee(
        uint64 domainID,
        bytes calldata encodedProofs,
        UnpackedData memory unpacked
    ) internal view returns (uint256) {
        // Format and prepare message parameters for dispatch
        DispatchParams memory params = _formatHyperlaneMessage(
            domainID,
            encodedProofs,
            unpacked
        );

        // Query Hyperlane mailbox for accurate fee estimate
        return
            IMailbox(MAILBOX).quoteDispatch(
                params.destinationDomain,
                params.recipientAddress,
                params.messageBody,
                params.metadata,
                params.hook
            );
    }

    /**
     * @notice Returns the proof type used by this prover
     * @return ProofType indicating Hyperlane proving mechanism
     */
    function getProofType() external pure override returns (string memory) {
        return PROOF_TYPE;
    }

    /**
     * @notice Formats data for Hyperlane message dispatch with encoded proofs
     * @dev Prepares all parameters needed for the Mailbox dispatch call
     * @param domainID Domain ID of the source chain
     * @param encodedProofs Encoded (intentHash, claimant) pairs as bytes
     * @param unpacked Struct containing decoded data from data parameter
     * @return params Structured dispatch parameters for Hyperlane message
     */
    function _formatHyperlaneMessage(
        uint64 domainID,
        bytes calldata encodedProofs,
        UnpackedData memory unpacked
    ) internal view returns (DispatchParams memory params) {
        // Convert domain ID to Hyperlane domain ID format with overflow check
        if (domainID > type(uint32).max) {
            revert DomainIdTooLarge(domainID);
        }
        params.destinationDomain = uint32(domainID);

        // Use the source chain prover address as the message recipient
        params.recipientAddress = unpacked.sourceChainProver;

        params.messageBody = encodedProofs;

        // Pass through metadata as provided
        params.metadata = unpacked.metadata;

        // Default to mailbox's hook if none provided, following Hyperlane best practices
        params.hook = (unpacked.hookAddr == address(0))
            ? IMailbox(MAILBOX).defaultHook()
            : IPostDispatchHook(unpacked.hookAddr);
    }
}

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {IMetalayerRecipient, ReadOperation} from "@metalayer/contracts/src/interfaces/IMetalayerRecipient.sol";
import {FinalityState} from "@metalayer/contracts/src/lib/MetalayerMessage.sol";
import {TypeCasts} from "@hyperlane-xyz/core/contracts/libs/TypeCasts.sol";
import {MessageBridgeProver} from "./MessageBridgeProver.sol";
// Import Semver for versioning support
import {Semver} from "../libs/Semver.sol";
import {StandardHookMetadata} from "@hyperlane-xyz/core/contracts/hooks/libs/StandardHookMetadata.sol";
import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
import {IMetalayerRouterExt} from "../interfaces/IMetalayerRouterExt.sol";

/**
 * @title MetaProver
 * @notice Prover implementation using Caldera Metalayer's cross-chain messaging system
 * @notice the terms "source" and "destination" are used in reference to a given intent: created on source chain, fulfilled on destination chain
 * @dev Processes proof messages from Metalayer router and records proven intents
 */
contract MetaProver is IMetalayerRecipient, MessageBridgeProver, Semver {
    using TypeCasts for bytes32;
    using TypeCasts for address;
    using SafeCast for uint256;

    /**
     * @notice Struct for unpacked data from _data parameter
     * @dev Contains fields decoded from the _data parameter
     */
    struct UnpackedData {
        bytes32 sourceChainProver; // Address of prover on source chain
        uint256 gasLimit; // Gas limit for execution
    }

    /**
     * @notice Constant indicating this contract uses Metalayer for proving
     */
    string public constant PROOF_TYPE = "Meta";

    /**
     * @notice ETH message value used in fee calculation metadata
     * @dev Set to very high value (1e36) to avoid fee calculation failures
     *      in the Metalayer router's quote dispatch function
     */
    uint256 private immutable ETH_QUOTE_VALUE = 1e36;

    /**
     * @notice Address of local Metalayer router
     */
    IMetalayerRouterExt public immutable ROUTER;

    /**
     * @notice Initializes the MetaProver contract
     * @param router Address of local Metalayer router
     * @param portal Address of Portal contract
     * @param provers Array of trusted prover addresses (as bytes32 for cross-VM compatibility)
     * @param minGasLimit Minimum gas limit for cross-chain messages (200k if zero)
     */
    constructor(
        address router,
        address portal,
        bytes32[] memory provers,
        uint256 minGasLimit
    ) MessageBridgeProver(portal, provers, minGasLimit) {
        if (router == address(0)) revert MessengerContractCannotBeZeroAddress();

        ROUTER = IMetalayerRouterExt(router);
    }

    /**
     * @notice Handles incoming Metalayer messages containing proof data
     * @dev Processes batch updates to proven intents from valid sources
     * @dev called by the Metalayer Router on the source chain
     * @param origin Origin chain ID from the destination chain
     * @param sender Address that dispatched the message on destination chain
     * @param message Encoded array of intent hashes and claimants
     */
    function handle(
        uint32 origin,
        bytes32 sender,
        bytes calldata message,
        ReadOperation[] calldata /* operations */,
        bytes[] calldata /* operationsData */
    ) external payable only(address(ROUTER)) {
        // Verify origin and sender are valid
        if (origin == 0) revert MessageOriginChainDomainIDCannotBeZero();

        // Validate sender is not zero
        if (sender == bytes32(0)) revert MessageSenderCannotBeZeroAddress();

        _handleCrossChainMessage(sender, message);
    }

    /**
     * @notice Decodes the raw cross-chain message data into a structured format
     * @dev Parses ABI-encoded parameters into the UnpackedData struct and enforces minimum gas limit
     * @param data Raw message data containing source chain information
     * @return unpacked Structured representation of the decoded parameters with validated gas limit
     */
    function _unpackData(
        bytes calldata data
    ) internal view returns (UnpackedData memory unpacked) {
        unpacked = abi.decode(data, (UnpackedData));

        // Enforce minimum gas limit to prevent underfunded transactions
        if (unpacked.gasLimit < MIN_GAS_LIMIT) {
            unpacked.gasLimit = MIN_GAS_LIMIT;
        }
    }

    /**
     * @notice Implementation of message dispatch for Metalayer
     * @dev Called by base prove() function after common validations
     * @param domainID Domain ID of the source chain
     * @param encodedProofs Encoded (intentHash, claimant) pairs as bytes
     * @param data Additional data for message formatting
     * @param fee Fee amount for message dispatch
     */
    function _dispatchMessage(
        uint64 domainID,
        bytes calldata encodedProofs,
        bytes calldata data,
        uint256 fee
    ) internal override {
        // Parse incoming data into a structured format
        UnpackedData memory unpacked = _unpackData(data);

        // Format message for dispatch using pre-decoded value
        (
            uint32 sourceChainDomain,
            bytes32 recipient,
            bytes memory message
        ) = _formatMetalayerMessage(
                domainID,
                encodedProofs,
                unpacked.sourceChainProver
            );

        // Call Metalayer router's send message function
        ROUTER.dispatch{value: fee}(
            sourceChainDomain,
            recipient,
            new ReadOperation[](0),
            message,
            FinalityState.INSTANT,
            unpacked.gasLimit
        );
    }

    /**
     * @notice Fetches fee required for message dispatch
     * @dev Uses custom hook metadata with actual gas limit to ensure accurate fee estimation.
     *      Fixes issue where 3-parameter quoteDispatch used hardcoded 100k gas limit.
     * @param domainID Domain ID of source chain
     * @param encodedProofs Encoded (intentHash, claimant) pairs as bytes
     * @param data Additional data containing gas limit that will be used in dispatch
     * @return Fee amount required for message dispatch
     */
    function fetchFee(
        uint64 domainID,
        bytes calldata encodedProofs,
        bytes calldata data
    ) public view override returns (uint256) {
        // Delegate to internal function with pre-decoded value
        return _fetchFee(domainID, encodedProofs, _unpackData(data));
    }

    /**
     * @notice Internal function to calculate fee with pre-decoded data
     * @dev Uses actual gas limit from unpacked data to ensure accurate fee estimation
     * @param domainID Domain ID of source chain
     * @param encodedProofs Encoded (intentHash, claimant) pairs as bytes
     * @param unpacked Pre-decoded data including actual gas limit that will be used
     * @return Fee amount required for message dispatch
     */
    function _fetchFee(
        uint64 domainID,
        bytes calldata encodedProofs,
        UnpackedData memory unpacked
    ) internal view returns (uint256) {
        (
            uint32 sourceChainDomain,
            bytes32 recipient,
            bytes memory message
        ) = _formatMetalayerMessage(
                domainID,
                encodedProofs,
                unpacked.sourceChainProver
            );

        // Create custom hook metadata with the actual gas limit that will be used in dispatch
        bytes memory feeHookMetadata = StandardHookMetadata.formatMetadata(
            ETH_QUOTE_VALUE,
            unpacked.gasLimit, // Use actual gas limit (min 200k)
            msg.sender, // Refund address
            bytes("") // Optional custom metadata
        );

        return
            ROUTER.quoteDispatch(
                sourceChainDomain,
                recipient,
                message,
                feeHookMetadata
            );
    }

    /**
     * @notice Returns the proof type used by this prover
     * @return ProofType indicating Metalayer proving mechanism
     */
    function getProofType() external pure override returns (string memory) {
        return PROOF_TYPE;
    }

    /**
     * @notice Formats data for Metalayer message dispatch with encoded proofs
     * @param domainID Domain ID of the source chain
     * @param encodedProofs Encoded (intentHash, claimant) pairs as bytes
     * @param sourceChainProver Pre-decoded prover address on source chain
     * @return domain Metalayer domain ID
     * @return recipient Recipient address encoded as bytes32
     * @return message Encoded message body with intent hashes and claimants
     */
    function _formatMetalayerMessage(
        uint64 domainID,
        bytes calldata encodedProofs,
        bytes32 sourceChainProver
    )
        internal
        pure
        returns (uint32 domain, bytes32 recipient, bytes memory message)
    {
        // Convert domain ID to domain with overflow check
        if (domainID > type(uint32).max) {
            revert DomainIdTooLarge(domainID);
        }
        domain = uint32(domainID);

        // Use pre-decoded source chain prover address as recipient
        recipient = sourceChainProver;

        message = encodedProofs;
    }
}


Vulnerability type: CWE-435|CWE-436|CWE-437