Overview Simulation Quick start API Examples Run locally
Getting started
Live simulation Quick start
v0.1.0  ·  op-interop-sdk

Cross-chain messaging
for the Optimism Superchain

A minimal TypeScript SDK for native interop between OP stack L2s. No bridge. No oracle. Three methods.

Quick start Full docs
$npm install op-interop-sdk viem
SDK

Everything in one class

One entry point, one dependency, zero configuration beyond a private key.

Three methods

The complete public API.

sendMessage relayMessage getMessageStatus

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.

Live simulation

Watch a cross-chain message in action

Step through the complete protocol flow — from encoding to relay confirmation — with realistic values.

Cross-chain message simulation
OP Sepolia
chainId: 11155420
Message encoded
encodeFunctionData({
  functionName: 'setGreeting',
  args: ['Hello from OP Sepolia']
})
sendMessage() submitted
txHash: 0x3b4a...21f8
SentMessage event emitted
block: 7841293   logIndex: 0
msgHash: 0x5a31...f97
nonce: 42
→
SentMessage
log
→
CrossL2Inbox
slot populated
↺
relay tx
confirmed
Zora Sepolia
chainId: 999999999
CrossL2Inbox slot populated
slot: 0x03bc...a9f1
value: 0x67b3a8c0 (timestamp)
relayMessage() submitted
txHash: 0xa644...58f2
accessList: CrossL2Inbox slot included
Status
relayed  ·  successfulMessages[msgHash] = true
Ready. Press Run simulation to start.
Quick start

Send your first cross-chain message

Four steps from install to confirmed relay.

1. Install

bash
npm install op-interop-sdk viem

2. Create a messenger

TypeScript
import { InteropMessenger, supersimL2A, supersimL2B } from 'op-interop-sdk'

const messenger = new InteropMessenger({
  privateKey: process.env.PRIVATE_KEY as `0x${string}`,
})

3. Send a message

TypeScript
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

TypeScript
await messenger.relayMessage({
  identifier:  result.identifier,
  sourceChain: supersimL2A,
  destChain:   supersimL2B,
})

const status = await messenger.getMessageStatus({
  msgHash:   result.msgHash,
  destChain: supersimL2B,
})
// → 'relayed'
Concepts

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:

ContractAddressRole
L2ToL2CrossDomainMessenger0x4200...0023Sends and relays messages. Maintains successfulMessages.
CrossL2Inbox0x4200...0022Validates 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.

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

API Reference

InteropMessenger

One class, three methods. Pass a private key — the SDK handles everything else.

TypeScript
import { InteropMessenger } from 'op-interop-sdk'

const messenger = new InteropMessenger({ privateKey: '0x...' })

sendMessage(params)

ParamTypeDescription
sourceChainChainThe chain to send from
destChainChainThe 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)

ParamTypeDescription
identifierMessageIdentifierFrom sendMessage result
sourceChainChainSource chain (to fetch the log)
destChainChainDestination chain (to submit relay tx)

Returns: { txHash, receipt }

getMessageStatus(params)

ParamTypeDescription
msgHash`0x${string}`From sendMessage result
destChainChainDestination chain to query

Returns: 'pending' | 'relayed'

Chains

ExportChainChain ID
supersimL2ASupersim L2A (local)901
supersimL2BSupersim L2B (local)902
optimismSepoliaOP Sepolia11155420
zoraSepoliaZora Sepolia999999999
modeSepoliaMode Sepolia919
unichainSepoliaUnichain Sepolia1301
optimismMainnetOP Mainnet10
Examples

Five examples, every pattern covered

Clone op-interop-examples to run them locally.

Basic cross-chain message

Send, relay, and confirm in ~20 lines. Testnet.

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'

Local test with Supersim

Full flow at zero cost. Requires supersim --interop.autorelay.

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'

Cross-chain Greeter

Deploy a Solidity contract on Zora Sepolia and update its state from OP Sepolia.

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

Status polling

Wait for relay confirmation with timeout. Use in apps that react to cross-chain state.

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
}

Error handling

The three most common revert reasons and how to handle each.

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.
Run locally

From zero to relayed in five steps

Start with example 02 — no tokens, no testnet, instant feedback.

Step 1

Clone the examples repo

All five examples, shared config, and a ready-to-use .env.example.

git clone https://github.com/benbaruka/op-interop-examples
Step 2

Install and configure

One install. Copy .env.example to .env and set your private key.

npm install && cp .env.example .env
Step 3

Start Supersim

Two local L2s with 10,000 ETH per account. No real tokens needed.

supersim --interop.autorelay
Step 4

Run the local example

Sends L2A → L2B and prints status: relayed.

npm run example:02
Step 5

Move to testnet

Get Sepolia ETH from the faucet, set PRIVATE_KEY, run example 01.

npm run example:01