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. Send the Ether
It exists two different methods to do this action

First method - OptimismPortal

Send the Ether to OptimismPortal contract of the respective L2 (on chain 900)**

Initiate a bridge transaction on the L1:
In the case of the chain 901 the contract is 0x37a418800d0c812A9dE83Bc80e993A6b76511B57

cast send 0x37a418800d0c812A9dE83Bc80e993A6b76511B57 --value 0.1ether --rpc-url http://127.0.0.1:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

Second method - L1StandardBridge

Call bridgeETH function on the L1StandardBridgeProxy / L1StandardBridge contract of the respective L2 on L1 (chain 900) In the case of the chain 901 the contract is 0x8d515eb0e5F293B16B6bBCA8275c060bAe0056B0

Initiate a bridge transaction on the L1:

cast send 0x8d515eb0e5F293B16B6bBCA8275c060bAe0056B0 "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 0x4200000000000000000000000000000000000028 "sendERC20(address _token, address _to, uint256 _amount, uint256 _chainId)" 0x420beeF000000000000000000000000000000001 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] SuperchainTokenBridge#RelayERC20 token=0x420beeF000000000000000000000000000000001 from=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 to=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 amount=1000 source=901

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

    --interop.delay value               (default: 0)                       ($SUPERSIM_INTEROP_DELAY)
          Delay before relaying messages sent to the L2ToL2CrossDomainMessenger

    --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

    --odyssey.enabled                   (default: false)                   ($SUPERSIM_ODYSSEY_ENABLED)
          Enable odyssey experimental features

   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
          
          --interop.delay value               (default: 0)                       ($SUPERSIM_INTEROP_DELAY)
                Delay before relaying messages sent to the L2ToL2CrossDomainMessenger

          --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

          --odyssey.enabled                   (default: false)                   ($SUPERSIM_ODYSSEY_ENABLED)
                Enable odyssey experimental features

          --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",
  "SuperchainWETH": "0x4200000000000000000000000000000000000024",
  "SuperchainTokenBridge": "0x4200000000000000000000000000000000000028",

  // 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",
  "SuperchainWETH": "0x4200000000000000000000000000000000000024",
  "SuperchainTokenBridge": "0x4200000000000000000000000000000000000028",

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

Deposit transactions

Supersim supports deposits transactions as described in the explainer. However in a very lightweight manner without the op-node derivation pipeline by listening directly to the TransactionDeposited events on the OptimsimPortal contract and simply forwarding the transaction to the applicable L2.

This implies the execution engine used with Supersim must support the optimism Deposit Transaction Type.

OptimismPortal

When starting Supersim, the L1 contracts for each L2 chain are emitted as output to the console. The L1CrossDomainMessenger, L1StandardBridge, and OptimismPortal can be used to initiate deposits in the same manner as one would on a production network like OP Mainnet or Base.

Chain Configuration
-----------------------
L1: Name: Local  ChainID: 900  RPC: http://127.0.0.1:8545  LogPath: ...

