OP Stack
Interop message passing
Interop is currently in active development and not yet ready for production use. The information provided here may change frequently. We recommend checking back regularly for the most up-to-date information.

Interop message passing tutorial

Overview

This tutorial demonstrates how to implement cross-chain communication within the Superchain ecosystem. You'll build a complete message passing system that enables different chains to interact with each other using the L2ToL2CrossDomainMessenger contract.

What You'll Build

  • A Greeter contract that stores and updates messages
  • A GreetingSender contract that sends cross-chain messages
  • A TypeScript application to relay messages between chains

What you'll learn

  • How to deploy contracts across different chains
  • How to implement cross-chain message passing
  • How to handle sender verification across chains
  • How to relay messages manually between chains
💡

This tutorial provides step-by-step instructions for implementing cross-chain messaging. For a conceptual overview, see the message passing explainer.

In this tutorial, you will learn how to use the L2ToL2CrossDomainMessenger (opens in a new tab) contract to pass messages between interoperable blockchains.

Prerequisites

Before starting this tutorial, ensure your development environment meets the following requirements:

Technical knowledge

  • Intermediate Solidity programming
  • Basic TypeScript knowledge
  • Understanding of smart contract development
  • Familiarity with blockchain concepts

Development environment

  • Unix-like operating system (Linux, macOS, or WSL for Windows)
  • Node.js version 16 or higher
  • Git for version control

Required tools

The tutorial uses these primary tools:

  • Foundry: For smart contract development
  • Supersim: For local blockchain simulation
  • TypeScript: For implementation
  • Viem: For blockchain interaction

Setting up your development environment

Follow the Installation Guide to install:

  • Foundry for smart contract development
  • Supersim for local blockchain simulation

Verify your installation:

forge --version
supersim --version

Implementing onchain message passing (in Solidity)

The implementation consists of three main components:

  1. Greeter Contract: Deployed on Chain B, receives and stores messages.
  2. GreetingSender Contract: Deployed on Chain A, initiates cross-chain messages.
  3. Message relay system: Ensures message delivery between chains.

For development purposes, we'll first use autorelay mode to handle message execution automatically. Later sections cover manual message relaying for production environments.

Setting up test networks

⚠️

If you attempt to run these steps with the devnet, you must Send the executing message yourself, as explained here.

  1. In the directory where Supersim is installed, start it with autorelay.

    ./supersim --interop.autorelay

    Supersim creates three anvil blockchains:

    RoleChainIDRPC URL
    L1900http://127.0.0.1:8545 (opens in a new tab)
    OPChainA901http://127.0.0.1:9545 (opens in a new tab)
    OPChainB902http://127.0.0.1:9546 (opens in a new tab)
  2. In a separate shell, store the configuration in environment variables.

    USER_ADDR=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
    PRIV_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
    RPC_L1=http://localhost:8545
    RPC_A=http://localhost:9545
    RPC_B=http://localhost:9546
Sanity check

To verify that the chains are running, check the balance of $USER_ADDR.

cast balance --ether $USER_ADDR --rpc-url $RPC_L1
cast balance --ether $USER_ADDR --rpc-url $RPC_A
cast balance --ether $USER_ADDR --rpc-url $RPC_B        

Create the contracts

  1. Create a new Foundry project.

    mkdir onchain-code
    cd onchain-code
    forge init 
  2. In src/Greeter.sol put this file. This is a variation on Hardhat's Greeter contract (opens in a new tab).

    //SPDX-License-Identifier: MIT
    pragma solidity ^0.8.0;
     
    contract Greeter {
        string greeting;
     
        event SetGreeting(
            address indexed sender,     // msg.sender
            string greeting
        ); 
     
        function greet() public view returns (string memory) {
            return greeting;
        }
     
        function setGreeting(string memory _greeting) public {
            greeting = _greeting;
            emit SetGreeting(msg.sender, _greeting);
        }
    }
  3. Deploy the Greeter contract to Chain B and store the resulting contract address in the GREETER_B_ADDR environment variable.

    GREETER_B_ADDR=`forge create --rpc-url $RPC_B --private-key $PRIV_KEY Greeter --broadcast | awk '/Deployed to:/ {print $3}'`
Explanation

The command that deploys the contract is:

forge create --rpc-url $RPC_B --private-key $PRIV_KEY Greeter --broadcast

The command output gives us the deployer address, the address of the new contract, and the transaction hash:

Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Deployed to: 0x5FC8d32690cc91D4c39d9d3abcBD16989F875707
Transaction hash: 0xf155d360ec70ee10fe0e02d99c16fa5d6dc2a0e79b005fec6cbf7925ff547dbf

