Bridging ERC20 Tokens Manually
This page explains how to create and bridge ERC20 tokens from Ethereum (L1) to Zircuit (L2) programmatically using JavaScript with the ethers
and zircuit-viem
libraries.
Create Node Environment
To get started, we need a node environment and install the ethers
and zircuit-viem
. Zircuit is built in part on the OP Stack, but certain withdrawal mechanisms have been modified to reduce user-side costs. To support these changes, we provide our own fork of the viem
library called zircuit-viem
for communicating with the network.
npm init -y
npm install @zircuit/zircuit-viem
Connect to Smart Contracts on L1 and L2
Next, we need to set the following variables in a new file. The following adapted code snippets can be saved to a file called bridge.js
. You can run the script using Node.js. This tutorial expects, that the ERC20 contract has already been deployed on L1 and the user has a token balance. Next, we begin the setup. We connect the signing wallet to the L1 contract. RPC URLs, the private key as well as the address of the ERC20 contract have to be adapted.
import { createWalletClient, createPublicClient, http, parseEventLogs, encodeFunctionData } from '@zircuit/zircuit-viem'
import { privateKeyToAccount } from '@zircuit/zircuit-viem/accounts'
import { mainnet, zircuit } from '@zircuit/zircuit-viem/chains'
// The L1 Address of the ERC20 Contract to be bridged
const l1Erc20ContractAddress = "0xYourERC20TokenAddressHere";
// Partial ABI
const l1Erc20ContractABI = [
{
"inputs": [
{
"internalType": "address",
"name": "guy",
"type": "address"
},
{
"internalType": "uint256",
"name": "wad",
"type": "uint256"
}
],
"name": "approve",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "nonpayable",
"type": "function"
}
];
// Set up L1 and L2 with the correct URLs
const L1_RPC_URL = "L1_RPC_URL";
const L2_RPC_URL = "L2_RPC_URL";
// Import account from private key
const account = privateKeyToAccount('0xYOUR_PRIVATE_KEY')
// Create clients
const walletClientL1 = createWalletClient({
account,
chain: mainnet,
transport: http(L1_RPC_URL)
})
const walletClientL2 = createWalletClient({
account,
chain: zircuit,
transport: http(L2_RPC_URL)
})
const publicClientL1 = createPublicClient({
chain: mainnet,
transport: http(L1_RPC_URL)
})
const publicClientL2 = createPublicClient({
chain: zircuit,
transport: http(L2_RPC_URL)
})
We also need to connect to the ERC20 Factory on Zircuit L2 to bridge ERC20s. The OptimismMintableERC20FactoryAddress
is a predeployed contract on L2. The name, symbol and decimals of the ERC20 have to be adapted accordingly.
// Connect to the ERC20 Factory contract on Zircuit
// This is a predeployed contract to create ERC20s on L2
const OptimismMintableERC20FactoryAddress = "0x4200000000000000000000000000000000000012";
// ERC20 name, symbol, and decimals
// Set these according to your ERC20
const tokenName = "TOKEN_NAME"
const tokenSymbol = "TOKEN_SYMBOL"
const tokenDecimals = 18
// We only provide the partial ABI with createOptimismMintableERC20WithDecimals and the StandardL2TokenCreated event
// The complete ABI can be extracted from the file attached above
const OptimismMintableERC20FactoryABI = [
{
"type": "function",
"name": "createOptimismMintableERC20WithDecimals",
"inputs": [
{
"name": "_remoteToken",
"type": "address",
"internalType": "address"
},
{
"name": "_name",
"type": "string",
"internalType": "string"
},
{
"name": "_symbol",
"type": "string",
"internalType": "string"
},
{
"name": "_decimals",
"type": "uint8",
"internalType": "uint8"
}
],
"outputs": [
{
"name": "",
"type": "address",
"internalType": "address"
}
],
"stateMutability": "nonpayable"
},
{
"type": "event",
"name": "StandardL2TokenCreated",
"inputs": [
{
"name": "remoteToken",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "localToken",
"type": "address",
"indexed": true,
"internalType": "address"
}
],
"anonymous": false
}
];
Create ERC20 contract on Zircuit L2
Next we make a call createOptimismMintableERC20WithDecimals
to the L2 factory contract to create the ERC20 on Zircuit L2.
let l2Erc20ContractAddress= "";
(async () => {
try {
// Send transaction
const hash = await walletClientL2.writeContract({
address: OptimismMintableERC20FactoryAddress,
abi: OptimismMintableERC20FactoryABI,
functionName: 'createOptimismMintableERC20WithDecimals',
args: [l1Erc20ContractAddress, tokenName, tokenSymbol, tokenDecimals]
})
// Wait for confirmation
const receipt = await publicClientL2.waitForTransactionReceipt({ hash })
// Parse logs to find StandardL2TokenCreated event
const events = parseEventLogs({
abi: OptimismMintableERC20FactoryABI,
logs: receipt.logs,
eventName: 'StandardL2TokenCreated'
})
l2Erc20ContractAddress = events[0].args.localToken
console.log('L2 ERC20 Contract Address:', l2Erc20ContractAddress)
} catch (error) {
console.error('Error:', error)
}
})()
Congratulations, you have successfully deployed the contract on Zircuit on the returned address.
Bridge ERC20 Tokens to L2
With the ERC20 Token deployed on L1 and L2, a user can now bridge actual tokens from that L1 contract to the L2 contract. To bridge tokens, we need to connect to the L1StandardBridge
contract, approve the bridge amount and then bridge the tokens to L2. The L1StandardBridgeProxy
address can be found on this page Contract Addresses. Also, the token amount needs to be set.
// Amount of Tokens to bridge
const tokenAmount = 100n
// Proxy Address of L1StandardBridge, double check this!
const L1StandardBridgeProxyAddress = "0x386B76D9cA5F5Fb150B6BFB35CF5379B22B26dd8";
// Partial ABI of the L1StandardBridge, complete ABI can be extracted from the files above
const L1StandardBridgeABI = [
{
"inputs": [
{
"internalType": "address",
"name": "_localToken",
"type": "address"
},
{
"internalType": "address",
"name": "_remoteToken",
"type": "address"
},
{
"internalType": "uint256",
"name": "_amount",
"type": "uint256"
},
{
"internalType": "uint32",
"name": "_minGasLimit",
"type": "uint32"
},
{
"internalType": "bytes",
"name": "_extraData",
"type": "bytes"
}
],
"name": "bridgeERC20",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}
];
// Bridge tokens
(async () => {
const approvalHash = await walletClientL1.writeContract({
address: l1Erc20ContractAddress,
abi: l1Erc20ContractABI,
functionName: 'approve',
args: [L1StandardBridgeProxyAddress, tokenAmount]
})
await publicClientL1.waitForTransactionReceipt({ hash: approvalHash })
console.log('Approval transaction confirmed:', approvalHash)
// Encode bridgeERC20 call
const txData = encodeFunctionData({
abi: L1StandardBridgeABI,
functionName: 'bridgeERC20',
args: [
l1Erc20ContractAddress,
l2Erc20ContractAddress,
tokenAmount,
80000n, // GasLimit
'0x' // Extra data
]
})
// Prepare unsigned transaction
const tx = {
to: L1StandardBridgeProxyAddress,
data: txData
}
// Estimate gas
const estimatedGasLimit = await publicClientL1.estimateGas({
account: walletClientL1.account,
...tx
})
console.log(`Estimated Gas Limit: ${estimatedGasLimit}`)
// Send with gas limit
const bridgeHash = await walletClientL1.sendTransaction({
...tx,
gas: estimatedGasLimit
})
await publicClientL1.waitForTransactionReceipt({ hash: bridgeHash })
console.log('Bridge transaction confirmed:', bridgeHash)
})();
Congratulations, the ERC20 Tokens are now bridged to Zircuit.
Withdrawing ERC20 Tokens from L2 to L1
As with normal withdrawals, withdrawing assets is a three step process of initiating a withdrawal on L2, and then proving and finalizing the withdrawal transaction on L1. You can place the following code into a new file called withdrawal.js
. Again, the RPC URLs and the private key needs to be set. Also, the token amount and the contract addresses of the ERC20 need to be set.
Start your withdrawal on L2
import { createWalletClient, createPublicClient, http, encodeFunctionData } from '@zircuit/zircuit-viem'
import { privateKeyToAccount } from '@zircuit/zircuit-viem/accounts'
import { mainnet, zircuit } from '@zircuit/zircuit-viem/chains'
import {
getL2TransactionHashes,
getWithdrawals,
publicActionsL1,
publicActionsL2,
walletActionsL1,
walletActionsL2,
} from '@zircuit/zircuit-viem/op-stack';
const L2StandardBridgeAddress = '0x4200000000000000000000000000000000000010'
const L2StandardBridgeAbi = [
{
inputs: [
{ internalType: 'address', name: '_localToken', type: 'address' },
{ internalType: 'address', name: '_remoteToken', type: 'address' },
{ internalType: 'uint256', name: '_amount', type: 'uint256' },
{ internalType: 'uint32', name: '_minGasLimit', type: 'uint32' },
{ internalType: 'bytes', name: '_extraData', type: 'bytes' }
],
name: 'bridgeERC20',
outputs: [],
stateMutability: 'nonpayable',
type: 'function'
}
]
const l2Token = '0xYourL2ERC20TokenAddressHere'
const l1Token = '0xYourL1ERC20TokenAddressHere'
const tokenAmount = 100n
const L1_RPC_URL = "L1_RPC_URL";
const L2_RPC_URL = 'L2_RPC_URL'
const account = privateKeyToAccount('0xYOUR_PRIVATE_KEY')
const walletClientL1 = createWalletClient({
account,
chain: mainnet,
transport: http(L1_RPC_URL)
}).extend(walletActionsL1());
const walletClientL2 = createWalletClient({
account,
chain: zircuit,
transport: http(L2_RPC_URL)
}).extend(walletActionsL2());
const publicClientL1 = createPublicClient({
chain: mainnet,
transport: http(L1_RPC_URL)
}).extend(publicActionsL1());
const publicClientL2 = createPublicClient({
chain: zircuit,
transport: http(L2_RPC_URL)
}).extend(publicActionsL2());
let withdrawalHash = ''
await (async () => {
try {
// Encode bridgeERC20 call data
const data = encodeFunctionData({
abi: L2StandardBridgeAbi,
functionName: 'bridgeERC20',
args: [l2Token, l1Token, tokenAmount, 500000, '0x']
})
const tx = {
to: L2StandardBridgeAddress,
data
}
// Send transaction
const txHash = await walletClientL2.sendTransaction(tx)
// Wait for confirmation
const receipt = await publicClientL2.waitForTransactionReceipt({ hash: txHash })
withdrawalHash = receipt.transactionHash;
console.log('withdrawalTransactionHash:', withdrawalHash)
} catch (error) {
console.error('Error during token bridging:', error)
}
})();
const optimismPortalAddress= '0x17bfAfA932d2e23Bd9B909Fd5B4D2e2a27043fb1'
const l2OutputOracleAddress= '0x92Ef6Af472b39F1b363da45E35530c24619245A4'
const optimismPortalAbi = [
{
inputs: [
{
internalType: 'bytes32',
name: '',
type: 'bytes32'
}
],
name: 'finalizedWithdrawals',
outputs: [
{
internalType: 'bool',
name: '',
type: 'bool'
}
],
stateMutability: 'view',
type: 'function'
}
]
Wait until the withdrawal is ready to prove
The second step to withdrawing tokens from L2 to L1 is to prove to the bridge on L1 that the withdrawal happened on L2. You first need to wait until the rollup process of the previous L2 transaction is finished and the withdrawal is ready to prove. This may take up to an hour to finish the rollup process.
// Can take up to 1 hour
console.log("Waiting for Rollup Process to finish");
const receipt = await publicClientL2.getTransactionReceipt({
hash: withdrawalHash
})
try {
const status = await publicClientL1.getWithdrawalStatus({
receipt,
targetChain: publicClientL2.chain
});
console.log("status: ", status);
} catch (error) {
console.error("Status not avaliable yet.");
}
Prove and release the withdrawal on L1
Once the withdrawal is ready to be proven, you will send an L1 transaction to prove that the withdrawal happened on L2, and to release it. This can only happen after the finalization period elapsed. The finalization period lasts 5 hours on Zircuit. When the withdrawal is ready to be relayed, you can finally complete the withdrawal process.
if (status == 'ready-to-prove') {
// If your transaction contains multiple withdrawals you can
// extract each withdrawal with getWithdrawals and process it separately
const [withdrawal] = getWithdrawals(receipt);
const finalizationPeriodSeconds = await publicClientL1.readContract({
address: l2OutputOracleAddress,
abi: l2OutputOracleAbi,
functionName: 'finalizationPeriodSeconds',
});
// 5 Hours on Zircuit at the moment
console.log(
`Waiting for finalization period to pass: ${finalizationPeriodSeconds}`
);
// Once the withdrawal is ready to be relayed, you can finally complete the withdrawal process.
// First you fetch the first rolled up state after the withdrawal submission
const output = await publicClientL1.getL2Output({
l2BlockNumber: receipt.blockNumber,
targetChain: zircuit
});
// Then you compute the withdrawal proof
const args = await publicClientL2.buildProveZircuitWithdrawal({
output,
receipt,
withdrawal,
});
// And finally you send the withdrawal release transaction
const proveHash = await walletClientL1.proveWithdrawal(args);
const proveReceipt = await publicClientL1.waitForTransactionReceipt({ hash: proveHash })
console.log('proveWithdrawal:', proveReceipt.transactionHash)
// Now you simply wait until the message is relayed.
const isFinalized = await publicClientL1.readContract({
abi: optimismPortalAbi,
address: optimismPortalAddress,
functionName: 'finalizedWithdrawals',
args: [withdrawal.withdrawalHash],
});
console.log(isFinalized);
}
Congrats! You've just deposited and withdrawn tokens on Zircuit.
Last updated