Cross-Chain Messaging & Bridges

Difficulty: Advanced

Cross-chain messaging enables smart contracts on different blockchains to communicate and trigger actions on each other. As the blockchain ecosystem has expanded to dozens of L1s and L2s (Ethereum, Arbitrum, Optimism, Base, Polygon, Avalanche, Solana, etc.), the need for interoperability has become critical. Cross-chain messaging protocols allow tokens to be transferred between chains, governance votes on one chain to trigger execution on another, and applications to aggregate liquidity across multiple networks.

Bridges are the infrastructure that makes cross-chain communication possible, and they come in several architectural patterns. Lock-and-mint bridges lock tokens on the source chain and mint wrapped representations on the destination chain (e.g., WBTC on Ethereum represents locked BTC). Burn-and-mint bridges burn tokens on the source and mint native tokens on the destination (used by Circle's CCTP for USDC). Liquidity pool bridges maintain pools on both chains and swap through them, avoiding wrapped tokens entirely (e.g., Stargate, Hop Protocol). Each approach has different trust assumptions, finality guarantees, and capital efficiency tradeoffs.

Chainlink CCIP (Cross-Chain Interoperability Protocol) is an enterprise-grade messaging protocol that leverages Chainlink's decentralized oracle network for verification. CCIP supports both arbitrary message passing and token transfers. Messages are verified by three independent networks: the Committing DON (signs a Merkle root of messages), the Executing DON (proves message inclusion and executes on the destination), and the Risk Management Network (an independent set of nodes that monitors for anomalies and can halt the system). This defense-in-depth approach makes CCIP one of the more secure cross-chain protocols, though it trades off speed for security.

LayerZero takes a different architectural approach with its Ultra Light Node (ULN) design. Instead of running full nodes or relying on a single oracle network, LayerZero uses a modular verification system where applications can choose their own security configuration. The protocol separates message delivery (handled by Executors) from message verification (handled by Decentralized Verifier Networks or DVNs). Applications can require verification from multiple independent DVNs before accepting a message, allowing them to customize their security-cost tradeoff. LayerZero V2 introduced the OApp (Omnichain Application) standard that provides composable cross-chain primitives.

Message verification and finality are the core security challenges in cross-chain communication. When chain A sends a message to chain B, the bridge must ensure the message was actually included in a finalized block on chain A before executing it on chain B. Different chains have different finality models: Ethereum's proof-of-stake provides finality after two epochs (~12.8 minutes), optimistic rollups have a 7-day challenge period for full finality, and ZK rollups achieve finality once the proof is verified on L1. Bridge exploits (Ronin, Wormhole, Nomad) have resulted in billions of dollars in losses, making bridge security one of the most critical challenges in the blockchain space.

Code examples

Cross-Chain Token Transfer with Chainlink CCIP

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

import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol";
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

/
 * @title CCIPTokenSender
 * @notice Send ERC-20 tokens cross-chain using Chainlink CCIP
 */
contract CCIPTokenSender {
    using SafeERC20 for IERC20;

    IRouterClient public immutable router;
    IERC20 public immutable linkToken; // LINK for paying CCIP fees

    event TokensSent(
        bytes32 indexed messageId,
        uint64 indexed destinationChain,
        address receiver,
        address token,
        uint256 amount,
        uint256 fees
    );

    constructor(address _router, address _link) {
        router = IRouterClient(_router);
        linkToken = IERC20(_link);
    }

    /
     * @notice Send tokens to a receiver on another chain
     * @param destinationChainSelector CCIP chain selector (not chainId!)
     * @param receiver Address on the destination chain
     * @param token ERC-20 token to send
     * @param amount Amount of tokens
     */
    function sendTokens(
        uint64 destinationChainSelector,
        address receiver,
        address token,
        uint256 amount
    ) external returns (bytes32 messageId) {
        // Build the CCIP token amount struct
        Client.EVMTokenAmount[] memory tokenAmounts = new Client.EVMTokenAmount[](1);
        tokenAmounts[0] = Client.EVMTokenAmount({
            token: token,
            amount: amount
        });

        // Build the CCIP message
        Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
            receiver: abi.encode(receiver),
            data: "",  // No additional data
            tokenAmounts: tokenAmounts,
            extraArgs: Client._argsToBytes(
                Client.EVMExtraArgsV1({gasLimit: 200_000})
            ),
            feeToken: address(linkToken)  // Pay fees in LINK
        });

        // Get the fee required for this message
        uint256 fees = router.getFee(destinationChainSelector, message);

        // Transfer LINK from sender to this contract for fees
        linkToken.safeTransferFrom(msg.sender, address(this), fees);
        linkToken.approve(address(router), fees);

        // Transfer tokens from sender to this contract
        IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
        IERC20(token).approve(address(router), amount);

        // Send the CCIP message
        messageId = router.ccipSend(destinationChainSelector, message);

        emit TokensSent(
            messageId,
            destinationChainSelector,
            receiver,
            token,
            amount,
            fees
        );
    }

    /
     * @notice Estimate fees for a cross-chain token transfer
     */
    function estimateFee(
        uint64 destinationChainSelector,
        address receiver,
        address token,
        uint256 amount
    ) external view returns (uint256) {
        Client.EVMTokenAmount[] memory tokenAmounts = new Client.EVMTokenAmount[](1);
        tokenAmounts[0] = Client.EVMTokenAmount({token: token, amount: amount});

        Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
            receiver: abi.encode(receiver),
            data: "",
            tokenAmounts: tokenAmounts,
            extraArgs: Client._argsToBytes(
                Client.EVMExtraArgsV1({gasLimit: 200_000})
            ),
            feeToken: address(linkToken)
        });

        return router.getFee(destinationChainSelector, message);
    }
}

