Interoperability
Relay transactions manually

Relay transactions manually

Interop is currently in active development and not yet ready for production use. The information provided here may change frequently. We recommend checking back regularly for the most up-to-date information.
💡
Normally we expect Superchain blockchains to run an autorelayer and relay your messages automatically. However, for performance reasons or reliability, you might decide to submit the executing message manually. See below to learn how to do that.

Overview

Learn to relay transactions directly by sending the correct transaction.

About this tutorial

Prerequisite technical knowledge

What you'll learn

  • How to use cast to relay transactions when autorelay does not work
  • How to relay transactions using JavaScript

Development environment requirements

  • Unix-like operating system (Linux, macOS, or WSL for Windows)
  • Node.js version 16 or higher
  • Git for version control
  • Supersim environment configured and running
  • Foundry tools installed (forge, cast, anvil)

What you'll build

Setup

These steps are necessary to run the tutorial, regardless of whether you are using cast or the JavaScript API.

Run Supersim

This exercise needs to be done on Supersim. You cannot use the devnets because you cannot disable autorelay on them.

  1. Follow the installation guide.

  2. Run Supersim without --interop.relay.

    ./supersim

Create the state for relaying messages

The results of this step are similar to what the message passing tutorial would produce if you did not have autorelay on.

Execute this script.

#! /bin/sh
 
rm -rf manual-relay
mkdir -p manual-relay/onchain
cd manual-relay/onchain
 
PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
USER_ADDRESS=`cast wallet address --private-key $PRIVATE_KEY`
URL_CHAIN_A=http://localhost:9545
URL_CHAIN_B=http://localhost:9546
 
forge init
cd lib
npm install @eth-optimism/contracts-bedrock
cd ..
echo @eth-optimism/=lib/node_modules/@eth-optimism/ >> remappings.txt
 
cat > src/Greeter.sol <<EOF
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
 
contract Greeter {
    string greeting;
 
    event SetGreeting(
        address indexed sender,     // msg.sender
        string greeting
    );
 
    function greet() public view returns (string memory) {
        return greeting;
    }
 
    function setGreeting(string memory _greeting) public {
        greeting = _greeting;
        emit SetGreeting(msg.sender, _greeting);
    }
}
EOF
 
cat > src/GreetingSender.sol <<EOF
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
 
import { Predeploys } from "@eth-optimism/contracts-bedrock/src/libraries/Predeploys.sol";
import { IL2ToL2CrossDomainMessenger } from "@eth-optimism/contracts-bedrock/src/L2/IL2ToL2CrossDomainMessenger.sol";
 
import { Greeter } from "src/Greeter.sol";
 
contract GreetingSender {
    IL2ToL2CrossDomainMessenger public immutable messenger =
        IL2ToL2CrossDomainMessenger(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER);
 
    address immutable greeterAddress;
    uint256 immutable greeterChainId;
 
    constructor(address _greeterAddress, uint256 _greeterChainId) {
        greeterAddress = _greeterAddress;
        greeterChainId = _greeterChainId;
    }
 
    function setGreeting(string calldata greeting) public {
        bytes memory message = abi.encodeCall(
            Greeter.setGreeting,
            (greeting)
        );
        messenger.sendMessage(greeterChainId, greeterAddress, message);
    }
}
EOF
 
CHAIN_ID_B=`cast chain-id --rpc-url $URL_CHAIN_B`
GREETER_B_ADDRESS=`forge create --rpc-url $URL_CHAIN_B --private-key $PRIVATE_KEY Greeter --broadcast | awk '/Deployed to:/ {print $3}'`
GREETER_A_ADDRESS=`forge create --rpc-url $URL_CHAIN_A --private-key $PRIVATE_KEY --broadcast GreetingSender --constructor-args $GREETER_B_ADDRESS $CHAIN_ID_B | awk '/Deployed to:/ {print $3}'`
 
echo Setup done
 
cd ..
 
cat > sendAndRelay.sh <<EOF
#! /bin/sh
PRIVATE_KEY=$PRIVATE_KEY
USER_ADDRESS=$USER_ADDRESS
URL_CHAIN_A=$URL_CHAIN_A
URL_CHAIN_B=$URL_CHAIN_B
GREETER_A_ADDRESS=$GREETER_A_ADDRESS
GREETER_B_ADDRESS=$GREETER_B_ADDRESS
CHAIN_ID_B=$CHAIN_ID_B
 
cast send -q --private-key \$PRIVATE_KEY --rpc-url \$URL_CHAIN_A \$GREETER_A_ADDRESS "setGreeting(string)" "Hello from chain A \$$"
 
