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.
About this tutorial
Prerequisite technical knowledge
- Intermediate Solidity programming
- Basic TypeScript knowledge
- Understanding of smart contract development
- Familiarity with blockchain concepts
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
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 (optional)
- TypeScript: For offchain code (for relaying messages manually)
- Viem: For interactions with the chain from the offchain app
What You'll Build
- A
Greeter
contract that stores and updates a greeting - A
GreetingSender
contract that sends cross-chain messages to update the greeting - A TypeScript application to relay messages 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.
Setting up your development environment
Follow the Installation Guide to install:
- Foundry for smart contract development (required in all cases)
- Supersim for local blockchain simulation (optional)
Verify your installation:
forge --version
./supersim --version
Implementing onchain message passing (in Solidity)
The implementation consists of three main components:
- Greeter Contract: Deployed on
Chain B
, receives and stores messages. - GreetingSender Contract: Deployed on
Chain A
, initiates cross-chain messages.
Setting up test networks
-
If you are using Supersim, go to the directory where Supersim is installed and start it with autorelay.
./supersim --interop.autorelay
If you are using the devnets, just skip this step.
Supersim creates three
anvil
blockchains:Role ChainID RPC URL L1 900 http://127.0.0.1:8545 (opens in a new tab) OPChainA 901 http://127.0.0.1:9545 (opens in a new tab) OPChainB 902 http://127.0.0.1:9546 (opens in a new tab) -
In a separate shell, store the configuration in environment variables.
Set these parameters for Supersim.
PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 USER_ADDRESS=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 URL_CHAIN_A=http://127.0.0.1:9545 URL_CHAIN_B=http://127.0.0.1:9546 INTEROP_BRIDGE=0x4200000000000000000000000000000000000028
Sanity check
To verify that the chains are running, check the balance of $USER_ADDRESS
.
cast balance --ether $USER_ADDRESS --rpc-url $URL_CHAIN_A
cast balance --ether $USER_ADDRESS --rpc-url $URL_CHAIN_B
Create the contracts
-
Create a new Foundry project.
mkdir onchain-code cd onchain-code forge init
-
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); } }
-
Deploy the
Greeter
contract to Chain B and store the resulting contract address in theGREETER_B_ADDRESS
environment variable.GREETER_B_ADDRESS=`forge create --rpc-url $URL_CHAIN_B --private-key $PRIVATE_KEY Greeter --broadcast | awk '/Deployed to:/ {print $3}'`
Explanation
The command that deploys the contract is:
forge create --rpc-url $URL_CHAIN_B --private-key $PRIVATE_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 hasDeployed 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_ADDRESS=<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 $URL_CHAIN_B $GREETER_B_ADDRESS "greet()" | cast --to-ascii
cast send --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_B $GREETER_B_ADDRESS "setGreeting(string)" Hello$$
cast call --rpc-url $URL_CHAIN_B $GREETER_B_ADDRESS "greet()" | cast --to-ascii
-
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
-
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
-
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).
-
Deploy
GreetingSender
to chain A.CHAIN_ID_B=`cast chain-id --rpc-url $URL_CHAIN_B` GREETER_A_ADDRESS=`forge create --rpc-url $URL_CHAIN_A --private-key $PRIVATE_KEY --broadcast GreetingSender --constructor-args $GREETER_B_ADDRESS $CHAIN_ID_B | awk '/Deployed to:/ {print $3}'`
Send a message
Send a greeting from chain A to chain B.
cast call --rpc-url $URL_CHAIN_B $GREETER_B_ADDRESS "greet()" | cast --to-ascii
cast send --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_A $GREETER_A_ADDRESS "setGreeting(string)" "Hello from chain A"
sleep 4
cast call --rpc-url $URL_CHAIN_B $GREETER_B_ADDRESS "greet()" | cast --to-ascii
The sleep
call is because it can take up to two seconds until the transaction is included in chain A, and then up to two seconds until the relay transaction is included in chain B.
Sender information
Run this command to view the events to see who called setGreeting
.
cast logs --rpc-url $URL_CHAIN_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
-
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 callL2ToL2CrossDomainMessenger.crossDomainMessageContext
(opens in a new tab). -
Redeploy the contracts. Because the address of
Greeter
is immutable inGreetingSender
, we need to redeploy both contracts.GREETER_B_ADDRESS=`forge create --rpc-url $URL_CHAIN_B --private-key $PRIVATE_KEY Greeter --broadcast | awk '/Deployed to:/ {print $3}'` GREETER_A_ADDRESS=`forge create --rpc-url $URL_CHAIN_A --private-key $PRIVATE_KEY --broadcast GreetingSender --constructor-args $GREETER_B_ADDRESS $CHAIN_ID_B | awk '/Deployed to:/ {print $3}'`
Verify you can see cross chain sender information
-
Set the greeting through
GreetingSender
.cast call --rpc-url $URL_CHAIN_B $GREETER_B_ADDRESS "greet()" | cast --to-ascii cast send --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_A $GREETER_A_ADDRESS "setGreeting(string)" "Hello from chain A, with a CrossDomainSetGreeting event" sleep 4 cast call --rpc-url $URL_CHAIN_B $GREETER_B_ADDRESS "greet()" | cast --to-ascii
-
Read the log entries.
cast logs --rpc-url $URL_CHAIN_B 'CrossDomainSetGreeting(address,uint256,string)' echo $GREETER_A_ADDRESS echo 0x385 | cast --to-dec echo 0x190a85c0 | cast --to-dec
See that the second topic (the first indexed log parameter) is the same as
$GREETER_A_ADDRESS
. The third topic can be either0x385=901
, which is the chain ID for supersim chain A, or0x190a85c0=420120000
, which is the chain ID for devnet alpha 0.
Implement manual message relaying
If you are using the devnets, skip this section. You will not be able to see the effects anyway because you cannot disable autoforwarding on them.
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.
-
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
-
Edit
package.json
to add thestart
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" } }
-
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_ADDRESS GREETER_B_ADDRESS PRIVATE_KEY
Sanity check
-
Create a simple
src/app.mts
file.console.log(`Greeter A ${process.env.GREETER_A_ADDRESS}`) console.log(`Greeter B ${process.env.GREETER_B_ADDRESS}`)
-
Run the program.
npm run start
Send a greeting
-
Link the compiled versions of the onchain code, which include the ABI, to the offchain code repository.
cd src ln -s ../../onchain-code/out/Greeter.sol/Greeter.json . ln -s ../../onchain-code/out/GreetingSender.sol/GreetingSender.json . cd ..
-
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.PRIVATE_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_ADDRESS as Address, abi: greeterData.abi, client: walletB }) const greetingSender = getContract({ address: process.env.GREETER_A_ADDRESS 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}`)
-
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.
-
In the window that runs Supersim, stop it and restart with this command:
./supersim
-
In the window you used for your earlier tests, redeploy the contracts. Export the addresses so we'll have them in the offchain.
cd ../onchain-code export GREETER_B_ADDRESS=`forge create --rpc-url $URL_CHAIN_B --private-key $PRIVATE_KEY Greeter --broadcast | awk '/Deployed to:/ {print $3}'` export GREETER_A_ADDRESS=`forge create --rpc-url $URL_CHAIN_A --private-key $PRIVATE_KEY --broadcast GreetingSender --constructor-args $GREETER_B_ADDRESS $CHAIN_ID_B | awk '/Deployed to:/ {print $3}'` cd ../offchain-code
-
Rerun the JavaScript program.
npm start
See that the transaction to chain B changes the greeting, but the transaction to chain A does not.
> offchain-code@1.0.0 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
-
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 } from '@eth-optimism/viem' import greeterData from './Greeter.json' import greetingSenderData from './GreetingSender.json' const account = privateKeyToAccount(process.env.PRIVATE_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_ADDRESS as Address, abi: greeterData.abi, client: walletB }) const greetingSender = getContract({ address: process.env.GREETER_A_ADDRESS 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 sentMessages = await walletA.interop.getCrossDomainMessages({ logs: receiptA.logs, }) const sentMessage = sentMessages[0] // We only sent 1 message const relayMessageParams = await walletA.interop.buildExecutingMessage({ log: sentMessage.log, }) const relayMsgTxnHash = await walletB.interop.relayCrossDomainMessage(relayMessageParams) const receiptRelay = await walletB.waitForTransactionReceipt({ hash: relayMsgTxnHash, }) const greeting2 = await greeter.read.greet() console.log(`Chain A Greeting: ${greeting2}`)
Explanation
import { supersimL2A, supersimL2B } from '@eth-optimism/viem/chains'
import { walletActionsL2, publicActionsL2 } 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())
const walletB = createWalletClient({
chain: supersimL2B,
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. On wallet A we need the public actions, those that only read information. On wallet B we need the wallet actions, 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 sentMessages = await walletA.interop.getCrossDomainMessages({
logs: receiptA.logs,
})
const sentMessage = sentMessages[0] // We only sent 1 message
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 relayMessageParams = await walletA.interop.buildExecutingMessage({
log: sentMessage.log,
})
const relayMsgTxnHash = await walletB.interop.relayCrossDomainMessage(relayMessageParams)
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.
-
Rerun the JavaScript program, and see that the message is relayed.
npm start
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
- Review the Superchain Interop Explainer for answers to common questions about interoperability.
- Read the Message Passing Explainer to understand what happens "under the hood".
- Write a revolutionary app that uses multiple blockchains within the Superchain.