Introduction

Supersim is a lightweight tool to simulate the Superchain (with a single L1 and multiple OP-Stack L2s).

Run multiple local nodes with one command, and coordinate message passing between these chains.

It does not require a complicated devnet setup and is run using cli commands with configuration options that fall back to sensible defaults if they are not specified. Each chain is an instance of anvil, though future versions may support other local testing tools.

Features

  • spin up multiple anvil nodes
  • predeployed OP Stack contracts and useful mock contracts (ERC20)
  • fork multiple remote chains (fork the entire Superchain)
  • simulate L1 <> L2 message passing (deposits)
  • simulate L2 <> L2 message passing (interoperability) and auto-relayer
  • (Coming soon) Withdrawals
  • (Coming soon) ERC-4337 account abstraction services (bundlers / paymasters / wallet implementation)

Installation

1. Prerequisites: foundry

supersim requires anvil to be installed.

Follow the guide here to install the Foundry toolchain.

2. Install supersim

Precompiled Binaries

Download the executable for your platform from the GitHub releases page.

Homebrew (OS X, Linux)

brew tap ethereum-optimism/tap
brew install supersim

3. Start supersim in vanilla mode

supersim

Vanilla mode will start 3 chains, with the OP Stack contracts already deployed.

  • (1) L1 Chain
    • Chain 900
  • (2) L2 Chains
    • Chain 901
    • Chain 902

First steps

supersim allows testing multichain features locally. Previously, testing multichain features required complex docker setups or using a testnet.

To see it in practice, let's first try sending some ETH from the L1 to the L2.

Deposit ETH from the L1 into the L2 (L1 to L2 message passing)

1. Check initial balance on the L2 (chain 901)

Grab the balance of the sender account on L2:

cast balance 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --rpc-url http://127.0.0.1:9545

2. Call bridgeETH function on the L1StandardBridgeProxy contract on the L1 (chain 900)

Initiate a bridge transaction on the L1:

cast send 0xa01ae68902e205B420FD164435F299E07b0C778b "bridgeETH(uint32 _minGasLimit, bytes calldata _extraData)" 50000 0x --value 0.1ether --rpc-url http://127.0.0.1:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

3. Check the balance on the L2 (chain 901)

Verify that the ETH balance of the sender has increased on the L2:

cast balance 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --rpc-url http://127.0.0.1:9545

Send an interoperable SuperchainERC20 token from chain 901 to 902 (L2 to L2 message passing)

In a typical L2 to L2 cross-chain transfer, two transactions are required:

  1. Send transaction on the source chain – This initiates the token transfer on Chain 901.
  2. Relay message transaction on the destination chain – This relays the transfer details to Chain 902.

To simplify this process, you can use the --interop.autorelay flag. This flag automatically triggers the relay message transaction once the initial send transaction is completed on the source chain, improving the developer experience by removing the need to manually send the relay message.

1. Start supersim with the autorelayer enabled

supersim --interop.autorelay 

2. Mint tokens to transfer on chain 901

Run the following command to mint 1000 L2NativeSuperchainERC20 tokens to the recipient address:

cast send 0x420beeF000000000000000000000000000000001 "mint(address _to, uint256 _amount)"  0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 1000  --rpc-url http://127.0.0.1:9545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

3. Initiate the send transaction on chain 901

Send the tokens from Chain 901 to Chain 902 using the following command:

cast send 0x420beeF000000000000000000000000000000001 "sendERC20(address _to, uint256 _amount, uint256 _chainId)" 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 1000 902 --rpc-url http://127.0.0.1:9545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

4. Wait for the relayed message to appear on chain 902

In a few seconds, you should see the RelayedMessage on chain 902:

# example
INFO [08-30|14:30:14.698] L2ToL2CrossChainMessenger#RelayedMessage sourceChainID=901 destinationChainID=902 nonce=0 sender=0x420beeF000000000000000000000000000000001 target=0x420beeF000000000000000000000000000000001

5. Check the balance on chain 902

Verify that the balance of the L2NativeSuperchainERC20 on chain 902 has increased:

cast balance --erc20 0x420beeF000000000000000000000000000000001 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --rpc-url http://127.0.0.1:9546

With the steps above, you've now successfully completed both an L1 to L2 ETH bridge and an L2 to L2 interoperable SuperchainERC20 token transfer, all done locally using supersim. This approach simplifies multichain testing, allowing you to focus on development without the need for complex setups or relying on external testnets.

supersim (vanilla mode)

Overview

Start supersim in vanilla (non-forked) mode

supersim

Vanilla mode will start 3 chains, with the OP Stack contracts & periphery contracts already deployed.

  • (1) L1 Chain
    • Chain 900
  • (2) L2 Chains
    • Chain 901
    • Chain 902

Example startup logs

Available Accounts
-----------------------
(0): 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
(1): 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
(2): 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC
(3): 0x90F79bf6EB2c4f870365E785982E1f101E93b906
(4): 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65
(5): 0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc
(6): 0x976EA74026E726554dB657fA54763abd0C3a0aa9
(7): 0x14dC79964da2C08b23698B3D3cc7Ca32193d9955
(8): 0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f
(9): 0xa0Ee7A142d267C1f36714E4a8F75612F20a79720

Private Keys
-----------------------
(0): 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
(1): 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
(2): 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
(3): 0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6
(4): 0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a
(5): 0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba
(6): 0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e
(7): 0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356
(8): 0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97
(9): 0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6

Orchestrator Config:
L1:
  Name: L1    Chain ID: 900    RPC: http://127.0.0.1:8545    LogPath: /var/folders/0w/ethers-phoenix/T/anvil-chain-900
L2:
  Name: OPChainA    Chain ID: 901    RPC: http://127.0.0.1:9545    LogPath: /var/folders/0w/ethers-phoenix/T/anvil-chain-901
  Name: OPChainB    Chain ID: 902    RPC: http://127.0.0.1:9546    LogPath: /var/folders/0w/ethers-phoenix/T/anvil-chain-902

Configuration

NAME:
   supersim - Superchain Multi-L2 Simulator

USAGE:
   supersim [global options] command [command options]

VERSION:
   untagged

