Bridging native cross-chain ETH transfers
This tutorial provides step-by-step instructions for bridging ETH from one Superchain Interop chain to another. For a conceptual overview, see the interoperable ETH explainer.
Overview
Crosschain ETH transfers in the Superchain are facilitated through the SuperchainWETH (opens in a new tab) contract. This tutorial walks through how to send native ETH from one chain to another. You can do this on Supersim, the Interop devnet, or production once it is released.
What you'll build
- A TypeScript application to transfer ETH chains
What you'll learn
- How to send ETH on the blockchain and between blockchains
- How to relay messages between chains
Prerequisites
Before starting this tutorial, ensure your development environment meets the following requirements:
Technical knowledge
- Intermediate TypeScript knowledge
- Understanding of smart contract development
- Familiarity with blockchain concepts
Development environment
- Unix-like operating system (Linux, macOS, or WSL for Windows)
- Node.js version 16 or higher
- Git for version control
Required tools
The tutorial uses these primary tools:
- Foundry: For smart contract development
- Supersim: For local blockchain simulation
- TypeScript: For implementation
- Viem: For blockchain interaction
Install prerequisite software
- Install Foundry (opens in a new tab).
- Install Node (opens in a new tab).
- Install git (opens in a new tab). The exact mechanism to do this depends on your operating system; most come with it preinstalled.
Configure the network
You can run this tutorial either with Supersim running locally, or using the Interop devnet. Select the correct tab and follow the directions.
-
Follow the Installation Guide to install Supersim for running blockchains with Interop.
-
Start Supersim.
./supersim --interop.autorelay
-
Supersim uses Foundry's
anvil
blockchains, which start with ten prefunded accounts. Set these environment variables to access one of those accounts on the L2 blockchains.export PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
-
Specify the URLs to the chains.
SRC_URL=http://localhost:9545 DST_URL=http://localhost:9546
Sanity check
Get the ETH balances for your address on both the source and destination chains.
cast balance --ether `cast wallet address $PRIVATE_KEY` --rpc-url $SRC_URL
cast balance --ether `cast wallet address $PRIVATE_KEY` --rpc-url $DST_URL
Transfer ETH using Foundry
Run these commands:
DST_CHAINID=`cast chain-id --rpc-url $DST_URL`
MY_ADDRESS=`cast wallet address $PRIVATE_KEY`
SUPERCHAIN_WETH=0x4200000000000000000000000000000000000024
BEFORE=`cast balance $MY_ADDRESS --rpc-url $DST_URL | cast from-wei`
cast send --rpc-url $SRC_URL --private-key $PRIVATE_KEY $SUPERCHAIN_WETH "sendETH(address,uint256)" $MY_ADDRESS $DST_CHAINID --value 0.001ether
sleep 10
AFTER=`cast balance $MY_ADDRESS --rpc-url $DST_URL | cast from-wei`
echo -e Balance before transfer\\t$BEFORE
echo -e Balance after transfer\\t$AFTER
Create the TypeScript project
To create an executing message on the destination chain we use the @eth-optimism/viem
package (opens in a new tab).
For that we need TypeScript code.
-
Create a new TypeScript project.
mkdir transfer-eth cd transfer-eth npm init -y npm install --save-dev -y viem tsx @types/node @eth-optimism/viem typescript mkdir src
-
Download the ABI for
SuperchainWETH
.curl https://raw.githubusercontent.com/ethereum-optimism/optimism/refs/heads/develop/packages/contracts-bedrock/snapshots/abi/SuperchainWETH.json > src/SuperchainWETH.abi.json
-
Place this in
src/transfer-eth.mts
:import { createWalletClient, http, publicActions, getContract, Address, formatEther, parseEther, } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { supersimL2A, supersimL2B, interopAlpha0, interopAlpha1 } from '@eth-optimism/viem/chains' import { walletActionsL2, publicActionsL2, createInteropSentL2ToL2Messages, contracts as optimismContracts } from '@eth-optimism/viem' import superchainWethAbi from './SuperchainWETH.abi.json' const supersimAddress="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`) const sourceChain = account.address == supersimAddress ? supersimL2A : interopAlpha0 const destinationChain = account.address == supersimAddress ? supersimL2B : interopAlpha1 const sourceWallet = createWalletClient({ chain: sourceChain, transport: http(), account }).extend(publicActions) .extend(publicActionsL2()) .extend(walletActionsL2()) const destinationWallet = createWalletClient({ chain: destinationChain, transport: http(), account }).extend(publicActions) .extend(publicActionsL2()) .extend(walletActionsL2()) const wethOnSource = await getContract({ address: optimismContracts.superchainWETH.address, abi: superchainWethAbi, client: sourceWallet }) const reportBalance = async (address: string): Promise<void> => { const sourceBalance = await sourceWallet.getBalance({ address: address }); const destinationBalance = await destinationWallet.getBalance({ address: address }); console.log(` Address: ${address} Balance on source chain: ${formatEther(sourceBalance)} Balance on destination chain: ${formatEther(destinationBalance)} `); } console.log("Before transfer") await reportBalance(account.address) const sourceHash = await wethOnSource.write.sendETH({ value: parseEther('0.001'), args: [account.address, destinationChain.id] }) const sourceReceipt = await sourceWallet.waitForTransactionReceipt({ hash: sourceHash }) console.log("After transfer on source chain") await reportBalance(account.address) const sentMessage = (await createInteropSentL2ToL2Messages(sourceWallet, { receipt: sourceReceipt })) .sentMessages[0] const relayMsgTxnHash = await destinationWallet.interop.relayMessage({ sentMessageId: sentMessage.id, sentMessagePayload: sentMessage.payload, }) const receiptRelay = await destinationWallet.waitForTransactionReceipt( {hash: relayMsgTxnHash}) console.log("After relaying message to destination chain") await reportBalance(account.address)
Explanation of
transfer-eth.mts
import { supersimL2A, supersimL2B, interopAlpha0, interopAlpha1 } from '@eth-optimism/viem/chains'
Import all chain definitions from
@eth-optimism/viem
.const supersimAddress="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`) const sourceChain = account.address == supersimAddress ? supersimL2A : interopAlpha0 const destinationChain = account.address == supersimAddress ? supersimL2B : interopAlpha1
If the address we use is
0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
, one of the prefunded addresses onanvil
, assume we're using Supersim. Otherwise, use Interop devnet.const sourceReceipt = await sourceWallet.waitForTransactionReceipt({ hash: sourceHash })
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 sentMessage = (await createInteropSentL2ToL2Messages(sourceWallet, { receipt: sourceReceipt })) .sentMessages[0]
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 relayMsgTxnHash = await destinationWallet.interop.relayMessage({ sentMessageId: sentMessage.id, sentMessagePayload: sentMessage.payload, }) const receiptRelay = await destinationWallet.waitForTransactionReceipt( {hash: relayMsgTxnHash})
This is how you use
@eth-optimism/viem
to create an executing message.
Run the example
-
Run the example.
npx tsx src/transfer-eth.mts
-
Read the results.
Before transfer Address: 0x7ED53BfaA58B79Dd655B2f229258C093b6C09A8C Balance on source chain: 0.020999799151902245 Balance on destination chain: 0.026999459226731331
The initial state. Note that the address depends on your private key; it should be different from mine.
After transfer on source chain Address: 0x7ED53BfaA58B79Dd655B2f229258C093b6C09A8C Balance on source chain: 0.019999732176717961 Balance on destination chain: 0.026999459226731331
After the initiating message the balance on the source chain is immediately reduced. Notice that even though we are sending 0.001 ETH, the balance on the source chain is reduced by a bit more (here, approximately 67 gwei). This is the cost of the initiating transaction on the source chain. Of course, as there has been no transaction on the destination chain, that balance is unchanged.
After relaying message to destination chain Address: 0x7ED53BfaA58B79Dd655B2f229258C093b6C09A8C Balance on source chain: 0.019999732176717961 Balance on destination chain: 0.027999278943880868
Now the balance on the destination chain increases, by slightly less than 0.001 ETH. The executing message also has a transaction cost (in this case, about 180gwei).
Next steps
- Check out the SuperchainWETH guide for more information.
- Use the SuperchainERC20 Starter Kit to deploy your token across the Superchain.
- Review the Superchain interop explainer for answers to common questions about interoperability.