Builders
Dapp Developers
Tutorials
Bridging Your Custom ERC-20 Token to OP Mainnet

Bridging your Custom ERC-20 token to OP Mainnet using the Standard Bridge

For an L1/L2 token pair to work on the Standard Bridge, there has to be a layer of original mint (where the minting and burning of tokens is controlled by the business logic), and a bridged layer where the Standard Bridge controls minting and burning. The most common configuration is to have L1 as the layer of original mint, and L2 as the bridged layer, which allows for ERC-20 contracts that were written with no knowledge of OP Mainnet to be bridged. The contract on the bridged layer has to implement either the legacy IL2StandardERC20 (opens in a new tab) interface (only if the bridged layer is L2) or the new IOptimismMintableERC20 (opens in a new tab) interface.

For this to be done securely, the only entity that is allowed to mint and burn tokens on the bridged layer has to be the Standard Bridge, to ensure that the tokens on the bridged layer are backed up by real tokens on the layer of original mint. It is also necessary that the ERC-20 token contract on the layer of original mint not implement either of the interfaces, to make sure the bridge contracts don't get confused and think it is the bridged layer.

Before You Begin

This tutorial assumes you already have Node.js (opens in a new tab), pnpm (opens in a new tab), and yarn (opens in a new tab) installed on your system.

Customize the L2StandardERC20 Implementation

Our example here implements a custom token L2CustomERC20 based on the L2StandardERC20 but with 8 decimal points, rather than 18.

For the purpose we import the L2StandardERC20 from the @eth-optimism/contracts package. This standard token implementation is based on the OpenZeppelin ERC20 contract and implements the required IL2StandardERC20 interface.

import { L2StandardERC20 } from "@eth-optimism/contracts/standards/L2StandardERC20.sol";

Then the only thing we need to do is call the internal _setupDecimals(8) method to alter the token decimals property from the default 18 to 8.

Deploying the custom token

Install the dependencies

pnpm add @eth-optimism/sdk@^3.0.0 dotenv@^10.0.0 @eth-optimism/contracts@^0.5.40 @openzeppelin/contracts^4.3.2

Create the .env file

MNEMONIC=xxxxxx
L1_ALCHEMY_KEY=xxxxx
L2_ALCHEMY_KEY=xxxx
L1_TOKEN_ADDRESS=0x32B3b2281717dA83463414af4E8CfB1970E56287

Edit .env to set the deployment parameters

  • MNEMONIC, the mnemonic for an account that has enough ETH for the deployment.
  • L1_ALCHEMY_KEY, the key for the alchemy application for a Goerli endpoint.
  • L2_ALCHEMY_KEY, the key for the alchemy application for an OP Goerli endpoint.
  • L1_TOKEN_ADDRESS, the address of the L1 ERC20 which you want to bridge. The default value, 0x32B3b2281717dA83463414af4E8CfB1970E56287 (opens in a new tab) is a test ERC-20 contract on Goerli that lets you call faucet to give yourself test tokens.

Open the hardhat console

yarn hardhat console --network optimism-goerli

Deploy the contract

l2CustomERC20Factory = await ethers.getContractFactory("L2CustomERC20")   
l2CustomERC20 = await l2CustomERC20Factory.deploy(
   "0x4200000000000000000000000000000000000010",
   process.env.L1_TOKEN_ADDRESS)

Transferring tokens

Get the token addresses

l1Addr = process.env.L1_TOKEN_ADDRESS
l2Addr = l2CustomERC20.address

Get setup for L1 (provider, wallet, tokens, etc)

Get the L1 wallet

l1Url = `https://eth-goerli.g.alchemy.com/v2/${process.env.L1_ALCHEMY_KEY}`
l1RpcProvider = new ethers.providers.JsonRpcProvider(l1Url)
hdNode = ethers.utils.HDNode.fromMnemonic(process.env.MNEMONIC)
privateKey = hdNode.derivePath(ethers.utils.defaultPath).privateKey
l1Wallet = new ethers.Wallet(privateKey, l1RpcProvider)

Get the L1 contract

l1Factory = await ethers.getContractFactory("OptimismUselessToken")
l1Contract = new ethers.Contract(process.env.L1_TOKEN_ADDRESS, l1Factory.interface, l1Wallet)

Get tokens on L1 (and verify the balance)

tx = await l1Contract.faucet()
rcpt = await tx.wait()
await l1Contract.balanceOf(l1Wallet.address)

Transfer tokens

Create and use CrossDomainMessenger (opens in a new tab) (the Optimism SDK object used to bridge assets). The SDK supports multiple OP Chains: OP, Base, etc. To see whether a specific OP Chain is supported directly, see the documentation (opens in a new tab). Chains that aren't officially supported just take a few extra steps. Get the L1 contract addresses, and provide them to the SDK. Once you do that, you can use the SDK normally.

Import the Optimism SDK

const optimismSDK = require("@eth-optimism/sdk")

Create the cross domain messenger

l1ChainId = (await l1RpcProvider.getNetwork()).chainId
l2ChainId = (await ethers.provider.getNetwork()).chainId
l2Wallet = await ethers.provider.getSigner()
crossChainMessenger = new optimismSDK.CrossChainMessenger({
   l1ChainId: l1ChainId,
   l2ChainId: l2ChainId,
   l1SignerOrProvider: l1Wallet,
   l2SignerOrProvider: l2Wallet,
})

Deposit (from Goerli to OP Goerli, or Ethereum or OP Mainnet)

Give the L2 bridge an allowance to use the user's token

The L2 address is necessary to know which bridge is responsible and needs the allowance.

depositTx1 = await crossChainMessenger.approveERC20(l1Contract.address, l2Addr, 1e9)
await depositTx1.wait()

Check your balances on L1 and L2

await l1Contract.balanceOf(l1Wallet.address) 
await l2CustomERC20.balanceOf(l1Wallet.address)

Do the actual deposit

depositTx2 = await crossChainMessenger.depositERC20(l1Contract.address, l2Addr, 1e9)
await depositTx2.wait()

Wait for the deposit to be relayed

await crossChainMessenger.waitForMessageStatus(depositTx2.hash, optimismSDK.MessageStatus.RELAYED)

Check your balances on L1 and L2

await l1Contract.balanceOf(l1Wallet.address) 
await l2CustomERC20.balanceOf(l1Wallet.address)

Withdrawal (from OP Mainnet to Ethereum, or OP Goerli to Goerli)

Initiate the withdrawal on L2

withdrawalTx1 = await crossChainMessenger.withdrawERC20(l1Contract.address, l2Addr, 1e9)
await withdrawalTx1.wait()

Prove the withdrawal after root state is published

  • Wait until the root state is published on L1.
  • This is likely to take less than 240 seconds.
await crossChainMessenger.waitForMessageStatus(withdrawalTx1.hash, optimismSDK.MessageStatus.READY_TO_PROVE)
withdrawalTx2 = await crossChainMessenger.proveMessage(withdrawalTx1.hash)
await withdrawalTx2.wait()

Wait the fault challenge period and finish the withdrawal

  • The fault challenge period is a short period on Goerli, but seven days on the production network.

    await crossChainMessenger.waitForMessageStatus(withdrawalTx1.hash, optimismSDK.MessageStatus.READY_FOR_RELAY)
    withdrawalTx3 = await crossChainMessenger.finalizeMessage(withdrawalTx1.hash)
    await withdrawalTx3.wait()   

Check your balances on L1 and L2

The balance on L2 should be back to zero.

await l1Contract.balanceOf(l1Wallet.address) 
await l2CustomERC20.balanceOf(l1Wallet.address)