The awk (opens in a new tab) command looks for the line that has Deployed to: and writes the third word in that line, which is the address.

awk '/Deployed to:/ {print $3}'

Finally, in UNIX (including Linux and macOS) the when the command line includes backticks (```), the shell executes the code between the backticks and puts the output, in this case the contract address, in the command. So we get.

GREETER_B_ADDR=<the address>
Sanity check

Run these commands to verify the contract works. The first and third commands retrieve the current greeting, while the second command updates it.

cast call --rpc-url $RPC_B $GREETER_B_ADDR "greet()" | cast --to-ascii 
cast send --private-key $PRIV_KEY --rpc-url $RPC_B $GREETER_B_ADDR "setGreeting(string)" Hello
cast call --rpc-url $RPC_B $GREETER_B_ADDR "greet()" | cast --to-ascii
  1. Install the Optimism Solidity libraries into the project.

    cd lib
    npm install @eth-optimism/contracts-bedrock
    cd ..
    echo @eth-optimism/=lib/node_modules/@eth-optimism/ >> remappings.txt
  2. The @eth-optimism/contracts-bedrock (opens in a new tab) library does not have the Interop Solidity code yet. Run these commands to add it.

    mkdir -p lib/node_modules/@eth-optimism/contracts-bedrock/interfaces/L2
    wget https://raw.githubusercontent.com/ethereum-optimism/optimism/refs/heads/develop/packages/contracts-bedrock/interfaces/L2/IL2ToL2CrossDomainMessenger.sol
    mv IL2ToL2CrossDomainMessenger.sol lib/node_modules/@eth-optimism/contracts-bedrock/interfaces/L2
  3. Create src/GreetingSender.sol.

    //SPDX-License-Identifier: MIT
    pragma solidity ^0.8.0;
     
    import { Predeploys } from "@eth-optimism/contracts-bedrock/src/libraries/Predeploys.sol";
    import { IL2ToL2CrossDomainMessenger } from "@eth-optimism/contracts-bedrock/interfaces/L2/IL2ToL2CrossDomainMessenger.sol";
     
    import { Greeter } from "src/Greeter.sol";
     
    contract GreetingSender {
        IL2ToL2CrossDomainMessenger public immutable messenger =
            IL2ToL2CrossDomainMessenger(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER);
     
        address immutable greeterAddress;
        uint256 immutable greeterChainId;
     
        constructor(address _greeterAddress, uint256 _greeterChainId) {
            greeterAddress = _greeterAddress;
            greeterChainId = _greeterChainId;
        }
     
        function setGreeting(string calldata greeting) public {
            bytes memory message = abi.encodeCall(
                Greeter.setGreeting,
                (greeting)
            );
            messenger.sendMessage(greeterChainId, greeterAddress, message);
        }
    }
Explanation
    function setGreeting(string calldata greeting) public {
        bytes memory message = abi.encodeCall(
            Greeter.setGreeting,
            (greeting)
        );
        messenger.sendMessage(greeterChainId, greeterAddress, message);
    }

This function encodes a call to setGreeting and sends it to a contract on another chain. abi.encodeCall(Greeter.setGreeting, (greeting)) constructs the calldata (opens in a new tab) by encoding the function selector and parameters. The encoded message is then passed to messenger.sendMessage, which forwards it to the destination contract (greeterAddress) on the specified chain (greeterChainId).

This ensures that setGreeting is executed remotely with the provided greeting value (as long as there is an executing message to relay it).

  1. Deploy GreetingSender to chain A.

    GREETER_A_ADDR=`forge create --rpc-url $RPC_A --private-key $PRIV_KEY --broadcast GreetingSender --constructor-args $GREETER_B_ADDR 902 | awk '/Deployed to:/ {print $3}'`

Send a message

Send a greeting from chain A to chain B.

cast call --rpc-url $RPC_B $GREETER_B_ADDR "greet()" | cast --to-ascii 
cast send --private-key $PRIV_KEY --rpc-url $RPC_A $GREETER_A_ADDR "setGreeting(string)" "Hello from chain A"
sleep 2
cast call --rpc-url $RPC_B $GREETER_B_ADDR "greet()" | cast --to-ascii

The sleep call is because the message is not relayed until the next chain B block, which can take up to two seconds.

Sender information

Run this command to view the events to see who called setGreeting.

cast logs --rpc-url $RPC_B 'SetGreeting(address,string)'

The sender information is stored in the second event topic. However, for cross-chain messages, this value corresponds to the local L2ToL2CrossDomainMessenger contract address (4200000000000000000000000000000000000023), making it ineffective for identifying the original sender.

In this section we change Greeter.sol to emit a separate event in it receives a cross domain message, with the sender's identity (address and chain ID).

Modify the Greeter contract

  1. Modify src/Greeter.sol to this code.

    //SPDX-License-Identifier: MIT
    pragma solidity ^0.8.0;
     
    import { Predeploys } from "@eth-optimism/contracts-bedrock/src/libraries/Predeploys.sol";
    import { IL2ToL2CrossDomainMessenger } from "@eth-optimism/contracts-bedrock/interfaces/L2/IL2ToL2CrossDomainMessenger.sol";    
     
    contract Greeter {
     
        IL2ToL2CrossDomainMessenger public immutable messenger =
            IL2ToL2CrossDomainMessenger(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER);
     
        string greeting;
     
        event SetGreeting(
            address indexed sender,     // msg.sender
            string greeting
        ); 
     
        event CrossDomainSetGreeting(
            address indexed sender,   // Sender on the other side
            uint256 indexed chainId,  // ChainID of the other side
            string greeting
        );
     
        function greet() public view returns (string memory) {
            return greeting;
        }
     
        function setGreeting(string memory _greeting) public {
            greeting = _greeting;
            emit SetGreeting(msg.sender, _greeting);
     
            if (msg.sender == Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER) {
                (address sender, uint256 chainId) =
                    messenger.crossDomainMessageContext();              
                emit CrossDomainSetGreeting(sender, chainId, _greeting);
            }
        }
    }
Explanation
        if (msg.sender == Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER) {
            (address sender, uint256 chainId) =
                messenger.crossDomainMessageContext();              
            emit CrossDomainSetGreeting(sender, chainId, _greeting);
        }

If we see that we got a message from L2ToL2CrossDomainMessenger, we call L2ToL2CrossDomainMessenger.crossDomainMessageContext (opens in a new tab).

  1. Redeploy the contracts. Because the address of Greeter is immutable in GreetingSender, we need to redeploy both contracts.

    GREETER_B_ADDR=`forge create --rpc-url $RPC_B --private-key $PRIV_KEY Greeter --broadcast | awk '/Deployed to:/ {print $3}'`
    GREETER_A_ADDR=`forge create --rpc-url $RPC_A --private-key $PRIV_KEY --broadcast GreetingSender --constructor-args $GREETER_B_ADDR 902 | awk '/Deployed to:/ {print $3}'`

Verify you can see cross chain sender information

  1. Set the greeting through GreetingSender.

    cast call --rpc-url $RPC_B $GREETER_B_ADDR "greet()" | cast --to-ascii 
    cast send --private-key $PRIV_KEY --rpc-url $RPC_A $GREETER_A_ADDR "setGreeting(string)" "Hello from chain A, with a CrossDomainSetGreeting event"
    sleep 2
    cast call --rpc-url $RPC_B $GREETER_B_ADDR "greet()" | cast --to-ascii
  2. Read the log entries.

    cast logs --rpc-url $RPC_B 'CrossDomainSetGreeting(address,uint256,string)'
    echo $GREETER_A_ADDR
    echo 0x385 | cast --to-dec

    See that the second topic (the first indexed log parameter) is the same as $GREETER_A_ADDR. The third topic is 0x385=901, which is the chain ID for chain A.

Implement manual message relaying

So far we relied on --interop.autorelay to send the executing messages to chain B. But we only have it because we're using a development system. In production we will not have this, we need to create our own executing messages.

Set up

We are going to use a Node (opens in a new tab) project, to be able to get executing messages from the command line. We use TypeScript (opens in a new tab) to have type safety combined with JavaScript functionality.

  1. Initialize a new Node project.

    mkdir ../offchain-code
    cd ../offchain-code
    npm init -y
    npm install --save-dev -y viem tsx @types/node @eth-optimism/viem
    mkdir src
  2. Edit package.json to add the start script.

    {
        "name": "offchain-code",
        "version": "1.0.0",
        "main": "index.js",
        "scripts": {
            "test": "echo \"Error: no test specified\" && exit 1",
            "start": "tsx src/app.mts"
        },
        "keywords": [],
        "author": "",
        "license": "ISC",
        "type": "commonjs",
        "description": "",
        "devDependencies": {
            "@eth-optimism/viem": "^0.3.2",
            "@types/node": "^22.13.1",
            "tsx": "^4.19.2",
            "viem": "^2.22.23"
        }
    }
  3. Export environment variables. This is necessary because those variables are currently limited to the shell process. We need them in the Node process that the shell creates.

    export GREETER_A_ADDR GREETER_B_ADDR PRIV_KEY
Sanity check
  1. Create a simple src/app.mts file.

    console.log(`Greeter A ${process.env.GREETER_A_ADDR}`)
    console.log(`Greeter B ${process.env.GREETER_B_ADDR}`)
  2. Run the program.

    npm run start

Send a greeting

  1. Link the compiled versions of the onchain code, which include the ABI, to the source code.

    cd src
    ln -s ../../onchain-code/out/Greeter.sol/Greeter.json .
    ln -s ../../onchain-code/out/GreetingSender.sol/GreetingSender.json .
    cd ..
  2. Create or replace src/app.mts with this code.

    import {
      createWalletClient,
      http,
      defineChain,
      publicActions,
      getContract,
      Address,
    } from 'viem'
    import { privateKeyToAccount } from 'viem/accounts'
    import { supersimL2A, supersimL2B } from '@eth-optimism/viem/chains'
     
     
    import greeterData from './Greeter.json'
    import greetingSenderData from './GreetingSender.json'
     
    const account = privateKeyToAccount(process.env.PRIV_KEY as `0x${string}`)
     
    const walletA = createWalletClient({
      chain: supersimL2A,
      transport: http(),
      account
    }).extend(publicActions)
     
    const walletB = createWalletClient({
      chain: supersimL2B,
      transport: http(),
      account
    }).extend(publicActions)
     
    const greeter = getContract({
      address: process.env.GREETER_B_ADDR as Address,
      abi: greeterData.abi,
      client: walletB
    })
     
    const greetingSender = getContract({
      address: process.env.GREETER_A_ADDR as Address,
      abi: greetingSenderData.abi,
      client: walletA
    })
    const txnBHash = await greeter.write.setGreeting(["Greeting directly to chain B"])
    await walletB.waitForTransactionReceipt({hash: txnBHash})
     
    const greeting1 = await greeter.read.greet()
    console.log(`Chain B Greeting: ${greeting1}`)
     
    const txnAHash = await greetingSender.write.setGreeting(["Greeting through chain A"])
    await walletA.waitForTransactionReceipt({hash: txnAHash})
     
    const greeting2 = await greeter.read.greet()
    console.log(`Chain A Greeting: ${greeting2}`)
  3. Run the program, see that a greeting from chain A is relayed to chain B.

    npm start

Rerun supersim

Now we need to rerun Supersim without autorelay.

  1. In the window that runs Supersim, stop it and restart with this command:

    ./supersim
  2. In the window you used for your earlier tests, redeploy the contracts. Export the addresses so we'll have them in JavaScript

    cd ../onchain-code
    export GREETER_B_ADDR=`forge create --rpc-url $RPC_B --private-key $PRIV_KEY Greeter --broadcast | awk '/Deployed to:/ {print $3}'`
    export GREETER_A_ADDR=`forge create --rpc-url $RPC_A --private-key $PRIV_KEY --broadcast GreetingSender --constructor-args $GREETER_B_ADDR 902 | awk '/Deployed to:/ {print $3}'`
    cd ../offchain-code
  3. Rerun the JavaScript program.

    npm start

    See that the transaction to chain B changes the greeting, but the transaction to chain A does not.

    > [email protected] start
    > tsx src/app.mts
    
    Chain B Greeting: Greeting directly to chain B
    Chain A Greeting: Greeting directly to chain B

Add manual relaying logic

  1. Replace src/app.mts with:

    import {
        createWalletClient,
        http,
        publicActions,
        getContract,
        Address,
    } from 'viem'
    import { privateKeyToAccount } from 'viem/accounts'
    import { supersimL2A, supersimL2B } from '@eth-optimism/viem/chains'
     
    import {
        walletActionsL2,
        publicActionsL2,
        createInteropSentL2ToL2Messages,
    } from '@eth-optimism/viem'
     
    import greeterData from './Greeter.json'
    import greetingSenderData from './GreetingSender.json'
     
    const account = privateKeyToAccount(process.env.PRIV_KEY as `0x${string}`)
     
    const walletA = createWalletClient({
        chain: supersimL2A,
        transport: http(),
        account
    }).extend(publicActions)
        .extend(publicActionsL2())
        .extend(walletActionsL2())
     
    const walletB = createWalletClient({
        chain: supersimL2B,
        transport: http(),
        account
    }).extend(publicActions)
        .extend(publicActionsL2())
        .extend(walletActionsL2())
     
    const greeter = getContract({
        address: process.env.GREETER_B_ADDR as Address,
        abi: greeterData.abi,
        client: walletB
    })
     
    const greetingSender = getContract({
        address: process.env.GREETER_A_ADDR as Address,
        abi: greetingSenderData.abi,
        client: walletA
    })
     
    const txnBHash = await greeter.write.setGreeting(
        ["Greeting directly to chain B"])
    await walletB.waitForTransactionReceipt({hash: txnBHash})
     
    const greeting1 = await greeter.read.greet()
    console.log(`Chain B Greeting: ${greeting1}`)
     
    const txnAHash = await greetingSender.write.setGreeting(
        ["Greeting through chain A"])
    const receiptA = await walletA.waitForTransactionReceipt({hash: txnAHash})
     
    const sentMessage =  
        (await createInteropSentL2ToL2Messages(walletA, { receipt: receiptA }))
            .sentMessages[0]
    const relayMsgTxnHash = await walletB.interop.relayMessage({
        sentMessageId: sentMessage.id,
        sentMessagePayload: sentMessage.payload,
        })
     
    const receiptRelay = await walletB.waitForTransactionReceipt(
            {hash: relayMsgTxnHash})
     
    const greeting2 = await greeter.read.greet()
    console.log(`Chain A Greeting: ${greeting2}`)
     
Explanation
import {
    walletActionsL2,
    publicActionsL2,
    createInteropSentL2ToL2Messages,
} from '@eth-optimism/viem'

Import from the @eth-optimism/viem (opens in a new tab) package.

const walletA = createWalletClient({
    chain: supersimL2A,
    transport: http(),
    account
}).extend(publicActions)
    .extend(publicActionsL2())
    .extend(walletActionsL2())

In addition to extending the wallets with Viem public actions (opens in a new tab), extend with the OP-Stack actions, both the public ones and the ones that require an account.

const receiptA = await walletA.waitForTransactionReceipt({hash: txnAHash})

To relay a message we need the information in the receipt. Also, we need to wait until the transaction with the relayed message is actually part of a block.

const sentMessage =  
    (await createInteropSentL2ToL2Messages(walletA, { receipt: receiptA }))
        .sentMessages[0]

A single transaction can send multiple messages. But here we know we sent just one, so we look for the first one in the list.

const relayMsgTxnHash = await walletB.interop.relayMessage({
    sentMessageId: sentMessage.id,
    sentMessagePayload: sentMessage.payload,
    })
 
const receiptRelay = await walletB.waitForTransactionReceipt(
        {hash: relayMsgTxnHash})

Here we first send the relay message on chain B, and then wait for the receipt for it.

  1. Rerun the JavaScript program, and see that the message is relayed.

    npm start

Using devnet

The same contracts are deployed on the devnet. You can relay messages in exactly the same way you'd do it on Supersim.

ContractNetworkAddress
GreeterDevnet 10x1A183FCf61053B7dcd2322BbE766f7E1946d3718 (opens in a new tab)
GreetingSenderDevnet 00x9De9f84a4EB3616B44CF1d68cD1A9098Df6cB25f (opens in a new tab)

To modify the program to relay messages on devnet, follow these steps:

  1. In src/app.mts, replace these lines to update the chains and contract addresses.

    Line numberNew content
    9import { interopAlpha0, interopAlpha1 } from '@eth-optimism/viem/chains'
    23 chain: interopAlpha0,
    31 chain: interopAlpha1,
    39 address: "0x1A183FCf61053B7dcd2322BbE766f7E1946d3718",
    45 address: "0x9De9f84a4EB3616B44CF1d68cD1A9098Df6cB25f",
  2. Set PRIV_KEY to the private key of an address that has Sepolia ETH (opens in a new tab).

    export PRIV_KEY=0x<private key here>
  3. Send ETH to the two L2 blockchains.

    cast send --rpc-url https://endpoints.omniatech.io/v1/eth/sepolia/public --private-key $PRIV_KEY --value 0.001ether 0x7385d89d38ab79984e7c84fab9ce5e6f4815468a
    cast send --rpc-url https://endpoints.omniatech.io/v1/eth/sepolia/public --private-key $PRIV_KEY --value 0.001ether 0x55f5c4653dbcde7d1254f9c690a5d761b315500c

    Wait a few minutes until you can see the ETH on your explorer (opens in a new tab).

  4. Rerun the test.

    npm start
  5. You can see the transactions in a block explorer.

Debugging

To see what messages were relayed by a specific transaction you can use this code:

import { decodeRelayedL2ToL2Messages } from '@eth-optimism/viem'
 
const decodedRelays = decodeRelayedL2ToL2Messages(
    {receipt: receiptRelay})
 
console.log(decodedRelays)
console.log(decodedRelays.successfulMessages[0].log)

Next steps