App Devs
Transferring a SuperchainERC20
💡

The SuperchainERC20 standard is ready for production deployments. Please note that the OP Stack interoperability upgrade, required for crosschain messaging, is currently still in active development.

Transferring SuperchainERC20 tokens

This guide shows how to transfer SuperchainERC20 tokens between chains programmatically.

Note that this tutorial provides step-by-step instructions for transferring SuperchainERC20 tokens using code.

Overview

⚠️

Always verify your addresses and amounts before sending transactions. Cross-chain transfers cannot be reversed.

What you'll build

  • A TypeScript application to transfer SuperchainERC20 tokens between chains

What you'll learn

  • How to send SuperchainERC20 tokens 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 issuing transactions
  • TypeScript: For implementation
  • Node: For running TypeScript code from the command line
  • Viem: For blockchain interaction

Directions

Preparation

You need onchain SuperchainERC20 tokens. You can deploy your own token, but in this tutorial we will use CustomSuperchainToken (opens in a new tab), existing SuperchainERC20 token on the Interop devnet.

  1. Create environment variables for the RPC endpoints for the blockchains and the token address.

    RPC_DEV0=https://interop-alpha-0.optimism.io
    RPC_DEV1=https://interop-alpha-1.optimism.io
    TOKEN_ADDRESS=0xF3Ce0794cB4Ef75A902e07e5D2b75E4D71495ee8
  2. Set PRIVATE_KEY to the private key of an address that has Sepolia ETH (opens in a new tab).

    export PRIVATE_KEY=0x<private key here>
    MY_ADDRESS=`cast wallet address $PRIVATE_KEY`
  3. Send ETH to the two L2 blockchains.

    cast send --rpc-url https://endpoints.omniatech.io/v1/eth/sepolia/public --private-key $PRIVATE_KEY --value 0.02ether 0x7385d89d38ab79984e7c84fab9ce5e6f4815468a
    cast send --rpc-url https://endpoints.omniatech.io/v1/eth/sepolia/public --private-key $PRIVATE_KEY --value 0.02ether 0x55f5c4653dbcde7d1254f9c690a5d761b315500c
  4. Wait a few minutes until you can see the ETH on the block explorer (opens in a new tab) for your address.

    Sanity check

    Check the ETH balance of your address on both blockchains.

    cast balance --ether $MY_ADDRESS --rpc-url $RPC_DEV0
    cast balance --ether $MY_ADDRESS --rpc-url $RPC_DEV1
  5. Obtain tokens on Interop devnet 0. When using CustomSuperchainToken, there are two ways to do this:

    Sanity check

    Run this command to check your token balance.

    cast call --rpc-url $RPC_DEV0 $TOKEN_ADDRESS "balanceOf(address)" $MY_ADDRESS | cast --from-wei

Transfer tokens using TypeScript

We are going to use a Node (opens in a new tab) project, to be able to use @eth-optimism/viem (opens in a new tab) to send the executing message. We use TypeScript (opens in a new tab) to have type safety (opens in a new tab) combined with JavaScript functionality.

  1. Initialize a new Node project.

    mkdir xfer-erc20
    cd xfer-erc20
    npm init -y
    npm install --save-dev -y viem tsx @types/node @eth-optimism/viem
    mkdir src
  2. Edit package.json to add the start script.

    {
      "name": "xfer-erc20",
      "version": "1.0.0",
      "main": "index.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "start": "tsx src/xfer-erc20.mts"
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "type": "module",
      "description": "",
      "devDependencies": {
        "@eth-optimism/viem": "^0.3.2",
        "@types/node": "^22.13.4",
        "tsx": "^4.19.3",
        "viem": "^2.23.3"
      }
    }      
  3. Create src/xfer-erc20.mts:

    import {
        createWalletClient,
        http,
        publicActions,
        getContract,
        Address,
    } from 'viem'
    import { privateKeyToAccount } from 'viem/accounts'
    import { interopAlpha0, interopAlpha1 } from '@eth-optimism/viem/chains'
     
    import {
        walletActionsL2,
        publicActionsL2,
        createInteropSentL2ToL2Messages,
    } from '@eth-optimism/viem'
     
    const tokenAddress = "0xF3Ce0794cB4Ef75A902e07e5D2b75E4D71495ee8"
    const balanceOf = {
        "constant": true,
        "inputs": [{
            "name": "_owner",
            "type": "address"
        }],
        "name": "balanceOf",
        "outputs": [{
            "name": "balance",
            "type": "uint256"
        }],
        "payable": false,
        "stateMutability": "view",
        "type": "function"
        }
     
    const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
     
    const wallet0 = createWalletClient({
        chain: interopAlpha0,
        transport: http(),
        account
    }).extend(publicActions)
        .extend(publicActionsL2())
        .extend(walletActionsL2())
     
    const wallet1 = createWalletClient({
        chain: interopAlpha1,
        transport: http(),
        account
    }).extend(publicActions)
        .extend(publicActionsL2())
        .extend(walletActionsL2())
     
    const token0 = getContract({
        address: tokenAddress,
        abi: [balanceOf],
        client: wallet0
    })
     
    const token1 = getContract({
        address: tokenAddress,
        abi: [balanceOf],
        client: wallet1
    })
     
     
    const reportBalances = async () => {
        const balance0 = await token0.read.balanceOf([account.address])
        const balance1 = await token1.read.balanceOf([account.address])
     
        console.log(`
    Address: ${account.address}
        chain0: ${balance0.toString().padStart(20)}
        chain1: ${balance1.toString().padStart(20)}
     
    `)
    }
     
    await reportBalances()
     
    const sendTxnHash = await wallet0.interop.sendSuperchainERC20({
        tokenAddress,
        to: account.address,
        amount: 1000000000,
        chainId: wallet1.chain.id
    })
     
    console.log(`Send transaction: https://sid.testnet.routescan.io/tx/${sendTxnHash}`)
     
    const sendTxnReceipt = await wallet0.waitForTransactionReceipt({
        hash: sendTxnHash
    })
     
    const sentMessage =  
        (await createInteropSentL2ToL2Messages(wallet0, { receipt: sendTxnReceipt }))
            .sentMessages[0]
     
    const relayTxnHash = await wallet1.interop.relayMessage({
        sentMessageId: sentMessage.id,
        sentMessagePayload: sentMessage.payload,
    })
     
    const relayTxnReceipt = await wallet1.waitForTransactionReceipt({
        hash: relayTxnHash
    })
     
    console.log(`Relay transaction: https://sid.testnet.routescan.io/tx/${relayTxnHash}`)
     
    await reportBalances()
    Explanation of xfer-erc20.mts
    const sendTxnHash = await wallet0.interop.sendSuperchainERC20({
        tokenAddress,
        to: account.address,
        amount: 1000000000,
        chainId: wallet1.chain.id
    })

    Use @eth-optimism/viem's walletActionsL2().sendSuperchainERC20 to send the SuperchainERC20 tokens. Internally, this function calls SuperchainTokenBridge.sendERC20 (opens in a new tab) to send the tokens.

    💡
    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.
    const sendTxnReceipt = await wallet0.waitForTransactionReceipt({
        hash: sendTxnHash
    })

    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(wallet0, { receipt: sendTxnReceipt }))
            .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 relayTxnHash = await wallet1.interop.relayMessage({
        sentMessageId: sentMessage.id,
        sentMessagePayload: sentMessage.payload,
    })

    This is how you use @eth-optimism/viem to create an executing message.

  4. Run the TypeScript program, and see the change in your CustomSuperchainToken balances.

    npm start

Next steps