End-to-End Multichain Testing with Relayer.sol

End-to-End Multichain Testing with Relayer.sol

Relayer.sol brings first class multichain end-to-end testing to your Forge testing setup. By inheriting from the abstract Relayer test helper, your tests can spin up forks of multiple networks, emit real L2ToL2CrossDomainMessenger events, and relay them inside the same forge test process — no external relayer or scripting glue required. In this post you’ll learn how to:

  • Wire up forked RPC URLs from running Supersim endpoints
  • Deploy and exercise contracts on several chains
  • Relay every message (or just a subset) with a single call
  • Assert state changes on the destination chain just like any other unit test

Superchain Interop promises to change the paradigms of decentralized application development. This exciting improvement will allow for low-latency, seamless message-passing and asset bridging between chains in the Superchain interop set. As you seek to build for this future, you’ll need to level up your crosschain testing workflow.

Why you need a relayer in your tests

The Superchain’s interop workflow is event-driven: a contract calls sendMessage() on the L2ToL2CrossDomainMessenger, the messenger emits SentMessage, and an offchain relayer picks that event up and submits an executing transaction to the destination chain - only then does the payload run.

Relayer.sol embeds that relayer logic directly in Forge, eliminating flaky sleeps or manual cast commands you’d otherwise try in local CI runs.

Relayer.sol turns cross-chain testing from “run two disjointed test suites and hope the bridge works” to “prove, inside Forge, that the message really executes on chain B.” It removes external dependencies, cuts boilerplate, and brings your test environment closer to mainnet reality.

Relayer.sol significantly simplifies crosschain testing flows

Project Setup

1. Add the interop lib

forge install ethereum-optimism/interop-lib

Relayer.sol lives under src/test/Relayer.sol in the repo https://github.com/ethereum-optimism/interop-lib.

2. Launch Supersim and point Foundry at it

Install Supersim

brew install ethereum-optimism/tap/supersim      # macOS/Linux

Start a vanilla Superchain

supersim

Supersim boots three local anvil nodes, pre-deploys the Superchain interop contracts, and exposes JSON-RPC endpoints:

Chain ID RPC URL
L1(mainnet) 900 http://127.0.0.1:8545
L2-A 901 http://127.0.0.1:9545
L2-B 902 http://127.0.0.1:9546

For more information about Supersim and the various ways you can customize your local development workflow, check out the Supersim documentation.

Tell Foundry about these RPCs in foundry.toml

[rpc_endpoints]
l2a = "<http://127.0.0.1:9545>"
l2b = "<http://127.0.0.1:9546>"

Need devnet? Skip Supersim if you’d like to try out the interop devnet, or graduate your project from local development to devnet by changing your foundry.toml to point to these devnet endpoints (see documentation for the most up to date endpoints):

[rpc_endpoints]
devnet0 = "<https://interop-alpha-0.optimism.io>"
devnet1 = "<https://interop-alpha-1.optimism.io>"

Writing a Test That Crosses Chains

Below is a stripped-down version of the reference CrossChainIncrementer.t.sol test:

contract IncrementerTest is Relayer {
    /**
     * 0. Constructor – pass Supersim RPC URLs to Relayer so it can map
     *    chainIds ↔ forkIds under the hood.
     */
    constructor() Relayer(_rpcUrls()) {}

    function _rpcUrls() internal view returns (string[] memory urls) {
        urls = new string[](2);
        urls[0] = vm.rpcUrl("l2a"); // source
        urls[1] = vm.rpcUrl("l2b"); // destination
    }
    
    // 1. Fork identifiers
    uint256 l2aFork;
    uint256 l2bFork;
    
    // ──────────────── 2. Contract handles ────────────────
    Counter src;              // lives on l2a fork
    Counter dst;              // lives on l2b fork

    function setUp() public {
        // Foundry forks
        l2aFork = vm.createFork(vm.rpcUrl("l2a"));
        l2bFork = vm.createFork(vm.rpcUrl("l2b"));

        // 2. Deploy contracts on each chain
        vm.selectFork(l2aFork);
        src = new Counter();

        vm.selectFork(l2bFork);
        dst = new Counter();
    }

    function testIncrementAcrossChains() public {
        // 3. Build a message on the source chain
        vm.selectFork(l2aFork);
        L2ToL2CrossDomainMessenger(payable(CDM_ADDR)).sendMessage(
            address(dst),
            abi.encodeCall(dst.increment, ()),
            100_000
        );

        // 4. Relay everything that was just logged
        relayAllMessages();

        // 5. Assert on the destination chain
        vm.selectFork(l2bFork);
        assertEq(dst.count(), 1);
    }
}