L2: Predeploy Contracts Spec ( https://specs.optimism.io/protocol/predeploys.html )

  * Name: OPChainA  ChainID: 901  RPC: http://127.0.0.1:9545  LogPath: ...
    L1 Contracts:
     - OptimismPortal:         0x37a418800d0c812A9dE83Bc80e993A6b76511B57
     - L1CrossDomainMessenger: 0xcd712b03bc6424BF45cE6C29Fc90FFDece228F6E
     - L1StandardBridge:       0x8d515eb0e5F293B16B6bBCA8275c060bAe0056B0

  ...

If running Supersim in fork mode, the production contracts will be used for each of the forked networks.

Chain Configuration
-----------------------
L1: Name: mainnet  ChainID: 1  RPC: http://127.0.0.1:8545  LogPath: ...

L2: Predeploy Contracts Spec ( https://specs.optimism.io/protocol/predeploys.html )

  * Name: op  ChainID: 10  RPC: http://127.0.0.1:9545  LogPath: ...
    L1 Contracts:
     - OptimismPortal:         0xbEb5Fc579115071764c7423A4f12eDde41f106Ed
     - L1CrossDomainMessenger: 0x25ace71c97B33Cc4729CF772ae268934F7ab5fA1
     - L1StandardBridge:       0x99C9fc46f92E8a1c0deC1b1747d010903E884bE1

  * Name: mode  ChainID: 34443  RPC: http://127.0.0.1:9546  LogPath: ...
    L1 Contracts:
     - OptimismPortal:         0x8B34b14c7c7123459Cf3076b8Cb929BE097d0C07
     - L1CrossDomainMessenger: 0x95bDCA6c8EdEB69C98Bd5bd17660BaCef1298A6f
     - L1StandardBridge:       0x735aDBbE72226BD52e818E7181953f42E3b0FF21

   ...

Sample Deposit Flow

We'll run through a sample deposit directly with the optimism portal using cast

  1. Run Supersim
supersim
  1. Observe OptimismPortal Contract Address
...
* Name: OPChainA  ChainID: 901 ...
    L1 Contracts:
     - OptimismPortal: 0x37a418800d0c812A9dE83Bc80e993A6b76511B57
...
  1. Send Deposit Transaction On L1

We'll be using the first pre-funded account to send this deposit with 1 ether

cast send 0x37a418800d0c812A9dE83Bc80e993A6b76511B57 --value 1ether --rpc-url http://localhost:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
  1. Verify With Supersim Logs
INFO [11-28|13:56:06.756] OptimismPortal#depositTransaction chain.id=901 l2TxHash=0x592d6e13016751332115df1fce59904176bfe447854196ed1b97ee00f14be469

Interoperability

Cross Chain Contract Calls (PingPong)

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
    participant Chain2 as Chain 2
    
    Note over Chain1: 🚀 Game Starts (starting chain)
    Note over Chain1: 🏓 Hit 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,Chain2: Game continues...

Flow

1. Contract Deployment

  • Deployed on all participating chains
  • Utilizes CREATE2 with the same parameter, _serverChainId, resulting in the same address and initial state.

2. Hit the Ball (Starting Move)

  • Call hitBallTo on the chain with the ball, specifying a destination chain.
  • Contract uses L2ToL2CrossDomainMessenger to send the ball data to the specified chain.
  • The reference to the ball is deleted from the serving chain.

3. Receive on Destination Chain

  • L2ToL2CrossDomainMessenger on destination chain calls receiveBall.
  • Contract verifies the message sender and origin.
  • Ball data is stored, indicating its presence on this chain.

4. 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 2.

Walkthrough

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

Initializing contract state

Constructor Setup

constructor(uint256 _serverChainId) {
    if (block.chainid == _serverChainId) {
        ball = PingPongBall(1, block.chainid, msg.sender);
    }
}

If the starting chain, initialize the ball allowing it to be hittable.

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 (messenger.crossDomainMessageSender() != address(this)) revert InvalidCrossDomainSender();
    
  2. Self-referential Messaging: The contract sends messages to itself on other chains:

    messenger.sendMessage(_toChainId, address(this), _message);
    

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

  3. Initialization State Considerations:

    The starting chain id is apart of the initcode, meaning a deployment with a differing value would result in a different address via CREATE2. This is a nice feature as there's an implicit agreement on the starting chain from the address.

    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.

Hitting the ball

hitBallTo: This function is used to hit the ball, when present, to another chain

1. Hitting Constraints

function hitBallTo(uint256 _toChainId) public {
    if (ball.lastHitterAddress == address(0)) revert BallNotPresent();
    if (_toChainId == block.chainid) revert InvalidDestination();
    ...
}
  • The ball contract variable is populated on the chain, indicating its presence
  • The destination must be a different chain

2. Define The Receiving Handler

modifier onlyCrossDomainCallback() {
    if (msg.sender != address(messenger)) revert CallerNotL2ToL2CrossDomainMessenger();
    if (messenger.crossDomainMessageSender() != address(this)) revert InvalidCrossDomainSender();

    _;
}

function receiveBall(PingPongBall memory _ball) onlyCrossDomainCallback() external {
    // Hold reference to the ball
    ball = _ball;

    emit BallReceived(messenger.crossDomainMessageSource(), block.chainid, _ball);
}
  • The handler simply stores reference to the received ball
  • The handler can only be invokable by the cross chain messenger
  • Since the contract is self-referential, the cross chain sender must be the same contract address

3. Hit The Ball Cross Chain

function hitBallTo(uint256 _toChainId) public {
    ...

    // Construct a new ball
    PingPongBall memory newBall = PingPongBall(ball.rallyCount + 1, block.chainid, msg.sender);

    // Delete current reference
    delete ball;

    // Send to the destination
    messenger.sendMessage(_toChainId, address(this), abi.encodeCall(this.receiveBall, (newBall)));

    emit BallSent(block.chainid, _toChainId, newBall);
}
  • Populate a new ball with updated properties
  • Delete reference to the current ball so it's no longer hittable
  • Invoke the contract on the destination chain matching the receiveBall handler defined in (2).

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 Event Reading (TicTacToe)

A horizontally scalable implementation of TicTacToe. This implementation allows players to play each other from any chain without cross-chain calls, instead relying on cross-chain event reading. Since superchain interop can allow for event reading with a 1-block latency, the experience is the same as a single-chain implementation

See the documentation for the frontend for how this game UI is presented to the player.

How it works

We use events to define the ordering of the a game with players only maintaining a local view. By default, a chain is also apart of its own interopble dependency set, meaning players on the same chain can also play each other with no code changes!

The system predeploy that enables pulling in validated cross-chain events is the CrossL2Inbox.

contract ICrossL2Inbox {
    function validateMessage(Identifier calldata _id, bytes32 _msgHash) external view;
}

This contract relies on a CREATE2 deployment to ensure a consistent address across all chains, used to assert the origin of the pulled in game event.

1. Intent To Play

A game is uniquely identified by the chain it was started from with a unqiue nonce. This identifier is included in all event fields such that each player can uniquely reference it locally.

To start a game, a player invokes newGame which broadcasts a NewGame event that any opponent on any chain can react to.

event NewGame(uint256 chainId, uint256 gameId, address player);

function newGame() external {
    emit NewGame(block.chainid, nextGameId, msg.sender);
    nextGameId++;
}

2. Accepting A Game

When a NewGame event is observed, any player can declare their intent to play via acceptGame, referencing the NewGame event. An AcceptedGame event is emitted to signal to the creator that a game is ready to begin.

event AcceptedGame(uint256 chainId, uint256 gameId, address opponent, address player);

function acceptGame(ICrossL2Inbox.Identifier calldata _newGameId, bytes calldata _newGameData) external {
    if (_newGameId.origin != address(this)) revert IdOriginNotTicTacToe();
    ICrossL2Inbox(Predeploys.CROSS_L2_INBOX).validateMessage(_newGameId, keccak256(_newGameData));

    bytes32 selector = abi.decode(_newGameData[:32], (bytes32));
    if (selector != NewGame.selector) revert DataNotNewGame();

    ...

    emit AcceptedGame(chainId, gameId, game.opponent, game.player);
}

To prepare for the game, the event data is decoded and a local view of this game is stored.

(uint256 chainId, uint256 gameId, address opponent) = abi.decode(_newGameData[32:], (uint256, uint256, address));
if (opponent == msg.sender) revert SenderIsOpponent();

// Record Game Metadata (no moves)
Game storage game = games[chainId][gameId][msg.sender];
game.player = msg.sender;
game.opponent = opponent;
game.gameId = gameId;
game.lastOpponentId = _newGameId;
game.movesLeft = 9;

emit AcceptedGame(chainId, gameId, game.opponent, game.player);

3. Starting The Game

As AcceptedGame events are emmited, the player must pick one opponent to play. The opponent's AcceptedGame event is used to instantiate the game and play the starting move via the MovePlayed event.

event MovePlayed(uint256 chainId, uint256 gameId, address player, uint8 _x, uint8 _y);

function startGame(ICrossL2Inbox.Identifier calldata _acceptedGameId, bytes calldata _acceptedGameData, uint8 _x, uint8 _y) external {
    if (_acceptedGameId.origin != address(this)) revert IdOriginNotTicTacToe();
    ICrossL2Inbox(Predeploys.CROSS_L2_INBOX).validateMessage(_acceptedGameId, keccak256(_acceptedGameData));

    bytes32 selector = abi.decode(_acceptedGameData[:32], (bytes32));
    if (selector != AcceptedGame.selector) revert DataNotAcceptedGame();

    ...

    emit MovePlayed(chainId, gameId, game.player, _x, _y);

The event fields contain the information required to perform the neccessary validation.

  • The game identifier for lookup
  • The caller is the appropriate player
  • The player is accepting from the same starting chain
(uint256 chainId, uint256 gameId, address player, address opponent) = // player, opponent swapped in local view
    abi.decode(_acceptedGameData[32:], (uint256, uint256, address, address));

// The accepted game was started from this chain, from the sender
if (chainId != block.chainid) revert GameChainMismatch();
if (msg.sender != player) revert SenderNotPlayer();

// Game has not already been started with an opponent.
Game storage game = games[chainId][gameId][msg.sender];
if (game.opponent != address(0)) revert GameStarted();

// Store local view of this game
...

// Locally record the move by the player with 1
game.moves[_x][_y] = 1;
game.lastOpponentId = _acceptedGameId;

emit MovePlayed(chainId, gameId, game.player, _x, _y);

3. Making Moves

Once a game is started, players can continually make moves by invoking makeMove, reacting to a MovePlayed event of their opponent.

function makeMove(ICrossL2Inbox.Identifier calldata _movePlayedId, bytes calldata _movePlayedData, uint8 _x, uint8 _y) external {
    if (_movePlayedId.origin != address(this)) revert IdOriginNotTicTacToe();
    ICrossL2Inbox(Predeploys.CROSS_L2_INBOX).validateMessage(_movePlayedId, keccak256(_movePlayedData));

    bytes32 selector = abi.decode(_movePlayedData[:32], (bytes32));
    if (selector != MovePlayed.selector) revert DataNotMovePlayed();
}

Similar to acceptGame, validation is performed and the move of their opponent is first locally recorded.

  • The game identifier for lookup
  • The caller is the player for this game
  • The opponent event corresponds to the same game
  • Ordering is enforced by ensuring that the supplied event is always forward progressing.
(uint256 chainId, uint256 gameId,, uint8 oppX, uint8 oppY) = abi.decode(_movePlayedData[32:], (uint256, uint256, address, uint8, uint8));

// Game was instantiated for this player & the move is for the same game
Game storage game = games[chainId][gameId][msg.sender];
if (game.player != msg.sender) revert GameNotExists();
if (game.gameId != gameId) revert GameNotExists();

// The move played event is forward progressing from the last observed event
if (_movePlayedId.chainId != game.lastOpponentId.chainId) revert IdChainMismatch();
if (_movePlayedId.blockNumber <= game.lastOpponentId.blockNumber) revert MoveNotForwardProgressing();
game.lastOpponentId = _movePlayedId;

// Mark the opponents move
game.moves[oppX][oppY] = 2;
game.movesLeft--;

When a move is played we check if the game has been drawn or won, determining the subsequent event to emit.

The makeMove function is only callable when an opponent has a new MovePlayed event. Therefore, if the game is won or drawn, it cannot be progressed any further by the opponent.

// Make the players move
game.moves[_x][_y] = 1;
game.movesLeft--;

// Determine the status of the game
if (_isGameWon(game)) {
    emit GameWon(chainId, gameId, game.player, _x, _y);
} else if (game.movesLeft == 0) {
    emit GameDraw(chainId, gameId, game.player, _x, _y);
} else {
    emit MovePlayed(chainId, gameId, game.player, _x, _y);
}

Takeaways

Leveraging superchain interop, we can build a new type of horizontally scalable contracts that do not rely on hub/spoke messaging with relayers.

  1. As new chains are added to the superchain, this contract can be installed by anyone and immediately playable with no necessary code changes. The frontend simply needs to react the addition of a new chain

  2. The concept of a "chain" can be completely abstracted away from the user. When connecting their wallet, the frontend can simply pick the chain which the user has funds on with the lowest gas fees.

  3. Event reading enables a new level of composability for cross-chain interactions! Imagine contests contract that resolves based on the outcome of a TicTacToe game via the GameWon or GameLost event without the need for a trusted oracle, nor permission or native integration with the TicTacToe contract.

Cross Chain Event Composability (Contests)

We showcase cross chain composability through the implementation of contests. Leveraging the same underlying mechanism powering TicTacToe, this contests can permissionlessly integrate with the events emitted by any contract in the Superchain.

See the documentation for the frontend for how the contests UI is presented to the user.

How it works

Unlike TicTacToe which is deployed on every participating chain, the Contests is deployed on a single L2, behaving like an application-specific op-stack chain rather than a horizontally scaled app.

Contests.sol contains the implementation of the contests. We won't go into the details of the implementation here, but instead focus on how the contests can leverage cross chain event reading to compose with other contracts in the Superchain.

The system predeploy that enables pulling in validated cross-chain events is the CrossL2Inbox.

contract ICrossL2Inbox {
    function validateMessage(Identifier calldata _id, bytes32 _msgHash) external view;
}

A contest is identified by and has its outcome determined by the IContestResolver instance. The resolver starts in the UNDECIDED state, updated into YES or NO when resolving itself with the contest.

enum ContestOutcome {
    UNDECIDED,
    YES,
    NO
}

interface IContestResolver {
    function outcome() external returns (ContestOutcome);
}

BlockHash Contest

With the existence of an event that emits the blockhash and height of a block, we can create a contest on the parity of the blockhash being even or odd.

contract BlockHashEmitter {
    event BlockHash(uint256 blockHeight, bytes32 blockHash);

    function emitBlockHash(uint256 _blockHeight) external {
        bytes32 hash = blockhash(_blockHeight);
        require(hash != bytes32(0));

        emit BlockHash(_blockNumber, hash);
    }
}

Integrating this emitter into a contest is extremely simple. The BlockHashContestFactory is a simple factory that creates a new contest for a given chain & block height. When live, anyone can resolve the contest by simply providing the right BlockHash event to the deployed resolver.

contract BlockHashContestFactory {
    Contests         public contests;
    BlockHashEmitter public emitter; // Same emitter deployed on every chain

    function newContest(uint256 _chainId, uint256 _blockNumber) public payable {
        IContestResolver resolver = new BlockHashResolver(contests, emitter, _chainId, _blockNumber);
        contests.newContest{ value: msg.value }(resolver, msg.sender);
    }
}

contract BlockHashResolver is IContestResolver {
    Contests         public contests;
    ContestOutcome   public outcome;
    BlockHashEmitter public emitter;

    // The target chain & block height
    uint256 public chainId;
    uint256 public blockNumber;

    function resolve(Identifier calldata _id, bytes calldata _data) external {
        require(outcome == ContestOutcome.UNDECIDED);

        // Validate Log
        require(_id.origin == address(emitter), "not an event from the emitter");
        require(_id.chainId == chainId, "must match target chain");
        CrossL2Inbox(Predeploys.CROSS_L2_INBOX).validateMessage(_id, keccak256(_data));

        bytes32 selector = abi.decode(_data[:32], (bytes32));
        require(selector == BlockHashEmitter.BlockHash.selector, "incorrect event");

        // Event should correspond to the right contest
        uint256 dataBlockNumber = abi.decode(_data[32:64], (uint256));
        require(dataBlockNumber == blockNumber, "must match target block height");

        // Resolve the contest (yes if odd, no if even)
        bytes32 blockHash = abi.decode(_data[64:], (bytes32));
        outcome = uint256(blockHash) % 2 != 0 ? ContestOutcome.YES : ContestOutcome.NO;
        contests.resolveContest(this);
    }

}

TicTacToe Contest

A contest for TicTacToe is created on an accepted game between two players, captured by the emitted AcceptedGame event. When decoding the event, the game is uniquely identified by the chain it was created on, chainId, and the associated gameId. These identifying properties of the game are used to create the resolver for the game.

contract TicTacToeContestFactory {
    Contests  public contests;
    TicTacToe public tictactoe;

    function newContest(Identifier calldata _id, bytes calldata _data) public payable {
        // Validate Log
        require(_id.origin == address(tictactoe), "not an event from the TicTacToe contract");
        CrossL2Inbox(Predeploys.CROSS_L2_INBOX).validateMessage(_id, keccak256(_data));

        bytes32 selector = abi.decode(_data[:32], (bytes32));
        require(selector == TicTacToe.AcceptedGame.selector, "incorrect event");

        // Decode the event data
        (uint256 chainId, uint256 gameId, address creator,) = abi.decode(_data[32:], (uint256, uint256, address, address));

        IContestResolver resolver = new TicTacToeGameResolver(contest, tictactoe, chainId, gameId, creator);
        contests.newContest{ value: msg.value }(resolver, msg.sender);
    }
}

When live, anyone can resolve the contest by providing the GameWon or GameDraw event of the associated game from the TicTacToe contract.

contract TicTacToeGameResolver is IContestResolver {
    Contests       public contests;
    ContestOutcome public outcome;
    TicTacToe      public tictactoe;

    // @notice Game for this resolver
    Game public game;

    constructor(Contests _contest, TicTacToe _tictactoe, uint256 _chainId, uint256 _gameId, address _creator) {
        contest = _contest;
        tictactoe = _tictactoe;

        game = Game({chainId: _chainId, gameId: _gameId, creator: _creator});
        outcome = ContestOutcome.UNDECIDED;
    }

    // @notice resolve this game by providing the game ending event
    function resolve(Identifier calldata _id, bytes calldata _data) external {
        // Validate Log
        require(_id.origin == address(tictactoe));
        CrossL2Inbox(Predeploys.CROSS_L2_INBOX).validateMessage(_id, keccak256(_data));

        // Ensure this is a finalizing event
        bytes32 selector = abi.decode(_data[:32], (bytes32));
        require(selector == TicTacToe.GameWon.selector || selector == TicTacToe.GameDraw.selector, "event not a game outcome");

        // Event should correspond to the right game
        (uint256 _chainId, uint256 gameId, address winner,,) = abi.decode(_data[32:], (uint256, uint256, address, uint8, uint8));
        require(_chainId == game.chainId && gameId == game.gameId);

        // Resolve based on if the creator has won (non-draw)
        outcome = winner == game.creator && selector != TicTacToe.GameDraw.selector ? ContestOutcome.YES : ContestOutcome.NO;
        contests.resolveContest(this);
    }
}

Takeaways

Leveraging superchain interop, contracts in the superchain can compose with each other in a similar fashion to how they would on a single chain. No restrictions are placed on the kinds of events a contract can consume via the CrossL2Inbox.

In this example, the BlockHashContestFactory and TicTacToeContestFactory can be seen as just starting points for the Contests app chain. As more contracts and apps are created in the superchain, this developer can compose with them in a similar fashion without needing to change the Contests contract at all!

Bridging ETH

Crosschain ETH transfers in the Superchain are facilitated through the SuperchainWETH contract. For more information on this checkout the spec for SuperchainWETH: https://specs.optimism.io/interop/superchain-weth.html.

Cross-chain ETH transfer from chain 901 to 902

This outlines how to send native ETH from chain 901 to 902. To simplify these steps supersim will be run with the --interop.autorelay flag. The --interop.autorelay 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.

Note: If the source chain uses native ETH as their gas token, but the destination chain uses a custom gas token, then the recipient will receive SuperchainWETH on the destination chain.

1. Start supersim with the autorelayer enabled

supersim --interop.autorelay 

2. Initiate the send transaction on chain 901 through SuperchainWETH contract deployed at 0x4200000000000000000000000000000000000024

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

cast send 0x4200000000000000000000000000000000000024 "sendETH(address _to, uint256 _chainId)" 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 902 --value 10ether --rpc-url http://127.0.0.1:9545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

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

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

# example
INFO [12-02|14:53:02.434] SuperchainWETH#RelayETH chain.id=902 from=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 to=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 amount=10,000,000,000,000,000,000 source=901

4. Check the balance on chain 902

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

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

Viem to send and relay interop messages

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";
const SUPERCHAIN_TOKEN_BRIDGE_ADDRESS = "0x4200000000000000000000000000000000000028";

// 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: supersimL2B,
	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: SUPERCHAIN_TOKEN_BRIDGE_ADDRESS,
	abi: parseAbi([
		"function sendERC20(address _token, address _to, uint256 _amount, uint256 _chainId)",
	]),
	functionName: "sendERC20",
	args: [L2_NATIVE_SUPERCHAINERC20_ADDRESS, 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: SUPERCHAIN_TOKEN_BRIDGE_ADDRESS,
	abi: parseAbi([
		"function sendERC20(address _token, address _to, uint256 _amount, uint256 _chainId)",
	]),
	functionName: "sendERC20",
	args: [L2_NATIVE_SUPERCHAINERC20_ADDRESS, 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}`);

cast commands to relay interop messages

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 0x4200000000000000000000000000000000000028 "sendERC20(address _token, address _to, uint256 _amount, uint256 _chainId)" 0x420beeF000000000000000000000000000000001 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. Retrieve 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 --gas-limit 200000 \
    "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