Bridging ERC-20 tokens with the Optimism SDK
This tutorial teaches you how to use the Optimism SDK (opens in a new tab) to transfer ERC-20 tokens between Layer 1 (Ethereum or Goerli) and Layer 2 (OP Mainnet or OP Goerli). While you could use the bridge contracts directly, a simple usage error can cause you to lock tokens in the bridge forever and lose their value. The SDK provides transparent safety rails to prevent that mistake.
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.
The standard bridge does not support certain ERC-20 configurations:
Before You Begin
You will need to setup your computer with the following: pnpm
(opens in a new tab) and node
(opens in a new tab).
Please ensure these programs are installed before beginning the tutorial.
Make a Project Folder and Initialize It
mkdir project-folder-name
cd folder-name
pnpm init
Create Two Files for the Folder
- Create a
.env
file - Create an
index.js
file
Install the dependencies
pnpm add @eth-optimism/sdk@^3.0.0 dotenv@^16.0.0
Go to Alchemy (opens in a new tab) and create two applications
- An application on Goerli
- An application on OP Goerli
Keep a copy of the two keys.
Edit the .env
file
- Copy/paste the text below into your
.env
file.
# Put the mnemonic for an account on Optimism here
MNEMONIC="test test test test test test test test test test test junk"
GOERLI_ALCHEMY_KEY= <<value goes here>>
OP_GOERLI_ALCHEMY_KEY= <<value goes here>>
- Edit the file as follows:
- Set
MNEMONIC
to point to an account that has ETH on the Goerli test network and the OP Goerli test network. - Set
GOERLI_ALCHEMY_KEY
to the key for the Goerli app. - Set
OP_GOERLI_ALCHEMY_KEY
to the key for the Optimistic Goerli app
- Set
Paradigm faucet (opens in a new tab) gives ETH on the Goerli network. Optimism faucet (opens in a new tab) gives ETH on the OP Goerli network.
Edit the index.js
file
Add the following content to your index.js
file:
#! /usr/local/bin/node
// ERC-20 transfers between L1 and L2 using the Optimism SDK
const ethers = require("ethers")
const optimismSDK = require("@eth-optimism/sdk")
require('dotenv').config()
const mnemonic = process.env.MNEMONIC
const words = process.env.MNEMONIC.match(/[a-zA-Z]+/g).length
validLength = [12, 15, 18, 24]
if (!validLength.includes(words)) {
console.log(`The mnemonic (${process.env.MNEMONIC}) is the wrong number of words`)
process.exit(-1)
}
const l1Url = `https://eth-goerli.g.alchemy.com/v2/${process.env.GOERLI_ALCHEMY_KEY}`
const l2Url = `https://opt-goerli.g.alchemy.com/v2/${process.env.OP_GOERLI_ALCHEMY_KEY}`
// Contract addresses for OPTb tokens, taken
// from https://github.com/ethereum-optimism/ethereum-optimism.github.io/blob/master/data/OUTb/data.json
const erc20Addrs = {
l1Addr: "0x32B3b2281717dA83463414af4E8CfB1970E56287",
l2Addr: "0x3e7eF8f50246f725885102E8238CBba33F276747"
} // erc20Addrs
// To learn how to deploy an L2 equivalent to an L1 ERC-20 contract,
// see here:
// https://docs.optimism.io/dapp-developers/tutorials/standard-bridge-standard-token
// Global variable because we need them almost everywhere
let crossChainMessenger
let l1ERC20, l2ERC20 // OUTb contracts to show ERC-20 transfers
let ourAddr // The address of the signer we use.
// Get signers on L1 and L2 (for the same address). Note that
// this address needs to have ETH on it, both on Optimism and
// Optimism Georli
const getSigners = async () => {
const l1RpcProvider = new ethers.providers.JsonRpcProvider(l1Url)
const l2RpcProvider = new ethers.providers.JsonRpcProvider(l2Url)
const hdNode = ethers.utils.HDNode.fromMnemonic(mnemonic)
const privateKey = hdNode.derivePath(ethers.utils.defaultPath).privateKey
const l1Wallet = new ethers.Wallet(privateKey, l1RpcProvider)
const l2Wallet = new ethers.Wallet(privateKey, l2RpcProvider)
return [l1Wallet, l2Wallet]
} // getSigners
// The ABI fragment for the contract. We only need to know how to do two things:
// 1. Get an account's balance
// 2. Call the faucet to get more (only works on L1). Of course, production
// ERC-20 tokens tend to be a bit harder to acquire.
const erc20ABI = [
// balanceOf
{
constant: true,
inputs: [{ name: "_owner", type: "address" }],
name: "balanceOf",
outputs: [{ name: "balance", type: "uint256" }],
type: "function",
},
// faucet
{
inputs: [],
name: "faucet",
outputs: [],
stateMutability: "nonpayable",
type: "function"
}
] // erc20ABI
const setup = async() => {
const [l1Signer, l2Signer] = await getSigners()
ourAddr = l1Signer.address
crossChainMessenger = new optimismSDK.CrossChainMessenger({
l1ChainId: 5, // Goerli value, 1 for mainnet
l2ChainId: 420, // Goerli value, 10 for mainnet
l1SignerOrProvider: l1Signer,
l2SignerOrProvider: l2Signer,
})
l1ERC20 = new ethers.Contract(erc20Addrs.l1Addr, erc20ABI, l1Signer)
l2ERC20 = new ethers.Contract(erc20Addrs.l2Addr, erc20ABI, l2Signer)
} // setup
const reportERC20Balances = async () => {
const l1Balance = (await l1ERC20.balanceOf(ourAddr)).toString().slice(0,-18)
const l2Balance = (await l2ERC20.balanceOf(ourAddr)).toString().slice(0,-18)
console.log(`OUTb on L1:${l1Balance} OUTb on L2:${l2Balance}`)
if (l1Balance != 0) {
return
}
console.log(`You don't have enough OUTb on L1. Let's call the faucet to fix that`)
const tx = (await l1ERC20.faucet())
console.log(`Faucet tx: ${tx.hash}`)
console.log(`\tMore info: https://goerli.etherscan.io/tx/${tx.hash}`)
await tx.wait()
const newBalance = (await l1ERC20.balanceOf(ourAddr)).toString().slice(0,-18)
console.log(`New L1 OUTb balance: ${newBalance}`)
} // reportERC20Balances
const oneToken = BigInt(1e18)
const depositERC20 = async () => {
console.log("Deposit ERC20")
await reportERC20Balances()
const start = new Date()
// Need the l2 address to know which bridge is responsible
const allowanceResponse = await crossChainMessenger.approveERC20(
erc20Addrs.l1Addr, erc20Addrs.l2Addr, oneToken)
await allowanceResponse.wait()
console.log(`Allowance given by tx ${allowanceResponse.hash}`)
console.log(`\tMore info: https://goerli.etherscan.io/tx/${allowanceResponse.hash}`)
console.log(`Time so far ${(new Date()-start)/1000} seconds`)
const response = await crossChainMessenger.depositERC20(
erc20Addrs.l1Addr, erc20Addrs.l2Addr, oneToken)
console.log(`Deposit transaction hash (on L1): ${response.hash}`)
console.log(`\tMore info: https://goerli.etherscan.io/tx/${response.hash}`)
await response.wait()
console.log("Waiting for status to change to RELAYED")
console.log(`Time so far ${(new Date()-start)/1000} seconds`)
await crossChainMessenger.waitForMessageStatus(response.hash,
optimismSDK.MessageStatus.RELAYED)
await reportERC20Balances()
console.log(`depositERC20 took ${(new Date()-start)/1000} seconds\n\n`)
} // depositERC20()
const withdrawERC20 = async () => {
console.log("Withdraw ERC20")
const start = new Date()
await reportERC20Balances()
const response = await crossChainMessenger.withdrawERC20(
erc20Addrs.l1Addr, erc20Addrs.l2Addr, oneToken)
console.log(`Transaction hash (on L2): ${response.hash}`)
console.log(`\tFor more information: https://goerli-optimism.etherscan.io/tx/${response.hash}`)
await response.wait()
console.log("Waiting for status to be READY_TO_PROVE")
console.log(`Time so far ${(new Date()-start)/1000} seconds`)
await crossChainMessenger.waitForMessageStatus(response.hash,
optimismSDK.MessageStatus.READY_TO_PROVE)
console.log(`Time so far ${(new Date()-start)/1000} seconds`)
await crossChainMessenger.proveMessage(response.hash)
console.log("In the challenge period, waiting for status READY_FOR_RELAY")
console.log(`Time so far ${(new Date()-start)/1000} seconds`)
await crossChainMessenger.waitForMessageStatus(response.hash,
optimismSDK.MessageStatus.READY_FOR_RELAY)
console.log("Ready for relay, finalizing message now")
console.log(`Time so far ${(new Date()-start)/1000} seconds`)
await crossChainMessenger.finalizeMessage(response.hash)
console.log("Waiting for status to change to RELAYED")
console.log(`Time so far ${(new Date()-start)/1000} seconds`)
await crossChainMessenger.waitForMessageStatus(response,
optimismSDK.MessageStatus.RELAYED)
await reportERC20Balances()
console.log(`withdrawERC20 took ${(new Date()-start)/1000} seconds\n\n\n`)
} // withdrawERC20()
const main = async () => {
await setup()
await depositERC20()
await withdrawERC20()
} // main
main().then(() => process.exit(0))
.catch((error) => {
console.error(error)
process.exit(1)
})
Run the sample code
Execute the sample code in index.js
.
After you execute it, wait. It is not unusual for each operation to take a few minutes on Goerli.
On the production network, the withdrawals take around a week each because of the challenge period.
Expected output
The output from the script should be similar to:
Deposit ERC20
OUTb on L1: OUTb on L2:
You don't have enough OUTb on L1. Let's call the faucet to fix that
Faucet tx: 0xccf8a2b4cc9009f78975c761f5b156f4dd02574af51310bb0fb9627dbe900510
More info: https://goerli.etherscan.io/tx/0xccf8a2b4cc9009f78975c761f5b156f4dd02574af51310bb0fb9627dbe900510
New L1 OUTb balance: 1000
Allowance given by tx 0xe3c08d2dbed75f16f0eb9681d5b29971e76f831f0370175b1100a4d28b2940e1
More info: https://goerli.etherscan.io/tx/0xe3c08d2dbed75f16f0eb9681d5b29971e76f831f0370175b1100a4d28b2940e1
Time so far 11.819 seconds
Deposit transaction hash (on L1): 0xb03a4e53aa7f237ff9a1feac59f56aa683a5c6f8f0f491597b0286ff8553925e
More info: https://goerli.etherscan.io/tx/0xb03a4e53aa7f237ff9a1feac59f56aa683a5c6f8f0f491597b0286ff8553925e
Waiting for status to change to RELAYED
Time so far 21.561 seconds
OUTb on L1:999 OUTb on L2:1
depositERC20 took 179.776 seconds
Withdraw ERC20
OUTb on L1:999 OUTb on L2:1
Transaction hash (on L2): 0x30758c4550035687ef13f4b1211fac9683847a9b1fab5902f6d4caf8642c4973
For more information: https://goerli-optimism.etherscan.io/tx/0x30758c4550035687ef13f4b1211fac9683847a9b1fab5902f6d4caf8642c4973
Waiting for status to be READY_TO_PROVE
Time so far 8.223 seconds
Time so far 313.661 seconds
In the challenge period, waiting for status READY_FOR_RELAY
Time so far 317.002 seconds
Ready for relay, finalizing message now
Time so far 339.851 seconds
Waiting for status to change to RELAYED
Time so far 342.437 seconds
OUTb on L1:1000 OUTb on L2:
withdrawERC20 took 351.272 seconds
As you can see, the total running time is about six minutes. It could be longer.
Next Steps
You should now be able to write applications that use our SDK and bridge to transfer ERC-20 assets between layer 1 and layer 2.
Note that for withdrawals of a commonly used ERC-20 token (or ETH) you would probably want to use a third party bridge (opens in a new tab) for higher speed and lower cost. Here is the API documentation for some of those bridges:
- Hop (opens in a new tab)
- Synapse (opens in a new tab)
- Across (opens in a new tab)
- Celer Bridge (opens in a new tab)
Detailed Breakdown of index.js
The following section provides a detailed breakdown of how everything operates under the hood. It is optional reading, of course, but might be useful if debugging or troubleshooting is needed.
#! /usr/local/bin/node
// Transfers between L1 and L2 using the Optimism SDK
const ethers = require("ethers")
const optimismSDK = require("@eth-optimism/sdk")
require('dotenv').config()
The libraries we need: ethers
(opens in a new tab), dotenv
(opens in a new tab) and the Optimism SDK itself.
const mnemonic = process.env.MNEMONIC
const l1Url = `https://eth-goerli.g.alchemy.com/v2/${process.env.GOERLI_KEY}`
const l2Url = `https://opt-goerli.g.alchemy.com/v2/${process.env.OP_GOERLI_KEY}`
Configuration, read from .env
.
// Contract addresses for OPTb tokens, taken
// from https://github.com/ethereum-optimism/ethereum-optimism.github.io/blob/master/data/OUTb/data.json
const erc20Addrs = {
l1Addr: "0x32B3b2281717dA83463414af4E8CfB1970E56287",
l2Addr: "0x3e7eF8f50246f725885102E8238CBba33F276747"
} // erc20Addrs
The addresses of the ERC-20 token on L1 and L2.
// Global variable because we need them almost everywhere
let crossChainMessenger
let l1ERC20, l2ERC20 // OUTb contracts to show ERC-20 transfers
let ourAddr // Our address
The configuration parameters required for transfers.
getSigners
This function returns the two signers (one for each layer).
const getSigners = async () => {
const l1RpcProvider = new ethers.providers.JsonRpcProvider(l1Url)
const l2RpcProvider = new ethers.providers.JsonRpcProvider(l2Url)
The first step is to create the two providers, each connected to an endpoint in the appropriate layer.
const hdNode = ethers.utils.HDNode.fromMnemonic(mnemonic)
const privateKey = hdNode.derivePath(ethers.utils.defaultPath).privateKey
To derive the private key and address from a mnemonic it is not enough to create the HDNode
(Hierarchical Deterministic Node (opens in a new tab)).
The same mnemonic can be used for different blockchains (it's originally a Bitcoin standard), and the node with Ethereum information is under ethers.utils.defaultPath
(opens in a new tab).
const l1Wallet = new ethers.Wallet(privateKey, l1RpcProvider)
const l2Wallet = new ethers.Wallet(privateKey, l2RpcProvider)
return [l1Wallet, l2Wallet]
} // getSigners
Finally, create and return the wallets. We need to use wallets, rather than providers, because we need to sign transactions.
erc20ABI
A fragment of the ABI with the functions we need to call directly.
const erc20ABI = [
// balanceOf
{
constant: true,
inputs: [{ name: "_owner", type: "address" }],
name: "balanceOf",
outputs: [{ name: "balance", type: "uint256" }],
type: "function",
},
This is balanceOf
from the ERC-20 standard, used to get the balance of an address.
// faucet
{
inputs: [],
name: "faucet",
outputs: [],
stateMutability: "nonpayable",
type: "function"
}
] // erc20ABI
This is faucet
, a function supported by the L1 contract, which gives the caller a thousand tokens.
Technically speaking we should have two ABIs, because the L2 contract does not have faucet
, but that would be a needless complication in this case when we can just avoid trying to call it.
setup
This function sets up the parameters we need for transfers.
const setup = async() => {
const [l1Signer, l2Signer] = await getSigners()
ourAddr= l1Signer.address
Get the signers we need, and our address.
crossChainMessenger = new optimismSDK.CrossChainMessenger({
l1ChainId: 5, // Goerli value, 1 for mainnet
l2ChainId: 420, // OP Goerli value, 10 for mainnet
l1SignerOrProvider: l1Signer,
l2SignerOrProvider: l2Signer,
})
Create the CrossChainMessenger
(opens in a new tab) object that we use to transfer assets.
l1ERC20 = new ethers.Contract(erc20Addrs.l1Addr, erc20ABI, l1Signer)
l2ERC20 = new ethers.Contract(erc20Addrs.l2Addr, erc20ABI, l2Signer)
} // setup
The ERC20 contracts, one per layer.
### `reportERC20Balances`
This function reports the ERC-20 balances of the address on both layers.
```js
const reportERC20Balances = async () => {
const l1Balance = (await l1ERC20.balanceOf(addr)).toString().slice(0,-18)
const l2Balance = (await l2ERC20.balanceOf(addr)).toString().slice(0,-18)
console.log(`OUTb on L1:${l1Balance} OUTb on L2:${l2Balance}`)
Get the balances.
if (l1Balance != 0)
return
If the L1 balance isn't zero, return - there is nothing we need to do.
Otherwise, call l1ERC20.faucet()
to get the user OUTb
tokens to deposit and withdraw through the bridge.
console.log(`You don't have enough OUTb on L1. Let's call the faucet to fix that`)
const tx = (await l1ERC20.faucet())
console.log(`Faucet tx: ${tx.hash}`)
console.log(`\tMore info: https://goerli.etherscan.io/tx/${tx.hash}`)
await tx.wait()
const newBalance = (await l1ERC20.balanceOf(addr)).toString().slice(0,-18)
console.log(`New L1 OUTb balance: ${newBalance}`)
} // reportERC20Balances
depositERC20
This function shows how to deposit an ERC-20 token from Ethereum to OP Mainnet (or from Goerli to OP Goerli).
const oneToken = 1000000000000000000n
OUTb
tokens are divided into $10^18$ basic units, same as ETH divided into wei.
const depositERC20 = async () => {
console.log("Deposit ERC20")
await reportERC20Balances()
To show that the deposit actually happened we show before and after balances.
const start = new Date()
// Need the l2 address to know which bridge is responsible
const allowanceResponse = await crossChainMessenger.approveERC20(
erc20Addrs.l1Addr, erc20Addrs.l2Addr, oneToken)
To enable the bridge to transfer ERC-20 tokens, it needs to get an allowance first. The reason to use the SDK here is that it looks up the bridge address for us. While most ERC-20 tokens go through the standard bridge, a few require custom business logic that has to be written into the bridge itself. In those cases there is a custom bridge contract that needs to get the allowance.
await allowanceResponse.wait()
console.log(`Allowance given by tx ${allowanceResponse.hash}`)
console.log(`\tMore info: https://goerli.etherscan.io/tx/${allowanceResponse.hash}`)
console.log(`Time so far ${(new Date()-start)/1000} seconds`)
Wait until the allowance transaction is processed and then report the time it took and the hash.
const response = await crossChainMessenger.depositERC20(
erc20Addrs.l1Addr, erc20Addrs.l2Addr, oneToken)
crossChainMessenger.depositERC20()
(opens in a new tab) creates and sends the deposit trasaction on L1.
console.log(`Deposit transaction hash (on L1): ${response.hash}`)
console.log(`\tMore info: https://goerli.etherscan.io/tx/${response.hash}`)
await response.wait()
Of course, it takes time for the transaction to actually be processed on L1.
console.log("Waiting for status to change to RELAYED")
console.log(`Time so far ${(new Date()-start)/1000} seconds`)
await crossChainMessenger.waitForMessageStatus(response.hash,
optimismSDK.MessageStatus.RELAYED)
After the transaction is processed on L1 it needs to be picked up by an offchain service and relayed to L2.
To show that the deposit actually happened we need to wait until the message is relayed.
The waitForMessageStatus
(opens in a new tab) function does this for us.
Here are the statuses we can specify (opens in a new tab).
The third parameter (which is optional) is a hashed array of options:
pollIntervalMs
: The poll intervaltimeoutMs
: Maximum time to wait
await reportERC20Balances()
console.log(`depositERC20 took ${(new Date()-start)/1000} seconds\n\n`)
} // depositERC20()
Once the message is relayed the balance change on OP Mainnet (or OP Goerli) is practically instantaneous. We can just report the balances and see that the L2 balance rose by 1 gwei.
withdrawERC20
This function shows how to withdraw ERC-20 from OP Mainnet to Ethereum (or OP Goerli to Goerli). The withdrawal process has these stages:
- Submit the withdrawal transaction on OP Mainnet (or OP Goerli)
- Wait until the state root with the withdrawal is published (and the status changes to
optimismSDK.MessageStatus.READY_TO_PROVE
) - Submit the proof on L1 using
crossChainMessenger.proveMessage()
- Wait the fault challenge period. When this period is over, the status becomes
optimismSDK.MessageStatus.READY_FOR_RELAY
- Finalize to cause the actual withdrawal on L1 using
crossChainMessenger.finalizeMessage()
const withdrawERC20 = async () => {
console.log("Withdraw ERC20")
const start = new Date()
await reportERC20Balances()
We want users to see their balances, and how long the withdrawal is taking.
const response = await crossChainMessenger.withdrawERC20(
erc20Addrs.l1Addr, erc20Addrs.l2Addr, oneToken)
console.log(`Transaction hash (on L2): ${response.hash}`)
console.log(`\tFor more information: https://goerli-optimism.etherscan.io/tx/${response.hash}`)
await response.wait()
This is the initial withdrawal transaction on OP Goerli (it would look the same on OP Mainnet).
console.log("Waiting for status to be READY_TO_PROVE")
console.log(`Time so far ${(new Date()-start)/1000} seconds`)
await crossChainMessenger.waitForMessageStatus(response.hash,
optimismSDK.MessageStatus.READY_TO_PROVE)
The Merkle proof has to be submitted after the state root is written on L1. On Goerli we usually submit a new state root every four minutes. When the state root is updated, you see a new transaction on the L2OutputOracle contract (opens in a new tab).
console.log(`Time so far ${(new Date()-start)/1000} seconds`)
await crossChainMessenger.proveMessage(response.hash)
Submit the Merkle proof, starting the challenge period.
console.log("In the challenge period, waiting for status READY_FOR_RELAY")
console.log(`Time so far ${(new Date()-start)/1000} seconds`)
await crossChainMessenger.waitForMessageStatus(response.hash,
optimismSDK.MessageStatus.READY_FOR_RELAY)
Wait the challenge period. On Goerli the challenge period is very short (a few seconds) to speed up debugging. On the production network it is seven days for security.
console.log("Ready for relay, finalizing message now")
console.log(`Time so far ${(new Date()-start)/1000} seconds`)
await crossChainMessenger.finalizeMessage(response.hash)
Finalize the withdrawal and actually get back the token.
console.log("Waiting for status to change to RELAYED")
console.log(`Time so far ${(new Date()-start)/1000} seconds`)
await crossChainMessenger.waitForMessageStatus(response,
optimismSDK.MessageStatus.RELAYED)
await reportERC20Balances()
console.log(`withdrawERC20 took ${(new Date()-start)/1000} seconds\n\n\n`)
} // withdrawERC20()
Wait for the message status to change to optimismSDK.MessageStatus.RELAYED
, at which time the tokens are finally withdrawn.
main
A main
to run the setup followed by both operations.
const main = async () => {
await setup()
await depositERC20()
await withdrawERC20()
} // main
main().then(() => process.exit(0))
.catch((error) => {
console.error(error)
process.exit(1)
})