Let’s unpack what’s happening here.

Step 1: Forking networks

vm.createFork clones remote state; vm.selectFork switches the active fork. Keep the returned IDs. You’ll need them whenever you jump between chains.

Step 2: Recording logs automatically

The Relayer constructor call vm.recordLogs() means every emitted event is captured. You don’t need to sprinkle this in yourself; just inherit the helper!

Step 3: Emitting an interop message

L2ToL2CrossDomainMessenger.sendMessage gives you replay protection and domain binding out of the box. Use it exactly as you will onchain.

Step 4: Relaying inside the test

  • relayAllMessages() pulls the buffer via vm.getRecordedLogs() , filters for SentMessage, and re-executes each on the correct destination fork.
  • Need more control? Pass a slice of vm.log[] to relayMessages() and decide which events get relayed.

Step 5: Asserting State

Once the relay is done, swap to the destination fork (vm.selectFork("l2a") again) and assert like any other unit test. Because everything ran synchronously in-process there are no race conditions or polling loops.

💡
To see this test pattern in action, take a look here!

Advanced Patterns

Granular Relaying

Sometimes you only want to relay a subset of events. Because the SentMessage log does not embed the origin chain, you must pass the sourceChainId that produced those logs.

Vm.Log[] memory logs = vm.getRecordedLogs();
relayMessages(slice(logs, 1, 3), 901); // only the second and third messages

This functionality is particularly useful when you need to cache the recorded logs for something beyond relaying. Because vm.getRecordedLogs() consumes the buffer, you can grab it once, store it, run custom assertions/decoding/fuzzing on the raw events, and then feed the same slice (or a filtered sub-slice) into relayMessages(). This lets you relay the items you care about, re-relay the same message to test replay protection paths, or keep the logs around for coverage metrics - all without losing data or requiring an extra onchain emit.

Promise testing (experimental)

The interop library also exposes a Promise primitive to guarantee delivery semantics; early test suites live in Promise.t.sol. Expect the helper to gain first-class Promise utilities soon!

Common Pitfalls

  • Missing predeploys: running against a local node without the messenger will revert. Stick to Supersim and the devnets!
  • Log buffer consumption: vm.getRecordedLogs() consumes the buffer each call. Cache it if you need multiple passes.

Putting it all together

With less than 40 lines of boilerplate, you now have realistic, deterministic multichain tests that run in seconds:

supersim &.         # one-time startup
forge test -vvvv.   # everything relays locally 

Under the hood, Forge:

  1. Forks both L2s on supersim
  2. Executes your source chain transaction
  3. Replays the log on the destination fork
  4. Asserts post-conditions

…All without leaving the EVM or depending on external infrastructure. That’s the power of Relayer.sol. Give it a spin, break some messages, and watch your crosschain logic harden before it ever hits production. Happy testing!

Further Reading

In crypto, and in OP Labs, we have a job like no other. The stakes are immense and so is the complexity of what we are trying to do. However, sometimes, simple processes can make all the difference and allow us to reach further.

At OP Labs, we are hiring individuals ready to work at the bleeding edge of technology and security. If you want to work on a world-class team on world-class projects, get in touch!