This contract sends ERC-20 tokens cross-chain via Chainlink CCIP. The sender builds an EVM2AnyMessage with the destination receiver, token amounts, and gas limit. CCIP fees can be paid in LINK or native ETH. The router handles all the complexity of message commitment, verification through the DON networks, and token locking/minting on the destination chain. Note that CCIP uses chain selectors (not chainIds) to identify destination networks.

CCIP Cross-Chain Message Receiver

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

import {CCIPReceiver} from "@chainlink/contracts-ccip/src/v0.8/ccip/applications/CCIPReceiver.sol";
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";

/
 * @title CrossChainVault
 * @notice Receives cross-chain messages and executes actions
 * @dev Demonstrates arbitrary message passing + token receiving
 */
contract CrossChainVault is CCIPReceiver {
    // Track deposits per user across chains
    mapping(address => uint256) public crossChainDeposits;

    // Allowed source chains and senders for security
    mapping(uint64 => mapping(address => bool)) public allowedSources;

    address public owner;

    event CrossChainDepositReceived(
        bytes32 indexed messageId,
        uint64 indexed sourceChain,
        address depositor,
        address token,
        uint256 amount
    );

    event CrossChainActionExecuted(
        bytes32 indexed messageId,
        uint64 indexed sourceChain,
        bytes data
    );

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    constructor(address _router) CCIPReceiver(_router) {
        owner = msg.sender;
    }

    /
     * @dev Called by CCIP router when a message arrives
     * @param message The decoded CCIP message
     */
    function _ccipReceive(
        Client.Any2EVMMessage memory message
    ) internal override {
        // Decode the source chain sender
        address sourceSender = abi.decode(message.sender, (address));
        uint64 sourceChain = message.sourceChainSelector;

        // Verify the message comes from an allowed source
        require(
            allowedSources[sourceChain][sourceSender],
            "Source not allowed"
        );

        // Handle any tokens received with the message
        if (message.destTokenAmounts.length > 0) {
            // Decode the depositor from the message data
            address depositor = abi.decode(message.data, (address));
            uint256 amount = message.destTokenAmounts[0].amount;

            crossChainDeposits[depositor] += amount;

            emit CrossChainDepositReceived(
                message.messageId,
                sourceChain,
                depositor,
                message.destTokenAmounts[0].token,
                amount
            );
        } else {
            // Pure message (no tokens) - execute arbitrary logic
            emit CrossChainActionExecuted(
                message.messageId,
                sourceChain,
                message.data
            );
        }
    }

    /
     * @notice Allow a source chain + sender combination
     */
    function setAllowedSource(
        uint64 chainSelector,
        address sender,
        bool allowed
    ) external onlyOwner {
        allowedSources[chainSelector][sender] = allowed;
    }

    /
     * @notice Retrieve failed messages for retry
     * @dev CCIP provides automatic retries, but manual recovery is possible
     */
    function getRouter() external view returns (address) {
        return i_ccipRouter;
    }
}

This receiver contract inherits from CCIPReceiver and implements _ccipReceive to handle incoming cross-chain messages. It validates that messages come from allowed sources (critical for security), processes token deposits by tracking per-user balances, and handles pure messages for arbitrary cross-chain logic. The allowedSources mapping prevents unauthorized contracts on other chains from triggering actions.