DESCRIPTION:
   Local multichain optimism development environment

COMMANDS:
   fork     Locally fork a network in the superchain registry
   help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:

    --interop.autorelay                 (default: false)                   ($SUPERSIM_INTEROP_AUTORELAY)
          Automatically relay messages sent to the L2ToL2CrossDomainMessenger using
          account 0xa0Ee7A142d267C1f36714E4a8F75612F20a79720

    --l1.port value                     (default: 8545)                    ($SUPERSIM_L1_PORT)
          Listening port for the L1 instance. `0` binds to any available port

    --l2.starting.port value            (default: 9545)                    ($SUPERSIM_L2_STARTING_PORT)
          Starting port to increment from for L2 chains. `0` binds each chain to any
          available port

    --log.color                         (default: false)                   ($SUPERSIM_LOG_COLOR)
          Color the log output if in terminal mode

    --log.format value                  (default: text)                    ($SUPERSIM_LOG_FORMAT)
          Format the log output. Supported formats: 'text', 'terminal', 'logfmt', 'json',
          'json-pretty',

    --log.level value                   (default: INFO)                    ($SUPERSIM_LOG_LEVEL)
          The lowest log level that will be output

    --log.pid                           (default: false)                   ($SUPERSIM_LOG_PID)
          Show pid in the log

    --logs.directory value                                                 ($SUPERSIM_LOGS_DIRECTORY)
          Directory to store logs

   MISC


    --help, -h                          (default: false)
          show help

    --version, -v                       (default: false)
          print the version

supersim fork (forked mode)

Overview

supersim fork

The supersim fork command simplifies the process of forking multiple chains in the Superchain ecosystem simultaneously. It determines the appropriate block heights for each chain and launches both the L1 and L2 chains based on these values.

If you're relying on contracts already deployed on testnet / mainnet chains, you can use fork mode to simulate and interact with the state of the chain without needing to re-deploy or modify the contracts.

Locally fork any of the available chains in a superchain network of the superchain registry, default mainnet versions.

Example startup logs

Available Accounts
-----------------------
(0): 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
--- truncated for brevity ---

Private Keys
-----------------------
(0): 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
--- truncated for brevity ---

Orchestrator Config:
L1:
  Name: mainnet    Chain ID: 1    RPC: http://127.0.0.1:8545    LogPath: /var/folders/0w/ethers-phoenix/T/anvil-chain-1-1521250718
L2:
  Name: op    Chain ID: 10    RPC: http://127.0.0.1:9545    LogPath: /var/folders/0w/ethers-phoenix/T/anvil-chain-10
  Name: base    Chain ID: 8453    RPC: http://127.0.0.1:9546    LogPath: /var/folders/0w/ethers-phoenix/T/anvil-chain-8453
  Name: zora    Chain ID: 7777777    RPC: http://127.0.0.1:9547    LogPath: /var/folders/0w/ethers-phoenix/T/anvil-chain-7777777

Configuration

NAME:
   supersim fork - Locally fork a network in the superchain registry

USAGE:
   supersim fork [command options]

OPTIONS:

          --l1.fork.height value              (default: 0)                       ($SUPERSIM_L1_FORK_HEIGHT)
                L1 height to fork the superchain (bounds L2 time). `0` for latest

          --chains value                                                         ($SUPERSIM_CHAINS)
                chains to fork in the superchain, mainnet options: [base, lyra, metal, mode, op,
                orderly, race, tbn, zora]. In order to replace the public rpc endpoint for a
                chain, specify the ($SUPERSIM_RPC_URL_<CHAIN>) env variable. i.e
                SUPERSIM_RPC_URL_OP=http://optimism-mainnet.infura.io/v3/<API-KEY>

          --network value                     (default: "mainnet")               ($SUPERSIM_NETWORK)
                superchain network. options: mainnet, sepolia, sepolia-dev-0. In order to
                replace the public rpc endpoint for the network, specify the
                ($SUPERSIM_RPC_URL_<NETWORK>) env variable. i.e
                SUPERSIM_RPC_URL_MAINNET=http://mainnet.infura.io/v3/<API-KEY>

          --interop.enabled                   (default: true)                    ($SUPERSIM_INTEROP_ENABLED)
                enable interop predeploy and functionality

          --l1.port value                     (default: 8545)                    ($SUPERSIM_L1_PORT)
                Listening port for the L1 instance. `0` binds to any available port

          --l2.starting.port value            (default: 9545)                    ($SUPERSIM_L2_STARTING_PORT)
                Starting port to increment from for L2 chains. `0` binds each chain to any
                available port

          --interop.autorelay                 (default: false)                   ($SUPERSIM_INTEROP_AUTORELAY)
                Automatically relay messages sent to the L2ToL2CrossDomainMessenger using
                account 0xa0Ee7A142d267C1f36714E4a8F75612F20a79720

          --logs.directory value                                                 ($SUPERSIM_LOGS_DIRECTORY)
                Directory to store logs

          --log.level value                   (default: INFO)                    ($SUPERSIM_LOG_LEVEL)
                The lowest log level that will be output

          --log.format value                  (default: text)                    ($SUPERSIM_LOG_FORMAT)
                Format the log output. Supported formats: 'text', 'terminal', 'logfmt', 'json',
                'json-pretty',

          --log.color                         (default: false)                   ($SUPERSIM_LOG_COLOR)
                Color the log output if in terminal mode

          --log.pid                           (default: false)                   ($SUPERSIM_LOG_PID)
                Show pid in the log

          --help, -h                          (default: false)
                show help

Notes

Fork height

The fork height is determined by L1 block height (default latest). This is then used to derive the corresponding L2 block to start from.

Interoperability contracts

By default, interop contracts are not deployed on forked networks. To include them, run supersim with the --interop.enabled flag.

supersim fork --chains=op,base,zora --interop.enabled

Included contracts

The chain environment includes contracts already deployed to help replicate the Superchain environment. You can see an example of contract addresses for a L2 system here.

OP Stack system contracts (L1)

These are the L1 contracts that are required for a rollup as part of the OP Stack protocol. Examples are the OptimismPortal, L1StandardBridge, and L1CrossDomainMessenger.