cast logs "SentMessage(uint256,address,uint256,address,bytes)" --rpc-url \$URL_CHAIN_A | tail -14 > log-entry
TOPICS=\`cat log-entry | grep -A4 topics | awk '{print \$1}' | tail -4 | sed 's/0x//'\`
TOPICS=\`echo \$TOPICS | sed 's/ //g'\`
 
ORIGIN=0x4200000000000000000000000000000000000023
BLOCK_NUMBER=\`cat log-entry | awk '/blockNumber/ {print \$2}'\`
LOG_INDEX=\`cat log-entry | awk '/logIndex/ {print \$2}'\`
TIMESTAMP=\`cast block \$BLOCK_NUMBER --rpc-url \$URL_CHAIN_A | awk '/timestamp/ {print \$2}'\`
CHAIN_ID_A=\`cast chain-id --rpc-url \$URL_CHAIN_A\`
SENT_MESSAGE=\`cat log-entry | awk '/data/ {print \$2}'\`
LOG_ENTRY=0x\`echo \$TOPICS\$SENT_MESSAGE | sed 's/0x//'\`
 
RPC_PARAMS=\$(cat <<INNER_END_OF_FILE
{
    "origin": "\$ORIGIN",
    "blockNumber": "\$BLOCK_NUMBER",
    "logIndex": "\$LOG_INDEX",
    "timestamp": "\$TIMESTAMP",
    "chainId": "\$CHAIN_ID_A",
    "payload": "\$LOG_ENTRY"
}
INNER_END_OF_FILE
)
 
ACCESS_LIST=\`cast rpc admin_getAccessListForIdentifier --rpc-url http://localhost:8420 "\$RPC_PARAMS" | jq .accessList\`
 
echo Old greeting
cast call \$GREETER_B_ADDRESS "greet()(string)" --rpc-url \$URL_CHAIN_B
 
cast send -q \$ORIGIN "relayMessage((address,uint256,uint256,uint256,uint256),bytes)" "(\$ORIGIN,\$BLOCK_NUMBER,\$LOG_INDEX,\$TIMESTAMP,\$CHAIN_ID_A)" \$LOG_ENTRY --access-list "\$ACCESS_LIST" --rpc-url \$URL_CHAIN_B --private-key \$PRIVATE_KEY
 
echo New greeting
cast call \$GREETER_B_ADDRESS "greet()(string)" --rpc-url \$URL_CHAIN_B
 
EOF
 
chmod +x sendAndRelay.sh
 
echo Set these environment variables
echo GREETER_A_ADDRESS=$GREETER_A_ADDRESS
echo GREETER_B_ADDRESS=$GREETER_B_ADDRESS
echo PRIVATE_KEY=$PRIVATE_KEY

Manual relay using the API

Setup

We are going to use a Node (opens in a new tab) project.

  1. Initialize a new Node project.

    mkdir -p manual-relay/offchain
    cd manual-relay/offchain
    npm init -y
    npm install --save-dev -y viem @eth-optimism/viem
    mkdir src
  2. Export environment variables. This is necessary because those variables are currently limited to the shell process. We need them in the Node process that the shell creates.

    export GREETER_A_ADDRESS GREETER_B_ADDRESS PRIVATE_KEY

Manual relaying app

  1. Create a file manual-relay.mjs with:

    import {
        createWalletClient,
        http,
        publicActions,
        getContract,
    } from 'viem'
    import { privateKeyToAccount } from 'viem/accounts'
    import { supersimL2A, supersimL2B } from '@eth-optimism/viem/chains' 
    import { walletActionsL2, publicActionsL2 } from '@eth-optimism/viem'
     
    import { readFileSync } from 'fs';
     
    const greeterData = JSON.parse(readFileSync('../onchain/out/Greeter.sol/Greeter.json'))
    const greetingSenderData = JSON.parse(readFileSync('../onchain/out/Greeter.sol/Greeter.json'))
     
    const account = privateKeyToAccount(process.env.PRIVATE_KEY)
     
    const walletA = createWalletClient({
        chain: supersimL2A,
        transport: http(),
        account
    }).extend(publicActions)
        .extend(publicActionsL2())
    //    .extend(walletActionsL2())
     
    const walletB = createWalletClient({
        chain: supersimL2B,
        transport: http(),
        account
    }).extend(publicActions)
    //    .extend(publicActionsL2())
        .extend(walletActionsL2())
     
    const greeter = getContract({
        address: process.env.GREETER_B_ADDRESS,
        abi: greeterData.abi,
        client: walletB
    })
     
    const greetingSender = getContract({
        address: process.env.GREETER_A_ADDRESS,
        abi: greetingSenderData.abi,
        client: walletA
    })
     
    const txnBHash = await greeter.write.setGreeting(
        ["Greeting directly to chain B"])
    await walletB.waitForTransactionReceipt({hash: txnBHash})
     
    const greeting1 = await greeter.read.greet()
    console.log(`Chain B Greeting: ${greeting1}`)
     
    const txnAHash = await greetingSender.write.setGreeting(
        ["Greeting through chain A"])
    const receiptA = await walletA.waitForTransactionReceipt({hash: txnAHash})
     
    const greeting2 = await greeter.read.greet()
    console.log(`Greeting before the relay transaction: ${greeting2}`)
     
    const sentMessages = await walletA.interop.getCrossDomainMessages({
      logs: receiptA.logs,
    })
    const sentMessage = sentMessages[0] // We only sent 1 message
    const relayMessageParams = await walletA.interop.buildExecutingMessage({
      log: sentMessage.log,
    })
    const relayMsgTxnHash = await walletB.interop.relayCrossDomainMessage(relayMessageParams)
     
    const receiptRelay = await walletB.waitForTransactionReceipt({
      hash: relayMsgTxnHash,
    })
     
    const greeting3 = await greeter.read.greet()
    console.log(`Greeting after the relay transaction: ${greeting3}`)
    Explanation
    import { supersimL2A, supersimL2B } from '@eth-optimism/viem/chains' 
    import { walletActionsL2, publicActionsL2 } from '@eth-optimism/viem'

    Import from the @eth-optimism/viem (opens in a new tab) package.

    const walletA = createWalletClient({
        chain: supersimL2A,
        transport: http(),
        account
    }).extend(publicActions)
        .extend(publicActionsL2())
    //    .extend(walletActionsL2())
     
    const walletB = createWalletClient({
        chain: supersimL2B,
        transport: http(),
        account
    }).extend(publicActions)
    //    .extend(publicActionsL2())
        .extend(walletActionsL2())

    In addition to extending the wallets with Viem public actions (opens in a new tab), extend with the OP-Stack actions. On wallet A we need the public actions, those that only read information. On wallet B we need the wallet actions, the ones that require an account.

    const receiptA = await walletA.waitForTransactionReceipt({hash: txnAHash})

    To relay a message we need the information in the receipt. Also, we need to wait until the transaction with the relayed message is actually part of a block.

    const greeting2 = await greeter.read.greet()
    console.log(`Greeting before the relay transaction: ${greeting2}`)

    Show the user that until the relay transaction happens on chain B, the greeting is unchanged.

    const sentMessages = await walletA.interop.getCrossDomainMessages({
      logs: receiptA.logs,
    })
    const sentMessage = sentMessages[0] // We only sent 1 message

    A single transaction can send multiple messages. But here we know we sent just one, so we look for the first one in the list.

    const relayMessageParams = await walletA.interop.buildExecutingMessage({
      log: sentMessage.log,
    })
    const relayMsgTxnHash = await walletB.interop.relayCrossDomainMessage(relayMessageParams)
     
    const receiptRelay = await walletB.waitForTransactionReceipt({
      hash: relayMsgTxnHash,
    })

    Here we first send the relay message on chain B, and then wait for the receipt for it.

  2. Run JavaScript program, and see that the message is relayed.

    node manual-relay.mjs

Debugging

To see what messages were relayed by a specific transaction you can use this code:

import { decodeRelayedL2ToL2Messages } from '@eth-optimism/viem'
 
const decodedRelays = decodeRelayedL2ToL2Messages(
    {receipt: receiptRelay})
 
console.log(decodedRelays)
console.log(decodedRelays.successfulMessages[0].log)

Manual relay using cast

Run this` script:

