App Devs
Bridging ERC-20 tokens with viem

Bridging ERC-20 tokens to OP Mainnet

This tutorial explains how you can use @eth-optimism/viem (opens in a new tab) to bridge ERC-20 tokens from L1 (Ethereum or Sepolia) to L2 (OP Mainnet or OP Sepolia). The @eth-optimism/viem package is an easy way to add bridging functionality to your javascript-based application. It also provides some safety rails to prevent common mistakes that could cause tokens to be made inaccessible.

Behind the scenes, @eth-optimism/viem package uses the Standard Bridge contracts to transfer tokens. Make sure to check out the Standard Bridge guide if you want to learn more about how the bridge works under the hood.

🚫

The Standard Bridge does not support fee on transfer tokens (opens in a new tab) or rebasing tokens (opens in a new tab) because they can cause bridge accounting errors.

Supported networks

The @eth-optimism/viem package supports any of the Superchain networks. If you want to use a network that isn't included by default, you can simply instantiate the package with the appropriate contract addresses.

Dependencies

Create a demo project

You're going to use the @eth-optimism/viem package for this tutorial. Since the @eth-optimism/viem package is a Node.js (opens in a new tab) library, you'll need to create a Node.js project to use it.

Make a Project Folder

mkdir bridging-erc20-tokens
cd bridging-erc20-tokens

Initialize the Project

pnpm init

Install the `@eth-optimism/viem`

pnpm add @eth-optimism/viem

Install viem

pnpm add viem

Want to create a new wallet for this tutorial? If you have cast (opens in a new tab) installed you can run cast wallet new in your terminal to create a new wallet and get the private key.

Get ETH on Sepolia and OP Sepolia

This tutorial explains how to bridge tokens from Sepolia to OP Sepolia. You will need to get some ETH on both of these testnets.

You can use this faucet (opens in a new tab) to get ETH on Sepolia. You can use the Superchain Faucet (opens in a new tab) to get ETH on OP Sepolia.

Add a private key to your environment

You need a private key to sign transactions. Set your private key as an environment variable with the export command. Make sure this private key corresponds to an address that has ETH on both Sepolia and OP Sepolia.

export TUTORIAL_PRIVATE_KEY=0x...

Start the Node REPL

You're going to use the Node REPL to interact with the @eth-optimism/viem. To start the Node REPL, run the following command in your terminal:

node

This will bring up a Node REPL prompt that allows you to run javascript code.

Import dependencies

You need to import some dependencies into your Node REPL session. The @eth-optimism/viem package uses ESM modules, and to use in the Node.js REPL, you need to use dynamic imports with await. Here's how to do it:

Import the @eth-optimism/viem package

 
  const viem = await import('viem');
  const { createPublicClient, createWalletClient, http, formatEther } = viem;
  const accounts = await import('viem/accounts');
  const { privateKeyToAccount } = accounts;
  const viemChains = await import('viem/chains');
  const { optimismSepolia, sepolia } = viemChains;
  const opActions = await import('@eth-optimism/viem/actions');
  const { depositERC20, withdrawOptimismERC20 } = opActions;

Set session variables

You'll need a few variables throughout this tutorial. Let's set those up now.

Load your private key

This step retrieves your private key from the environment variable you set earlier and converts it into an account object that Viem can use for transaction signing.

The private key is essential for authorizing transactions on both L1 and L2 networks. For security reasons, we access it from an environment variable rather than hardcoding it.

  const PRIVATE_KEY = process.env.TUTORIAL_PRIVATE_KEY || '';
  const account = privateKeyToAccount(PRIVATE_KEY);

Create the RPC providers and wallets

Here we establish the connections to both networks by creating four different clients:

  1. L1 Public Client: For reading data from the Sepolia network
  2. L1 Wallet Client: For signing and sending transactions on Sepolia
  3. L2 Public Client: For reading data from OP Sepolia
  4. L2 Wallet Client: For signing and sending transactions on OP Sepolia

Each client is configured with the appropriate chain information and RPC endpoint. This dual-network setup allows us to seamlessly interact with both layers using the same account. Replace <YOUR_API_KEY> with your API key from a RPC provider.

  const L1_RPC_URL = 'https://rpc.ankr.com/eth_sepolia/<YOU_API_KEY>';
  const L2_RPC_URL = 'https://sepolia.optimism.io';
  
  const publicClientL1 = createPublicClient({
    chain: sepolia,
    transport: http(L1_RPC_URL),
  });
  
  const walletClientL1 = createWalletClient({
    account,
    chain: sepolia,
    transport: http(L1_RPC_URL),
  });
  
  const publicClientL2 = createPublicClient({
    chain: optimismSepolia,
    transport: http(L2_RPC_URL),
  });
  
  const walletClientL2 = createWalletClient({
    account,
    chain: optimismSepolia,
    transport: http(L2_RPC_URL),
  });

Set the L1 and L2 ERC-20 addresses

We define the addresses of the ERC-20 tokens on both networks. These are specially deployed test tokens with corresponding implementations on both L1 (Sepolia) and L2 (OP Sepolia).