View examples of these contracts here the official OP docs or the source code here in the Optimism monorepo

OP Stack L2 contracts (L2)

The OP Stack system contracts on the L2 are included at the standard addresses by default.

Periphery contracts (L2)

L2 chains running on supersim also includes some useful contracts for testing purposes that are not part of the OP Stack by default.

L2NativeSuperchainERC20

A simple ERC20 that adheres to the SuperchainERC20 standard. It includes permissionless minting for easy testing.

Source: L2NativeSuperchainERC20.sol

Deployed address: 0x420beeF000000000000000000000000000000001

Minting new tokens

cast send 0x420beeF000000000000000000000000000000001 "mint(address _to, uint256 _amount)" $RECIPIENT_ADDRESS 1ether  --rpc-url $L2_RPC_URL

Network details

By default, two OP Stack systems will be spun up in vanilla mode

  • OPChainA (chainID 901)
  • OPChainB (chainID 902)

Both "roll up" into a single L1 chain (chainID 900).

OPChainA

Network details

ParameterValue
NameOPChainA
Chain ID901
RPC URLhttp://127.0.0.1:9545

Contract addresses

L1 contracts

{
  "AddressManager": "0x78d21C9820A9135215202A9a8D6521483D4b75cD",
  "AnchorStateRegistry": "0x21799f09394c50220CCD95E7dAc1cdD774FC871a",
  "AnchorStateRegistryProxy": "0xa6F40d5770b3509aB40B2effa5cb544D29743ec7",
  "DelayedWETH": "0x49BBFf1629824A1e7993Ab5c17AFa45D24AB28c9",
  "DelayedWETHProxy": "0x309DA6B9a8fE16afD7D067528d358E55314bEa6b",
  "DisputeGameFactory": "0x20B168142354Cee65a32f6D8cf3033E592299765",
  "DisputeGameFactoryProxy": "0x444689B81D485bc58AB81aC02A95a937fAa152D7",
  "L1CrossDomainMessenger": "0x094e6508ba9d9bf1ce421fff3dE06aE56e67901b",
  "L1CrossDomainMessengerProxy": "0xe5bda89cd85cE0DfB80E053281cA070D65B738e6",
  "L1ERC721Bridge": "0x5C4F5e749A61a9503c4AAE8a9393e89609a0e804",
  "L1ERC721BridgeProxy": "0x018dC24a6617c47cAa00C3fA25097214B2D4F447",
  "L1StandardBridge": "0xb7900B27Be8f0E0fF65d1C3A4671e1220437dd2b",
  "L1StandardBridgeProxy": "0xa01ae68902e205B420FD164435F299E07b0C778b",
  "L2OutputOracle": "0x19652082F846171168Daf378C4fD3ee85a0D4A60",
  "L2OutputOracleProxy": "0x6cE0530E823e23be85D8e151FB023605eB4F6d43",
  "Mips": "0xB3A0348310a0ff78E5FbDB7f14BB7d3e02d40773",
  "OptimismMintableERC20Factory": "0x39Aea2Dd53f2d01c15877aCc2791af6BDD7aD567",
  "OptimismMintableERC20FactoryProxy": "0x15c855966C196Be3a8ca747E8A8Bf40928d4741f",
  "OptimismPortal": "0xb7461Fb347f68f9717e6fD12C8407dEcee063bdc",
  "OptimismPortal2": "0xfcbb237388CaF5b08175C9927a37aB6450acd535",
  "OptimismPortalProxy": "0xF5fe61a258CeBb54CCe428F76cdeD04Cbc12F53d",
  "PreimageOracle": "0x3bd7E801E51d48c5d94Ea68e8B801DFFC275De75",
  "ProtocolVersions": "0xfbfD64a6C0257F613feFCe050Aa30ecC3E3d7C3F",
  "ProtocolVersionsProxy": "0x6dA4f6489039d9f4F3144954DDF5bb2F4986e90b",
  "ProxyAdmin": "0xe32a4D31ffD5596542DAc8239a1DE3Fff9d63475",
  "SafeProxyFactory": "0x4a05c09875DE2DD5B81Bc01dd46eD4699b181bfA",
  "SafeSingleton": "0x99A395CE6d6b37CaaCBad64fB42d556b6CA73a48",
  "SuperchainConfig": "0x068E44eB31e111028c41598E4535be7468674D0A",
  "SuperchainConfigProxy": "0x7E6c6ebCF109fa23277b86bdA39738035C21BB86",
  "SystemConfig": "0x6167B477F8d9138aa509f54b2800443857e28c0f",
  "SystemConfigProxy": "0xf32919Ed2490b56EaD65E72749894aE4C9523320",
  "SystemOwnerSafe": "0xc052b7316C87390E555aF97D42bCd5FB6d5eEFDa"
}

L2 contracts

{
  // OP Stack predeploys
  "L2ToL1MessagePasser": "0x4200000000000000000000000000000000000016",
  "L2CrossDomainMessenger": "0x4200000000000000000000000000000000000007",
  "L2StandardBridge": "0x4200000000000000000000000000000000000010",
  "L2ERC721Bridge": "0x4200000000000000000000000000000000000014",
  "SequencerFeeVault": "0x4200000000000000000000000000000000000011",
  "OptimismMintableERC20Factory": "0x4200000000000000000000000000000000000012",
  "OptimismMintableERC721Factory": "0x4200000000000000000000000000000000000017",
  "L1BlockInterop": "0x4200000000000000000000000000000000000015",
  "GasPriceOracle": "0x420000000000000000000000000000000000000F",
  "ProxyAdmin": "0x4200000000000000000000000000000000000018",
  "BaseFeeVault": "0x4200000000000000000000000000000000000019",
  "L1FeeVault": "0x420000000000000000000000000000000000001A",
  "GovernanceToken": "0x4200000000000000000000000000000000000042",
  "SchemaRegistry": "0x4200000000000000000000000000000000000020",
  "EAS": "0x4200000000000000000000000000000000000021",
  "CrossL2Inbox": "0x4200000000000000000000000000000000000022",
  "L2ToL2CrossDomainMessenger": "0x4200000000000000000000000000000000000023",

  // Periphery
  "L2NativeSuperchainERC20": "0x420beeF000000000000000000000000000000001"
}

