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.

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 viavm.getRecordedLogs()
, filters forSentMessage
, and re-executes each on the correct destination fork.- Need more control? Pass a slice of
vm.log[]
torelayMessages()
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.
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:
- Forks both L2s on supersim
- Executes your source chain transaction
- Replays the log on the destination fork
- 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
- Interop message-passing overview Optimism Docs
- Manual
cast
relaying tutorial Optimism Docs - pyk’s multi-chain Forge guide Pyk.sh
- Full
recordLogs
/getRecordedLogs
cheatcodes Foundry Book - Superchain devnet tooling docs Optimism Docs
- Supersim docs & CLI reference Optimism Docs
- Supersim GitHub README GitHub
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!