The L2 token is configured to recognize deposits from its L1 counterpart. We also define a constant oneToken representing the full unit (10^18 wei) to simplify our deposit and withdrawal operations.

  const l1Token = "0x5589BB8228C07c4e15558875fAf2B859f678d129";
  const l2Token = "0xD08a2917653d4E460893203471f0000826fb4034";
💡

If you're coming from the Bridging Your Standard ERC-20 Token to OP Mainnet Using the Standard Bridge or Bridging Your Custom ERC-20 Token to OP Mainnet Using the Standard Bridge tutorials, you can use the addresses of your own ERC-20 tokens here instead.

Get L1 tokens

You're going to need some tokens on L1 that you can bridge to L2. The L1 testing token located at 0x5589BB8228C07c4e15558875fAf2B859f678d129 (opens in a new tab) has a faucet function that makes it easy to get tokens.

Set the ERC20 ABI

The Application Binary Interface (ABI) defines how to interact with the smart contract functions. This ERC-20 ABI includes several critical functions:

  • balanceOf: Allows us to check token balances for any address
  • faucet: A special function in this test token that mints new tokens to the caller
  • approve: Required to grant the bridge permission to transfer tokens on our behalf
  • allowance: To check how many tokens we've approved for the bridge
  • decimals and symbol: Provide token metadata

This comprehensive ABI gives us everything we need to manage our tokens across both L1 and L2.

  const erc20ABI = [
    {
      inputs: [
        {
          internalType: "address",
          name: "account",
          type: "address",
        },
      ],
      name: "balanceOf",
      outputs: [
        {
          internalType: "uint256",
          name: "",
          type: "uint256",
        },
      ],
      stateMutability: "view",
      type: "function",
    },
    {
      inputs: [],
      name: "faucet",
      outputs: [],
      stateMutability: "nonpayable",
      type: "function",
    },
    {
      inputs: [
        {
          internalType: "address",
          name: "spender",
          type: "address"
        },
        {
          internalType: "uint256",
          name: "value",
          type: "uint256"
        }
      ],
      name: "approve",
      outputs: [
        {
          internalType: "bool",
          name: "",
          type: "bool"
        }
      ],
      stateMutability: "nonpayable",
      type: "function"
    },
  ];

Request some tokens

Now we'll call the faucet function on the L1 test token contract to receive free tokens for testing. This transaction will mint new tokens directly to our wallet address.

The function doesn't require any parameters - it simply credits a predetermined amount to whoever calls it. We store the transaction hash for later reference and wait for the transaction to be confirmed.

  console.log('Getting tokens from faucet...');
  const tx = await walletClientL1.writeContract({
    address: l1Token,
    abi: erc20ABI,
    functionName: 'faucet',
    account,
  });
  console.log('Faucet transaction:', tx);

Check your token balance

After using the faucet, we verify our token balance by calling the balanceOf function on the L1 token contract.

This step confirms that we've successfully received tokens before proceeding with the bridging process. The balance is returned in the smallest unit (wei), but we format it into a more readable form using the formatEther utility function from viem, since this token uses 18 decimal places.

  const l1Balance = await publicClientL1.readContract({
    address: l1Token,
    abi: erc20ABI,
    functionName: 'balanceOf',
    args: [account.address]
  });
  console.log(`L1 Balance after receiving faucet: ${formatEther(l1Balance)}`);

Deposit tokens

Now that you have some tokens on L1, you can deposit those tokens into the L1StandardBridge contract. You'll then receive the same number of tokens on L2 in return.

Define the amount to deposit

We define a variable oneToken that represents 1 full token in its base units (wei). ERC-20 tokens typically use 18 decimal places, so 1 token equals 10^18 wei.

This constant helps us work with precise token amounts in our transactions, avoiding rounding errors and ensuring exact value transfers. We'll use this value for both deposits and withdrawals

  const oneToken = 1000000000000000000n

Allow the Standard Bridge to access your tokens

ERC-20 tokens require a two-step process for transferring tokens on behalf of a user. First, we must grant permission to the bridge contract to spend our tokens by calling the approve function on the token contract.

We specify the bridge address from the chain configuration and the exact amount we want to bridge. This approval transaction must be confirmed before the bridge can move our tokens.

  const bridgeAddress = optimismSepolia.contracts.l1StandardBridge[sepolia.id].address;
  const approveTx = await walletClientL1.writeContract({
    address: l1Token,
    abi: erc20ABI,
    functionName: 'approve',
    args: [bridgeAddress, oneToken],
  });
  console.log('Approval transaction:', approveTx);

Wait for approval

After submitting the approval transaction, we need to wait for it to be confirmed on L1. We use the waitForTransactionReceipt function to monitor the transaction until it's included in a block.

The receipt provides confirmation details, including which block includes our transaction. This step ensures our approval is finalized before attempting to bridge tokens.

  await publicClientL1.waitForTransactionReceipt({ hash: approveTx });

Deposit your tokens

Now we can execute the actual bridging operation using the depositERC20 function from the @eth-optimism/viem package.