OPChainB

Network details

ParameterValue
NameOPChainB
Chain ID902
RPC URLhttp://127.0.0.1:9546

Contract addresses

L1 contracts

{
  "AddressManager": "0xafB51A0f73C8409AeA1207DF7f39885c927BeA46",
  "AnchorStateRegistry": "0x05493149c84A71063f7948127bb931f8377F779C",
  "AnchorStateRegistryProxy": "0xfd0269a716A59fF125Bd7eb65Cd3427C8555bab7",
  "DelayedWETH": "0x49BBFf1629824A1e7993Ab5c17AFa45D24AB28c9",
  "DelayedWETHProxy": "0xA63353128502269b4A4A4c2677fE316cd9ad4397",
  "DisputeGameFactory": "0x20B168142354Cee65a32f6D8cf3033E592299765",
  "DisputeGameFactoryProxy": "0x5F416fEb15c8B382d338FDBDb7D44967ca2b59BC",
  "L1CrossDomainMessenger": "0x094e6508ba9d9bf1ce421fff3dE06aE56e67901b",
  "L1CrossDomainMessengerProxy": "0xCB9768921831677Ae15cE4B64A10B94F49cD88E2",
  "L1ERC721Bridge": "0x5C4F5e749A61a9503c4AAE8a9393e89609a0e804",
  "L1ERC721BridgeProxy": "0xDCE41E6C0901586EE27Eac329EBD4b5fe5A7170d",
  "L1StandardBridge": "0xb7900B27Be8f0E0fF65d1C3A4671e1220437dd2b",
  "L1StandardBridgeProxy": "0x2D8543c236a4d626f54B51Fa8bc229a257C5143E",
  "L2OutputOracle": "0x19652082F846171168Daf378C4fD3ee85a0D4A60",
  "L2OutputOracleProxy": "0x006Af3fB62c4BE4fB0393995d364BbFe6b0F3CB2",
  "Mips": "0xB3A0348310a0ff78E5FbDB7f14BB7d3e02d40773",
  "OptimismMintableERC20Factory": "0x39Aea2Dd53f2d01c15877aCc2791af6BDD7aD567",
  "OptimismMintableERC20FactoryProxy": "0x1A2A942d891e525D1Ab192578a378980729fD585",
  "OptimismPortal": "0xb7461Fb347f68f9717e6fD12C8407dEcee063bdc",
  "OptimismPortal2": "0xfcbb237388CaF5b08175C9927a37aB6450acd535",
  "OptimismPortalProxy": "0xdfC9DEAbEEbDaa7620C71e2E76AEda32919DE5f2",
  "PreimageOracle": "0x3bd7E801E51d48c5d94Ea68e8B801DFFC275De75",
  "ProtocolVersions": "0xfbfD64a6C0257F613feFCe050Aa30ecC3E3d7C3F",
  "ProtocolVersionsProxy": "0xE139cB0CDa5EF722870068ea331d0989776A7aDf",
  "ProxyAdmin": "0xff5E6C2Af859f70B875BA59B958BEde60E36bf69",
  "SafeProxyFactory": "0xb68f3B057fE3c6CdDF9DB35837Ea769FCc81978a",
  "SafeSingleton": "0xeeB44D84d505AbD958d032e90704c56443eB3ED0",
  "SuperchainConfig": "0x068E44eB31e111028c41598E4535be7468674D0A",
  "SuperchainConfigProxy": "0x2ED4AA34573c36bF3856e597501aEf9d9Dc1687C",
  "SystemConfig": "0x6167B477F8d9138aa509f54b2800443857e28c0f",
  "SystemConfigProxy": "0x2Db03FE998D7c20E4B65afD1f50f04Ec4BfAb694",
  "SystemOwnerSafe": "0xBF3830711B7c559042453B7546dB4736eFB4245e"
}

L2 contracts

{
  // OP Stack predeploys
  "L2ToL1MessagePasser": "0x4200000000000000000000000000000000000016",
  "L2CrossDomainMessenger": "0x4200000000000000000000000000000000000007",
  "L2StandardBridge": "0x4200000000000000000000000000000000000010",
  "L2ERC721Bridge": "0x4200000000000000000000000000000000000014",
  "SequencerFeeVault": "0x4200000000000000000000000000000000000011",
  "OptimismMintableERC20Factory": "0x4200000000000000000000000000000000000012",
  "OptimismMintableERC721Factory": "0x4200000000000000000000000000000000000017",
  "L1BlockInterop": "0x4200000000000000000000000000000000000015",
  "GasPriceOracle": "0x420000000000000000000000000000000000000F",
  "ProxyAdmin": "0x4200000000000000000000000000000000000018",
  "BaseFeeVault": "0x4200000000000000000000000000000000000019",
  "L1FeeVault": "0x420000000000000000000000000000000000001A",
  "GovernanceToken": "0x4200000000000000000000000000000000000042",
  "SchemaRegistry": "0x4200000000000000000000000000000000000020",
  "EAS": "0x4200000000000000000000000000000000000021",
  "CrossL2Inbox": "0x4200000000000000000000000000000000000022",
  "L2ToL2CrossDomainMessenger": "0x4200000000000000000000000000000000000023",

  // Periphery
  "L2NativeSuperchainERC20": "0x420beeF000000000000000000000000000000001"
}

Deposit transactions

Interoperability

Using viem to relay interop messages (TypeScript)

This guide describes how to use viem to send and relay interop messages using the L2ToL2CrossDomainMessenger

We'll perform the SuperchainERC20 interop transfer in First steps and Manually relaying interop messages with cast again, this time using viem to relay the message without the autorelayer.

Steps

The full code snippet can be found here

1. Start supersim

supersim

2. Install TypeScript packages

npm i viem @eth-optimism/viem

3. Imports & Setup