LayerZero V2 OApp (Omnichain Application)

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

import { OApp, MessagingFee, Origin } from "@layerzerolabs/oapp-evm/contracts/oapp/OApp.sol";
import { OptionsBuilder } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/OptionsBuilder.sol";

/
 * @title OmniCounter
 * @notice A simple cross-chain counter using LayerZero V2
 * @dev Demonstrates the OApp pattern for omnichain applications
 */
contract OmniCounter is OApp {
    using OptionsBuilder for bytes;

    uint256 public count;

    // Track counts per source chain
    mapping(uint32 => uint256) public countPerChain;

    event CountIncremented(uint32 indexed srcEid, uint256 newCount);
    event MessageSent(uint32 indexed dstEid, uint256 currentCount, bytes32 guid);

    constructor(
        address _endpoint,
        address _delegate
    ) OApp(_endpoint, _delegate) {}

    /
     * @notice Send an increment message to another chain
     * @param _dstEid Destination endpoint ID (LayerZero chain identifier)
     * @param _options Execution options (gas limit, value, etc.)
     */
    function increment(
        uint32 _dstEid,
        bytes calldata _options
    ) external payable {
        // Encode the message payload
        bytes memory payload = abi.encode(count, msg.sender);

        // Send the message via LayerZero
        // _lzSend returns (MessagingReceipt memory receipt)
        MessagingFee memory fee = _quote(_dstEid, payload, _options, false);
        require(msg.value >= fee.nativeFee, "Insufficient fee");

        _lzSend(
            _dstEid,        // Destination chain
            payload,        // Encoded message
            _options,       // Execution options
            MessagingFee(msg.value, 0),  // Fee payment
            payable(msg.sender)          // Refund address
        );

        emit MessageSent(_dstEid, count, bytes32(0));
    }

    /
     * @dev Called by LayerZero endpoint when a message is received
     * @param _origin Source chain information
     * @param _guid Unique message identifier
     * @param _message Encoded message payload
     */
    function _lzReceive(
        Origin calldata _origin,
        bytes32 _guid,
        bytes calldata _message,
        address /*_executor*/,
        bytes calldata /*_extraData*/
    ) internal override {
        // Decode the message
        (uint256 sourceCount, address sender) = abi.decode(_message, (uint256, address));

        // Update state
        count++;
        countPerChain[_origin.srcEid]++;

        emit CountIncremented(_origin.srcEid, count);
    }

    /
     * @notice Estimate the fee for sending a message
     * @param _dstEid Destination endpoint ID
     * @param _options Execution options
     */
    function quote(
        uint32 _dstEid,
        bytes calldata _options
    ) external view returns (MessagingFee memory) {
        bytes memory payload = abi.encode(count, msg.sender);
        return _quote(_dstEid, payload, _options, false);
    }

    /
     * @notice Build execution options for the destination chain
     * @dev Helper to construct proper options bytes
     */
    function buildOptions(
        uint128 _gasLimit,
        uint128 _nativeValue
    ) external pure returns (bytes memory) {
        return OptionsBuilder.newOptions()
            .addExecutorLzReceiveOption(_gasLimit, _nativeValue);
    }
}

// === Deployment & Configuration ===
// After deploying OmniCounter on chains A and B:
//
// 1. Set peers (each contract must know its counterpart):
//    counterA.setPeer(dstEidB, bytes32(uint256(uint160(counterBAddress))));
//    counterB.setPeer(dstEidA, bytes32(uint256(uint160(counterAAddress))));
//
// 2. Configure DVNs (Decentralized Verifier Networks):
//    endpoint.setConfig(oappAddress, sendLib, configParams);
//
// 3. Send a message:
//    const options = counterA.buildOptions(200000, 0);
//    const fee = await counterA.quote(dstEidB, options);
//    await counterA.increment(dstEidB, options, { value: fee.nativeFee });

This OmniCounter demonstrates the LayerZero V2 OApp pattern. The contract extends OApp and implements _lzReceive to handle incoming cross-chain messages. The increment function sends a message to a destination chain by calling _lzSend with the destination endpoint ID, encoded payload, execution options (gas limit for the destination), and fee payment. Peers must be configured bidirectionally so each contract knows its counterpart on other chains. LayerZero uses endpoint IDs (eid) rather than chain IDs to identify networks.

Key points

Concepts covered

Cross-Chain Communication, Bridges, Chainlink CCIP, LayerZero, Message Verification