Cross-chain messaging
for the Optimism Superchain
A minimal TypeScript SDK for native interop between OP stack L2s. No bridge. No oracle. Three methods.
Everything in one class
One entry point, one dependency, zero configuration beyond a private key.
Three methods
The complete public API.
Native protocol
Built directly on L2ToL2CrossDomainMessenger and CrossL2Inbox. No bridge, no oracle, no multisig.
Supersim ready
Test locally with no real ETH. supersimL2A and supersimL2B are built in.
viem only
One runtime dependency. No ethers.js, no Optimism SDK, no hidden transitive packages.
Watch a cross-chain message in action
Step through the complete protocol flow — from encoding to relay confirmation — with realistic values.
functionName: 'setGreeting',
args: ['Hello from OP Sepolia']
})
msgHash: 0x5a31...f97
nonce: 42
log
slot populated
confirmed
value: 0x67b3a8c0 (timestamp)
accessList: CrossL2Inbox slot included
Send your first cross-chain message
Four steps from install to confirmed relay.
1. Install
npm install op-interop-sdk viem
2. Create a messenger
import { InteropMessenger, supersimL2A, supersimL2B } from 'op-interop-sdk' const messenger = new InteropMessenger({ privateKey: process.env.PRIVATE_KEY as `0x${string}`, })
3. Send a message
import { encodeFunctionData } from 'viem' const result = await messenger.sendMessage({ sourceChain: supersimL2A, // OP Sepolia, Zora Sepolia, etc. destChain: supersimL2B, target: '0xYourContractOnL2B', message: encodeFunctionData({ abi, functionName: 'myFunc', args }), }) // result.msgHash → use with getMessageStatus // result.identifier → use with relayMessage
4. Relay and confirm
await messenger.relayMessage({ identifier: result.identifier, sourceChain: supersimL2A, destChain: supersimL2B, }) const status = await messenger.getMessageStatus({ msgHash: result.msgHash, destChain: supersimL2B, }) // → 'relayed'
How it works
The protocol moves a message in three on-chain steps, with the L2 node handling propagation between them.
The two predeploys
Every OP stack L2 has two predeploys at fixed addresses that power interop:
| Contract | Address | Role |
|---|---|---|
L2ToL2CrossDomainMessenger | 0x4200...0023 | Sends and relays messages. Maintains successfulMessages. |
CrossL2Inbox | 0x4200...0022 | Validates cross-chain message proofs via storage slots. |
The two hashes — the most important detail
The protocol uses two distinct hashes derived from the same message. Confusing them causes silent reverts.
| msgHash | sentMsgHash | |
|---|---|---|
| Formula | keccak256(abi.encode(dest, source, nonce, sender, target, msg)) |
keccak256(concat(topic0, topic1, topic2, topic3, log.data)) |
| Role | Relay status tracking | Cryptographic proof of log existence |
| Used by | successfulMessages, getMessageStatus |
CrossL2Inbox.validateMessage, slot calculation |
| SDK | Returned by sendMessage as result.msgHash |
Computed internally inside relayMessage |
The SDK computes both internally — you never need to handle sentMsgHash directly.
InteropMessenger
One class, three methods. Pass a private key — the SDK handles everything else.
import { InteropMessenger } from 'op-interop-sdk' const messenger = new InteropMessenger({ privateKey: '0x...' })
sendMessage(params)
| Param | Type | Description |
|---|---|---|
sourceChain | Chain | The chain to send from |
destChain | Chain | The chain to send to |
target | `0x${string}` | Contract to call on destChain |
message | `0x${string}` | ABI-encoded calldata for target |
Returns: { txHash, receipt, msgHash, messageNonce, identifier }
relayMessage(params)
| Param | Type | Description |
|---|---|---|
identifier | MessageIdentifier | From sendMessage result |
sourceChain | Chain | Source chain (to fetch the log) |
destChain | Chain | Destination chain (to submit relay tx) |
Returns: { txHash, receipt }
getMessageStatus(params)
| Param | Type | Description |
|---|---|---|
msgHash | `0x${string}` | From sendMessage result |
destChain | Chain | Destination chain to query |
Returns: 'pending' | 'relayed'
Chains
| Export | Chain | Chain ID |
|---|---|---|
supersimL2A | Supersim L2A (local) | 901 |
supersimL2B | Supersim L2B (local) | 902 |
optimismSepolia | OP Sepolia | 11155420 |
zoraSepolia | Zora Sepolia | 999999999 |
modeSepolia | Mode Sepolia | 919 |
unichainSepolia | Unichain Sepolia | 1301 |
optimismMainnet | OP Mainnet | 10 |
Five examples, every pattern covered
Clone op-interop-examples to run them locally.
import 'dotenv/config' import { encodeFunctionData } from 'viem' import { messenger, chains } from '../../shared/config.js' const message = encodeFunctionData({ abi: [{ name: 'receiveMessage', type: 'function', inputs: [{ type: 'string' }], outputs: [] }], functionName: 'receiveMessage', args: ['Hello from OP Sepolia'], }) const result = await messenger.sendMessage({ sourceChain: chains.source, // optimismSepolia destChain: chains.dest, // zoraSepolia target: process.env.TARGET_ADDRESS as `0x${string}`, message, }) console.log('msgHash:', result.msgHash) await messenger.relayMessage({ identifier: result.identifier, sourceChain: chains.source, destChain: chains.dest, }) const status = await messenger.getMessageStatus({ msgHash: result.msgHash, destChain: chains.dest, }) console.log('status:', status) // -> 'relayed'
import 'dotenv/config' import { encodeFunctionData } from 'viem' import { InteropMessenger, supersimL2A, supersimL2B } from 'op-interop-sdk' // Start Supersim: supersim --interop.autorelay // L2A http://localhost:9545 chainId 901 // L2B http://localhost:9546 chainId 902 const messenger = new InteropMessenger({ privateKey: process.env.PRIVATE_KEY as `0x${string}` }) const result = await messenger.sendMessage({ sourceChain: supersimL2A, destChain: supersimL2B, target: '0x0000000000000000000000000000000000000001', message: encodeFunctionData({ abi: [{ name: 'ping', type: 'function', inputs: [{ type: 'uint256' }], outputs: [] }], functionName: 'ping', args: [42n] }), }) // Supersim auto-relays — just check status after 2s await new Promise(r => setTimeout(r, 2000)) const status = await messenger.getMessageStatus({ msgHash: result.msgHash, destChain: supersimL2B }) console.log(status) // -> 'relayed'
// Greeter.sol — deploy on Zora Sepolia contract Greeter { address constant MESSENGER = 0x4200000000000000000000000000000000000023; string public greeting; function setGreeting(string calldata s) external { require(msg.sender == MESSENGER, "Only messenger"); greeting = s; } } // Then from OP Sepolia: const result = await messenger.sendMessage({ sourceChain: optimismSepolia, destChain: zoraSepolia, target: GREETER_ADDRESS, message: encodeFunctionData({ abi, functionName: 'setGreeting', args: ['Hello from OP Sepolia'] }), }) await messenger.relayMessage({ identifier: result.identifier, sourceChain: optimismSepolia, destChain: zoraSepolia }) // Verify on Zora Sepolia const greeting = await client.readContract({ address: GREETER_ADDRESS, abi, functionName: 'getGreeting' }) // -> "Hello from OP Sepolia"
async function waitForRelay( msgHash: `0x${string}`, destChain: Chain, timeoutMs = 120_000, intervalMs = 3_000, ): Promise<'relayed' | 'failed' | 'timeout'> { const start = Date.now() while (Date.now() - start < timeoutMs) { const status = await messenger.getMessageStatus({ msgHash, destChain }) if (status === 'relayed' || status === 'failed') return status await new Promise(r => setTimeout(r, intervalMs)) } return 'timeout' } const result = await waitForRelay(msgHash, zoraSepolia) if (result === 'relayed') { // safe to read destination chain state } else if (result === 'timeout') { // L2 node still propagating — retry later }
import { ContractFunctionRevertedError } from 'viem' // 1. NotInAccessList — slot not populated yet // Retry with backoff until slot is ready for (let i = 0; i < 10; i++) { try { await messenger.relayMessage(params); break } catch (err) { if (err instanceof ContractFunctionRevertedError && err.reason?.includes('NotInAccessList') && i < 9) { await new Promise(r => setTimeout(r, 5_000)) } else throw err } } // 2. MessageAlreadyRelayed — guard before relaying const status = await messenger.getMessageStatus({ msgHash, destChain }) if (status !== 'relayed') await messenger.relayMessage(params) // 3. TargetCallFailed — target contract reverted // Message stays 'pending', fix target logic and retry // Key rule: always pass result.msgHash to getMessageStatus, // and result.identifier to relayMessage. Never mix the two.
From zero to relayed in five steps
Start with example 02 — no tokens, no testnet, instant feedback.
Clone the examples repo
All five examples, shared config, and a ready-to-use .env.example.
Install and configure
One install. Copy .env.example to .env and set your private key.
Start Supersim
Two local L2s with 10,000 ETH per account. No real tokens needed.
supersim --interop.autorelayRun the local example
Sends L2A → L2B and prints status: relayed.
Move to testnet
Get Sepolia ETH from the faucet, set PRIVATE_KEY, run example 01.