./manual-relay/sendAndRelay.sh

What does the script do?

#! /bin/sh
PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
USER_ADDRESS=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
URL_CHAIN_A=http://localhost:9545
URL_CHAIN_B=http://localhost:9546
GREETER_A_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3
GREETER_B_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3
CHAIN_ID_B=902

This is the configuration. The greeter addresses are identical because the nonce for the user address has an identical nonce on both chains.

cast send -q --private-key $PRIVATE_KEY --rpc-url $URL_CHAIN_A $GREETER_A_ADDRESS "setGreeting(string)" "Hello from chain A $$"

Send a message from chain A to chain B. The $$ is the process ID, so if you rerun the script you'll see that the information changes.

cast logs "SentMessage(uint256,address,uint256,address,bytes)" --rpc-url $URL_CHAIN_A | tail -14 > log-entry

Whenever L2ToL2CrossDomainMessenger sends a message to a different blockchain, it emits a SendMessage (opens in a new tab) event. Extract only the latest SendMessage event from the logs.

Example log-entry
- address: 0x4200000000000000000000000000000000000023
  blockHash: 0xcd0be97ffb41694faf3a172ac612a23f224afc1bfecd7cb737a7a464cf5d133e
  blockNumber: 426
  data: 0x0000000000000000000000005fbdb2315678afecb367f032d93f642f64180aa300000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000064a41368620000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001948656c6c6f2066726f6d20636861696e2041203131333030370000000000000000000000000000000000000000000000000000000000000000000000
  logIndex: 0
  removed: false
  topics: [
          0x382409ac69001e11931a28435afef442cbfd20d9891907e8fa373ba7d351f320
          0x0000000000000000000000000000000000000000000000000000000000000386
          0x0000000000000000000000005fbdb2315678afecb367f032d93f642f64180aa3
          0x0000000000000000000000000000000000000000000000000000000000000000
  ]
  transactionHash: 0x1d6f2e5e2c8f3eb055e95741380ca36492f784b9782848b66b66c65c5937ff3a
  transactionIndex: 0