import {
	http,
	encodeFunctionData,
	createWalletClient,
	parseAbi,
	defineChain,
	publicActions,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import {
    contracts,
	publicActionsL2,
	walletActionsL2,
    supersimL2A,
    supersimL2B,
    createInteropSentL2ToL2Messages,
    decodeRelayedL2ToL2Messages,
} from "@eth-optimism/viem";

// SuperERC20 is in development so we manually define the address here
const L2_NATIVE_SUPERCHAINERC20_ADDRESS = "0x420beeF000000000000000000000000000000001";

// Account for 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
const account = privateKeyToAccount("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");

// Define chains
// ... left out as we'll use the supersim chain definitions

// Configure clients with optimism extension
const opChainAClient = createWalletClient({
	transport: http(),
	chain: supersimL2A,
	account,
}).extend(walletActionsL2())
	.extend(publicActionsL2())
	.extend(publicActions);

const opChainBClient = createWalletClient({
	transport: http(),
	chain: superismL2B,
	account,
}).extend(walletActionsL2())
	.extend(publicActionsL2())
	.extend(publicActions);

4. Mint and Bridge L2NativeSuperchainERC20 from source chain

// #######
// OP Chain A
// #######

// 1. Mint 1000 `L2NativeSuperchainERC20` token on chain A

const mintTxHash = await opChainAClient.writeContract({
	address: L2_NATIVE_SUPERCHAINERC20_ADDRESS,
	abi: parseAbi(["function mint(address to, uint256 amount)"]),
	functionName: "mint",
	args: [account.address, 1000n],
});

await opChainAClient.waitForTransactionReceipt({ hash: mintTxHash });

// 2. Initiate sendERC20 tx to bridge funds to chain B

console.log("Initiating sendERC20 on OPChainA to OPChainB...");
const sendERC20TxHash = await opChainAClient.writeContract({
	address: L2_NATIVE_SUPERCHAINERC20_ADDRESS,
	abi: parseAbi([
		"function sendERC20(address _to, uint256 _amount, uint256 _chainId)",
	]),
	functionName: "sendERC20",
	args: [account.address, 1000n, BigInt(supersimL2B.id)],
});

const sendERC20Receipt = await opChainAClient.waitForTransactionReceipt({ hash: sendERC20TxHash });

// 3. Construct the interoperable log data from the sent message

const { sentMessages } = await createInteropSentL2ToL2Messages(opChainAClient, { receipt: sendERC20Receipt })
const sentMessage = sentMessages[0] // We only sent 1 message

5. Relay the sent message on the destination chain


// ##########
// OP Chain B
// ##########

// 4. Relay the sent message

console.log("Relaying message on OPChainB...");
const relayTxHash = await opChainBClient.relayL2ToL2Message({
    sentMessageId: sentMessage.id,
    sentMessagePayload: sentMessage.payload,
});

const relayReceipt = await opChainBClient.waitForTransactionReceipt({ hash: relayTxHash });

// 5. Ensure the message was relayed successfully

const { successfulMessages, failedMessages } = decodeRelayedL2ToL2Messages({ receipt: relayReceipt });
if (successfulMessages.length != 1) {
    throw new Error("failed to relay message!")
}

// 6. Check balance on OPChainB
const balance = await opChainBClient.readContract({
	address: L2_NATIVE_SUPERCHAINERC20_ADDRESS,
	abi: parseAbi(["function balanceOf(address) view returns (uint256)"]),
	functionName: "balanceOf",
	args: [account.address],
});

console.log(`Balance on OPChainB: ${balance}`);

Full code snippet

Click to view
// Using viem to transfer L2NativeSuperchainERC20

import {
	http,
	encodeFunctionData,
	createWalletClient,
	parseAbi,
	defineChain,
	publicActions,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import {
    contracts,
	publicActionsL2,
	walletActionsL2,
    supersimL2A,
    supersimL2B,
    createInteropSentL2ToL2Messages,
    decodeRelayedL2ToL2Messages,
} from "@eth-optimism/viem";

// SuperERC20 is in development so we manually define the address here
const L2_NATIVE_SUPERCHAINERC20_ADDRESS =
	"0x420beeF000000000000000000000000000000001";

// account for 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
const account = privateKeyToAccount("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");

// Define chains
// ... left out as we'll use the supersim chain definitions

// Configure op clients
const opChainAClient = createWalletClient({
	transport: http(),
	chain: supersimL2A,
	account,
}).extend(walletActionsL2())
	.extend(publicActionsL2())
	.extend(publicActions);

const opChainBClient = createWalletClient({
	transport: http(),
	chain: supersimL2B,
	account,
}).extend(walletActionsL2())
	.extend(publicActionsL2())
	.extend(publicActions);

// #######
// OP Chain A
// #######

// 1. Mint 1000 `L2NativeSuperchainERC20` token

const mintTxHash = await opChainAClient.writeContract({
	address: L2_NATIVE_SUPERCHAINERC20_ADDRESS,
	abi: parseAbi(["function mint(address to, uint256 amount)"]),
	functionName: "mint",
	args: [account.address, 1000n],
});

await opChainAClient.waitForTransactionReceipt({ hash: mintTxHash });

// 2. Initiate sendERC20 tx to bridge funds to chain B

console.log("Initiating sendERC20 on OPChainA...");
const sendERC20TxHash = await opChainAClient.writeContract({
	address: L2_NATIVE_SUPERCHAINERC20_ADDRESS,
	abi: parseAbi([
		"function sendERC20(address _to, uint256 _amount, uint256 _chainId)",
	]),
	functionName: "sendERC20",
	args: [account.address, 1000n, BigInt(supersimL2B.id)],
});

const sendERC20Receipt = await opChainAClient.waitForTransactionReceipt({ hash: sendERC20TxHash });

// 3. Construct the interoperable log data from the sent message

const { sentMessages } = await createInteropSentL2ToL2Messages(opChainAClient, { receipt: sendERC20Receipt })
const sentMessage = sentMessages[0] // We only sent 1 message

// ##########
// OP Chain B
// ##########

// 4. Relay the sent message

console.log("Relaying message on OPChainB...");
const relayTxHash = await opChainBClient.relayL2ToL2Message({
    sentMessageId: sentMessage.id,
    sentMessagePayload: sentMessage.payload,
});

const relayReceipt = await opChainBClient.waitForTransactionReceipt({ hash: relayTxHash });

// 5. Ensure the message was relayed successfully

const { successfulMessages, failedMessages } = decodeRelayedL2ToL2Messages({ receipt: relayReceipt });
if (successfulMessages.length != 1) {
    throw new Error("failed to relay message!")
}

// 6. Check balance on OPChainB
const balance = await opChainBClient.readContract({
	address: L2_NATIVE_SUPERCHAINERC20_ADDRESS,
	abi: parseAbi(["function balanceOf(address) view returns (uint256)"]),
	functionName: "balanceOf",
	args: [account.address],
});

console.log(`Balance on OPChainB: ${balance}`);

Manually relaying interop messages with cast and L2ToL2CrossDomainMessenger

This guide describes how to form a message identifier to relay a L2ToL2CrossDomainMessenger cross chain call.

We'll perform the SuperchainERC20 interop transfer in First steps again, this time manually relaying the message without the autorelayer.

Overview

Contracts used

High level steps

Sending an interop message using the L2ToL2CrossDomainMessenger:

On source chain (OPChainA 901)

  1. Invoke L2NativeSuperchainERC20.sentERC20 to bridge funds
    • this leverages L2ToL2CrossDomainMessenger.sendMessage to make the cross chain call
  2. Retrieve the log identifier and the message payload for the SentMessage event.

On destination chain (OPChainB 902)

  1. Relay the message with L2ToL2CrossDomainMessenger.relayMessage
    • which then calls L2NativeSuperchainERC20.relayERC20

Message identifier

A message identifier uniquely identifies a log emitted on a chain. The sequencer and smart contracts (CrossL2Inbox) use the identifier to perform invariant checks to confirm that the message is valid.

struct Identifier {
    address origin;      // Account (contract) that emits the log
    uint256 blocknumber; // Block number in which the log was emitted
    uint256 logIndex;    // Index of the log in the array of all logs emitted in the block
    uint256 timestamp;   // Timestamp that the log was emitted
    uint256 chainid;     // Chain ID of the chain that emitted the log
}

Steps

1. Start supersim

supersim

2. Mint tokens to transfer on chain 901

Run the following command to mint 1000 L2NativeSuperchainERC20 tokens to the recipient address:

cast send 0x420beeF000000000000000000000000000000001 "mint(address _to, uint256 _amount)"  0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 1000  --rpc-url http://127.0.0.1:9545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

3. Initiate the send transaction on chain 901

Send the tokens from Chain 901 to Chain 902 using the following command:

cast send 0x420beeF000000000000000000000000000000001 "sendERC20(address _to, uint256 _amount, uint256 _chainId)" 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 1000 902 --rpc-url http://127.0.0.1:9545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

4. Get the log emitted by the L2ToL2CrossDomainMessenger

The token contract calls the L2ToL2CrossDomainMessenger, which emits a message (log) that can be relayed on the destination chain.

$ cast logs --address 0x4200000000000000000000000000000000000023 --rpc-url http://127.0.0.1:9545

address: 0x4200000000000000000000000000000000000023
blockHash: 0x3905831f1b109ce787d180c1ed977ebf0ff1a6334424a0ae8f3731b035e3f708
blockNumber: 4
data: 0x000000000000000000000000420beef00000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000064d9f50046000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb9226600000000000000000000000000000000000000000000000000000000000003e800000000000000000000000000000000000000000000000000000000
logIndex: 1
topics: [
      0x382409ac69001e11931a28435afef442cbfd20d9891907e8fa373ba7d351f320
      0x0000000000000000000000000000000000000000000000000000000000000386
      0x000000000000000000000000420beef000000000000000000000000000000001
      0x0000000000000000000000000000000000000000000000000000000000000000
]
...

5. Retrive the block timestamp the log was emitted in

Since the message identifier requires the block timestamp, fetch the block info to get the timestamp.

$ cast block 0xREPLACE_WITH_CORRECT_BLOCKHASH --rpc-url http://127.0.0.1:9545
...
timestamp            1728507703
...

6. Prepare the message identifier & payload

Now we have all the information needed for the message (log) identifier.

ParameterValueNote
origin0x4200000000000000000000000000000000000023L2ToL2CrossDomainMessenger
blocknumber4from step 4
logIndex1from step 4
timestamp1728507703from step 5
chainid901OPChainA chainID

The message payload is the concatenation of the [...topics, data] in order.

0x + 382409ac69001e11931a28435afef442cbfd20d9891907e8fa373ba7d351f320
   + 0000000000000000000000000000000000000000000000000000000000000386
   + 000000000000000000000000420beef000000000000000000000000000000001
   + 0000000000000000000000000000000000000000000000000000000000000000
   + 000000000000000000000000420beef00000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000064d9f50046000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb9226600000000000000000000000000000000000000000000000000000000000003e800000000000000000000000000000000000000000000000000000000

Payload: 0x382409ac69001e11931a28435afef442cbfd20d9891907e8fa373ba7d351f3200000000000000000000000000000000000000000000000000000000000000386000000000000000000000000420beef0000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000420beef00000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000064d9f50046000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb9226600000000000000000000000000000000000000000000000000000000000003e800000000000000000000000000000000000000000000000000000000

7. Send the relayMessage transaction

Call relayMessage on the L2ToL2CrossDomainMessenger

// L2ToL2CrossDomainMessenger.sol (truncated for brevity)

contract L2ToL2CrossDomainMessenger {

  // ...

  function relayMessage(
      ICrossL2Inbox.Identifier calldata _id,
      bytes calldata _sentMessage
  ) payable

  // ...
}

relayMessage parameters

  • ICrossL2Inbox.Identifier calldata _id: identifier pointing to the SentMessage log on the source chain
  • bytes memory _sentMessage: encoding of the log topics & data

Below is an example call, but make sure to replace them with the correct values you received in previous steps.

$ cast send 0x4200000000000000000000000000000000000023 \
    "relayMessage((address, uint256, uint256, uint256, uint256), bytes)" \
    "(0x4200000000000000000000000000000000000023, 4, 1, 1728507703, 901)" \
    0x382409ac69001e11931a28435afef442cbfd20d9891907e8fa373ba7d351f3200000000000000000000000000000000000000000000000000000000000000386000000000000000000000000420beef0000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000420beef00000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000064d9f50046000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb9226600000000000000000000000000000000000000000000000000000000000003e800000000000000000000000000000000000000000000000000000000 \
    --rpc-url http://127.0.0.1:9546 \
    --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

9. Check the balance on chain 902

Verify that the balance of the L2NativeSuperchainERC20 on chain 902 has increased:

cast balance --erc20 0x420beeF000000000000000000000000000000001 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --rpc-url http://127.0.0.1:9546

Alternatives

This is obviously very tedious to do by hand πŸ˜…. Here are some alternatives

  • use supersim --interop.autorelay - this only works on supersim, but relayers for the testnet/prod environment will be available soon!
  • use viem bindings/actions - if you're using typescript, we have bindings available to make fetching identifiers and relaying messages easy

Writing cross-chain contract using L2ToL2CrossDomainMessenger

This guide walks through the CrossChainPingPong.sol contract, focusing on high level design and steps on integrating the L2ToL2CrossChainMessenger contract. The source code can be found here.

High level overview

CrossChainPingPong.sol implements a cross-chain ping-pong game using the L2ToL2CrossDomainMessenger.

  • Players hit a virtual ** ball ** back and forth between allowed L2 chains. The game starts with a serve
  • from a designated start chain, and each hit increases the rally count. The contract tracks the last hitter's address, chain ID, and the current rally count.

Diagram

sequenceDiagram
    participant Chain1 as Chain 1 (First serve)
    participant Chain2 as Chain 2
    
    Note over Chain1: πŸš€ Game Starts
    Note over Chain1: πŸ“ Serve Ball
    Chain1->>Chain2: πŸ“€ Send PingPongBall {rallyCount: 1, lastHitter: Chain1}
    Chain1-->>Chain1: Emit BallSent event
    activate Chain2
    Note over Chain2: πŸ“₯ Receive Ball
        Chain2-->>Chain2: Emit BallReceived event

    Note over Chain2: πŸ“ Hit Ball
    Chain2->>Chain1: πŸ“€ Send PingPongBall {rallyCount: 2, lastHitter: Chain2}
    Chain2-->>Chain2: Emit BallSent event
    deactivate Chain2
    activate Chain1
    Note over Chain1: πŸ“₯ Receive Ball
        Chain1-->>Chain1: Emit BallReceived event
    Note over Chain1: πŸ“ Hit Ball
    Chain1->>Chain2: πŸ“€ Send PingPongBall {rallyCount: 3, lastHitter: Chain1}
    Chain1-->>Chain1: Emit BallSent event
    deactivate Chain1
    Note over Chain1,Chain2: Game continues...

Flow

1. Contract Deployment

  • Deploy identical CrossChainPingPong.sol contracts on multiple L2 chains.
  • Contracts are deployed using CREATE2 with the same parameter (_allowedChainIds and starting _serverChainId), resulting in the same address and initial state.

2. Initiate Game (Serve)

  • Call serveBallTo on the designated server chain, specifying a destination chain.
  • This can only be done once to start the game.
  • Contract creates initial PingPongBall struct.

3. Send Cross-Chain Message

  • Contract uses L2ToL2CrossDomainMessenger to send the ball data to the specified chain.

4. Receive on Destination Chain

  • L2ToL2CrossDomainMessenger on destination chain calls receiveBall.
  • Contract verifies the message sender and origin.
  • Ball data is stored, and the ball is marked as present on this chain.

5. Continue Game (Hit)

  • Any user on the chain currently holding the ball calls hitBallTo to send it to another chain.
  • Contract updates the PingPongBall data (increment rally count, update last hitter).
  • Process repeats from step 3.

Key Points

  • Game starts with serveBallTo on a designated chain (server).
  • After serving, hitBallTo is used to continue the game.
  • Each contract instance can both send and receive balls.
  • The contract tracks whether it currently holds the ball.
  • Cross-chain messaging is handled by L2ToL2CrossDomainMessenger.

Walkthrough

Here's an explanation of the functions in the contract, with a focus on how it interacts with L2ToL2CrossChainMessenger.

Initializing contract states

Constructor Setup

constructor(uint256[] memory _allowedChainIds, uint256 _serverChainId) {
    for (uint256 i = 0; i < _allowedChainIds.length; i++) {
        _isChainIdAllowed[_allowedChainIds[i]] = true;
    }

    if (!_isChainIdAllowed[_serverChainId]) {
        revert InvalidDestinationChain(_serverChainId);
    }

    SERVER_CHAIN_ID = _serverChainId;
}

This constructor initializes the contract with two crucial pieces of information:

  1. Allowed Chain IDs: It populates the _isChainIdAllowed mapping, which determines which chains can participate in the game.
  2. Server Chain ID: It sets the SERVER_CHAIN_ID, designating which chain has the authority to start the game.

Synchronizing the Game Start

The contract uses a simple mechanism to ensure the game starts correctly across all chains:

  1. Designate the server chain:

    if (SERVER_CHAIN_ID != block.chainid) {
        revert UnauthorizedServer(block.chainid, SERVER_CHAIN_ID);
    }
    

    Only the designated server chain can initiate the game.

  2. Only be able to serve once

    if (_hasServerAlreadyServed) {
        revert BallAlreadyServed();
    }
    _hasServerAlreadyServed = true;
    

    This ensures the ball is served only once, preventing multiple game initiations.

By using these checks in the serveBallTo function, the contract ensures that:

  • The game starts from a single, predetermined chain.
  • The initial serve happens only once.
  • All other chains wait to receive the ball before they can participate.

Because this contract is simple, it doesn't require complex time-based coordination or post-deployment setup. The game naturally begins when the server chain calls serveBallTo, and other chains join the game as they receive the ball.

Reliance on CREATE2 for Cross-Chain Consistency

While not explicitly mentioned in the code, this contract's design implicitly assumes the use of CREATE2 for deployment. Here's why CREATE2 is crucial for this setup:

  1. Predictable Addresses: CREATE2 enables deployment at the same address on all chains, crucial for cross-chain message verification:

    if (IL2ToL2CrossDomainMessenger(MESSENGER).crossDomainMessageSender() != address(this)) {
        revert InvalidCrossDomainSender();
    }
    
  2. Self-referential Messaging: The contract sends messages to itself on other chains:

    IL2ToL2CrossDomainMessenger(MESSENGER).sendMessage(_toChainId, address(this), _message);
    

    This requires address(this) to be consistent across chains.

  3. Initialization State Considerations: The constructor parameters (_allowedChainIds and _serverChainId) affect the contract's initialization state. Different values will result in different contract addresses when using CREATE2. To maintain address consistency:

    • Use identical _allowedChainIds arrays (same values in the same order)
    • Use the same _serverChainId across all chain deployments.

Without CREATE2, you would need to:

  • Manually track contract addresses for each chain.
  • Implement a more complex initialization process to register contract addresses across chains.
  • Potentially redesign the security model that relies on address matching.

Sending a cross chain message

Two functions initiate a message send

  • serveBallTo: This function initiates the game by serving the ball from the designated server chain to another chain.

  • hitBallTo: This function is used to hit the ball to another chain after receiving it.

Both functions create a new PingPongBall struct with updated information and then call _sendCrossDomainMessage to transmit this ball data to the specified destination chain.

Now, let's look at how _sendCrossDomainMessage works:

function _sendCrossDomainMessage(PingPongBall memory _ball, uint256 _toChainId) internal {
    bytes memory _message = abi.encodeCall(this.receiveBall, (_ball));
    IL2ToL2CrossDomainMessenger(MESSENGER).sendMessage(_toChainId, address(this), _message);
}

1. Encode the function call

bytes memory _message = abi.encodeCall(this.receiveBall, (_ball));
  • The encoded message includes the function selector for receiveBall and the ABI-encoded _ball struct.
  • By encoding a call to receiveBall, we're instructing the destination chain's messenger to execute this function when it receives the message.

2. Send the cross-domain message

IL2ToL2CrossDomainMessenger(MESSENGER).sendMessage(_toChainId, address(this), _message);
  • Calls the sendMessage function on the L2ToL2CrossDomainMessenger to initiate cross-chain communication.
  • Parameters:
    • _toChainId: Specifies the destination chain.
    • address(this): Ensures the message appears to come from the corresponding CrossChainPingPong contract on the receiving chain.
    • _message: The encoded function call created in step 1.
  • Relies on the L2ToL2CrossDomainMessenger to securely bridge the message to the specified chain.

Why we're sending a receiveBall function call

  • We're not directly calling receiveBall on another chain. Instead, we're sending a message to the L2ToL2CrossDomainMessenger on the destination chain.
  • This message instructs the messenger to execute the receiveBall function on our behalf.
  • The process works like this:
    1. Our contract encodes a call to receiveBall with the ball data.
    2. We send this encoded call to the L2ToL2CrossDomainMessenger on the current chain.
    3. The messenger system relays this to the corresponding messenger on the destination chain.
    4. The destination chain's messenger receives the message and executes the encoded receiveBall call on our contract.

Receiving a cross chain message

function receiveBall(PingPongBall memory _ball) external {
    if (msg.sender != MESSENGER) {
        revert CallerNotL2ToL2CrossDomainMessenger();
    }

    if (IL2ToL2CrossDomainMessenger(MESSENGER).crossDomainMessageSender() != address(this)) {
        revert InvalidCrossDomainSender();
    }

    _receivedBall = _ball;
    _isBallPresent = true;

    emit BallReceived(IL2ToL2CrossDomainMessenger(MESSENGER).crossDomainMessageSource(), block.chainid, _ball);
}

1. Check msg.sender to verify the caller

  if (msg.sender != MESSENGER) {
      revert CallerNotL2ToL2CrossDomainMessenger();
  }
  • The receiveBall function must only be called by the trusted L2ToL2CrossDomainMessenger. This messenger is responsible for handling cross-chain messages securely, so we need to verify that it is indeed the caller.
  • Without this check, any external user could directly call receiveBall and pass arbitrary _ball data, bypassing the intended cross-chain communication flow.
  • We rely on L2ToL2CrossDomainMessenger to ensure that:
    • The function is only triggered by a valid message sent from another chain.
    • The source chain's sender address and chain ID are correctly relayed.
    • Messages can’t be replayed or sent twice, preserving game integrity.

2. Check the sender of the message on the source chain

  if (IL2ToL2CrossDomainMessenger(MESSENGER).crossDomainMessageSender() != address(this)) {
      revert InvalidCrossDomainSender();
  }
  • The second check ensures that the message originated from the correct CrossChainPingPong contract on the source chain (the chain where someone called hitBallTo).
    • Leverages the fact that CrossChainPingPong contracts are deployed identically across chains, using deterministic deployment methods like CREATE2.
  • Without verifying this, any contract on a different chain could send an arbitrary _ball through the messenger, potentially disrupting the game's state.
  • Since the CrossChainPingPong contract is deployed with identical logic across chains, we trust that only the intended contract would trigger this message in the correct game context.

3. Make state updates and emit events

  _receivedBall = _ball;
  _isBallPresent = true;

  emit BallReceived(IL2ToL2CrossDomainMessenger(MESSENGER).crossDomainMessageSource(), block.chainid, _ball);

Updates the contract on this chain to keep the state of the ball and mark that it's present on this chain.

Takeaways

This is just one of many patterns to use the L2ToL2CrossDomainMessenger in your contract to power cross-chain calls. Key points to remember:

  1. Simple Message Passing: This design sends simple messages between identical contracts on different chains. Each message contains only the essential game state (rally count, last hitter). More complex systems might involve multiple contracts, intermediary relayers.

  2. Cross-Chain Sender Verification: Always verify the sender of cross-chain messages. This includes checking both the immediate caller (the messenger) and the original sender on the source chain.

  3. Cross-Chain Contract Coordination: This design uses CREATE2 for consistent contract addresses across chains, simplifying cross-chain verification. Alternative approaches include:

    • Beacon proxy patterns for upgradeable contracts
    • Post-deployment setup where contract addresses are specified after deployment

Cross-chain tic-tac-toe