This function handles all the complex interactions with the L1StandardBridge contract for us. We provide:

  • The addresses of both the L1 and L2 tokens
  • The amount to bridge
  • The target chain (OP Sepolia)
  • Our wallet address as the recipient on L2
  • A minimum gas limit for the L2 transaction

This streamlined process ensures our tokens are safely transferred to L2.

  console.log('Depositing tokens to L2...');
  const depositTx = await depositERC20(walletClientL1, {
    tokenAddress: l1Token,
    remoteTokenAddress: l2Token,
    amount: oneToken,
    targetChain: optimismSepolia,
    to: account.address,
    minGasLimit: 200000,
  });
  console.log(`Deposit transaction hash: ${depositTx}`);
💡

Using a smart contract wallet? As a safety measure, depositERC20 will fail if you try to deposit ETH from a smart contract wallet without specifying a recipient. Add the recipient option to the depositERC20 call to fix this. Check out the @eth-optimism/viem docs (opens in a new tab) for more info on the options you can pass to depositERC20.

Wait for the deposit to be relayed

After initiating the deposit, we need to wait for the L1 transaction to be confirmed. This function tracks the transaction until it's included in an L1 block.

Note that while this confirms the deposit was accepted on L1, there will still be a short delay (typically a few minutes) before the tokens appear on L2, as the transaction needs to be processed by the Optimism sequencer.

  const depositReceipt = await publicClientL1.waitForTransactionReceipt({ hash: depositTx });
  console.log(`Deposit confirmed in block ${depositReceipt.blockNumber}`);

Check your token balance on L1

After the deposit transaction is confirmed, we check our token balance on L1 again to verify that the tokens have been deducted.

This balance should be lower by the amount we bridged, as those tokens are now escrowed in the L1StandardBridge contract. This step helps confirm that the first part of the bridging process completed successfully:

  const l1BalanceAfterDeposit = await publicClientL1.readContract({
    address: l1Token,
    abi: erc20ABI,
    functionName: 'balanceOf',
    args: [account.address]
  });
  console.log(`L1 Balance after deposit: ${formatEther(l1BalanceAfterDeposit)}`);

Check your token balance on L2

After allowing some time for the L2 transaction to be processed, we check our token balance on L2 to verify that we've received the bridged tokens.

The newly minted L2 tokens should appear in our wallet at the same address we used on L1. This step confirms the complete success of the bridge operation from L1 to L2.

  const l2Balance = await publicClientL2.readContract({
    address: l2Token,
    abi: erc20ABI,
    functionName: 'balanceOf',
    args: [account.address]
  });
  console.log(`L2 Balance after withdrawal: ${formatEther(l2Balance)}`);

Withdraw tokens

You just bridged some tokens from L1 to L2. Nice! Now you're going to repeat the process in reverse to bridge some tokens from L2 to L1.

Initiate the withdrawal

To move tokens back to L1, we use the withdrawOptimismERC20 function from the @eth-optimism/viem package. This function interacts with the L2StandardBridge contract to initialize the withdrawal process. We specify:

  • The L2 token address
  • The amount to withdraw (we're using half of a token in this tutorial)
  • Our address as the recipient on L1
  • A minimum gas limit for the transaction

Unlike deposits, withdrawals from L2 to L1 are not immediate and require a multi-step process including a 7-day challenge period for security reasons.

  console.log('Withdrawing tokens back to L1...');
  const withdrawTx = await withdrawOptimismERC20(walletClientL2, {
    tokenAddress: l2Token,
    amount: oneToken / 2n, 
    to: account.address,
    minGasLimit: 200000,
  });
  console.log(`Withdrawal transaction hash: ${withdrawTx}`);

Wait for the transaction receipt

Similar to deposits, we wait for the withdrawal transaction to be confirmed on L2. This receipt provides confirmation that the withdrawal has been initiated.

The transaction logs contain critical information that will be used later in the withdrawal verification process. This is only the first step in the withdrawal - the tokens are now locked on L2, but not yet available on L1.

  const withdrawReceipt = await publicClientL2.waitForTransactionReceipt({ hash: withdrawTx });
  console.log(`Withdrawal initiated in L2 block ${withdrawReceipt.blockNumber}`);

This step can take a few minutes. Feel free to take a quick break while you wait.

Check your token balance on L2

After the withdrawal transaction is confirmed, we check our token balance on L2 again to verify that the tokens have been deducted. Our L2 balance should now be lower by the amount we initiated for withdrawal.

At this point, the withdrawal process has begun, but the tokens are not yet available on L1 - they will become accessible after the 7-day challenge period and after completing the "prove" and "finalize" withdrawal steps.

  const l2Balance = await publicClientL2.readContract({
    address: l2Token,
    abi: erc20ABI,
    functionName: 'balanceOf',
    args: [account.address]
  });
  console.log(`L2 Balance after withdrawal: ${formatEther(l2Balance)}`);

Next steps

Congrats! You've just deposited and withdrawn tokens using @eth-optimism/viem package. You should now be able to write applications that use the @eth-optimism/viem package to transfer ERC-20 tokens between L1 and L2. Although this tutorial used Sepolia and OP Sepolia, the same process works for Ethereum and OP Mainnet.