TOPICS=`cat log-entry | grep -A4 topics | awk '{print $1}' | tail -4 | sed 's/0x//'`
TOPICS=`echo $TOPICS | sed 's/ //g'`

Consolidate the log topics into a single hex string.

ORIGIN=0x4200000000000000000000000000000000000023
BLOCK_NUMBER=`cat log-entry | awk '/blockNumber/ {print $2}'`
LOG_INDEX=`cat log-entry | awk '/logIndex/ {print $2}'`
TIMESTAMP=`cast block $BLOCK_NUMBER --rpc-url $URL_CHAIN_A | awk '/timestamp/ {print $2}'`
CHAIN_ID_A=`cast chain-id --rpc-url $URL_CHAIN_A`
SENT_MESSAGE=`cat log-entry | awk '/data/ {print $2}'`

Read additional fields from the log entry.

LOG_ENTRY=0x`echo $TOPICS$SENT_MESSAGE | sed 's/0x//'`

Consolidate the entire log entry.

RPC_PARAMS=$(cat <<INNER_END_OF_FILE
{
    "origin": "$ORIGIN",
    "blockNumber": "$BLOCK_NUMBER",
    "logIndex": "$LOG_INDEX",
    "timestamp": "$TIMESTAMP",
    "chainId": "$CHAIN_ID_A",
    "payload": "$LOG_ENTRY"
}
INNER_END_OF_FILE
)
 
ACCESS_LIST=`cast rpc admin_getAccessListForIdentifier --rpc-url http://localhost:8420 "$RPC_PARAMS" | jq .accessList`

To secure cross-chain messaging and prevent potential denial-of-service attacks (opens in a new tab), relay transactions require properly formatted access lists that include a checksum derived from the message data. This lets sequencers know what executing messages to expect in a transaction, which makes it easy not to include transactions that are invalid because they rely on messages that were never sent.

The algorithm to calculate the access list (opens in a new tab) is a bit complicated, but you don't need to worry about it. Supersim exposes RPC calls (opens in a new tab) that calculates it for you on port 8420. The code above will calculate the correct access list even if you're using a different interop cluster where autorelay is not functioning. This is because the code implements a pure function (opens in a new tab), which produces consistent results regardless of external state. In contrast, the admin_getAccessListByMsgHash RPC call is not a pure function, it is dependent on system state and therefore less flexible in these situations.

echo Old greeting
cast call $GREETER_B_ADDRESS "greet()(string)" --rpc-url $URL_CHAIN_B

Show the current greeting. The message has not been relayed yet, so it's still the old greeting.

cast send -q $ORIGIN "relayMessage((address,uint256,uint256,uint256,uint256),bytes)" "($ORIGIN,$BLOCK_NUMBER,$LOG_INDEX,$TIMESTAMP,$CHAIN_ID_A)" $LOG_ENTRY --access-list "$ACCESS_LIST" --rpc-url $URL_CHAIN_B --private-key $PRIVATE_KEY

Call relayMessage (opens in a new tab) to relay the message.

echo New greeting
cast call $GREETER_B_ADDRESS "greet()(string)" --rpc-url $URL_CHAIN_B

Again, show the current greeting. Now it's the new one.

Next steps