Relay transactions manually
Overview
Learn to relay transactions directly by sending the correct transaction.
About this tutorial
Prerequisite technical knowledge
- Familiarity with blockchain concepts
- Familiarity with Foundry (opens in a new tab), especially
cast
What you'll learn
- How to use
cast
to relay transactions when autorelay does not work
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
- A script to relay messages without using the JavaScript library (opens in a new tab)
Setup
Run Supersim
This exercise needs to be done on Supersim. You cannot use the devnets because you cannot disable autorelay on them.
-
Follow the installation guide.
-
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 manual-relay
cd manual-relay
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
cat > src/Greeter.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/interfaces/L2/IL2ToL2CrossDomainMessenger.sol";
contract Greeter {
IL2ToL2CrossDomainMessenger public immutable messenger =
IL2ToL2CrossDomainMessenger(Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER);
string greeting;
event SetGreeting(
address indexed sender, // msg.sender
string greeting
);
event CrossDomainSetGreeting(
address indexed sender, // Sender on the other side
uint256 indexed chainId, // ChainID of the other side
string greeting
);
function greet() public view returns (string memory) {
return greeting;
}
function setGreeting(string memory _greeting) public {
greeting = _greeting;
emit SetGreeting(msg.sender, _greeting);
if (msg.sender == Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER) {
(address sender, uint256 chainId) =
messenger.crossDomainMessageContext();
emit CrossDomainSetGreeting(sender, chainId, _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/interfaces/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
cd lib
npm install @eth-optimism/contracts-bedrock
cd ..
echo @eth-optimism/=lib/node_modules/@eth-optimism/ >> remappings.txt
mkdir -p lib/node_modules/@eth-optimism/contracts-bedrock/interfaces/L2
wget https://raw.githubusercontent.com/ethereum-optimism/optimism/refs/heads/develop/packages/contracts-bedrock/interfaces/L2/IL2ToL2CrossDomainMessenger.sol
mv IL2ToL2CrossDomainMessenger.sol lib/node_modules/@eth-optimism/contracts-bedrock/interfaces/L2
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
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
Manually relay a message using cast
Run this script:
./manual-relay/sendAndRelay.sh
Explanation
#! /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 the same 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
- Review the Superchain interop explainer for answers to common questions about interoperability.
- Read the message passing explainer to understand what happens "under the hood".
- Write a revolutionary app that uses multiple blockchains within the Superchain.