This page explains how to create and bridge ERC20 tokens from Ethereum (L1) to Zircuit (L2) programmatically using JavaScript with the ethers library.
Create Node Environment
To get started, we need a node environment and install the ethers and eth-optimism/sdk. As Zircuit is built in part on the OP Stack, we can reuse the SDK to connect to the respective smart contracts.
npm init -y
npm install ethers @eth-optimism/sdk
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.
const { ethers } =require("ethers");// The L1 Address of the ERC20 Contract to be bridgedconstl1Erc20ContractAddress="0xYourERC20TokenAddressHere";// Partial ABIconstl1Erc20ContractABI= [ {"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 providers for L1 and L2 with the correct URLsconstproviderL1=newethers.providers.JsonRpcProvider("L1_RPC_URL");constproviderL2=newethers.providers.JsonRpcProvider("L2_RPC_URL");// Set up signers for transactionsconstprivateKey="YOUR_PRIVATE_KEY";constwalletL1=newethers.Wallet(privateKey, providerL1);constwalletL2=newethers.Wallet(privateKey, providerL2);// Create a contract instanceconsterc20L1Contract=newethers.Contract(l1Erc20ContractAddress, l1Erc20ContractABI, walletL1);
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 L2constOptimismMintableERC20FactoryAddress="0x4200000000000000000000000000000000000012";// ERC20 name, symbol, and decimals// Set these according to your ERC20consttokenName="TOKEN_NAME"consttokenSymbol="TOKEN_SYMBOL"consttokenDecimals=18// We only provide the partial ABI with createOptimismMintableERC20WithDecimals and the StandardL2TokenCreated event// The complete ABI can be extracted from the file attached aboveconstOptimismMintableERC20FactoryABI= [ {"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 }];// Connect to L2 PredeployconstL2OptimismMintableERC20FactoryContract=newethers.Contract(OptimismMintableERC20FactoryAddress, OptimismMintableERC20FactoryABI, walletL2);
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 {constresponse=awaitL2OptimismMintableERC20FactoryContract.createOptimismMintableERC20WithDecimals( l1Erc20ContractAddress, tokenName, tokenSymbol, tokenDecimals );constreceipt=awaitresponse.wait();// Fetch newly returned Address from Event Logsconstl2Erc20ContractAddress=awaitreceipt.events.find((e) =>e.event ==="StandardL2TokenCreated").args[1];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.
Double check all addresses before executing this code!
// Amount of Tokens to bridgeconsttokenAmount=100n// Proxy Address of L1StandardBridge, double check this!constL1StandardBridgeProxyAddress="0x386B76D9cA5F5Fb150B6BFB35CF5379B22B26dd8";// Partial ABI of the L1StandardBridge, complete ABI can be extracted from the files aboveconstL1StandardBridgeABI= [ {"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" }];// Load the L1StandardBridgeconstL1StandardBridgeContract=newethers.Contract( L1StandardBridgeProxyAddress, L1StandardBridgeABI, walletL1 );// Approve ERC bridge amount for L1StandardBridge(async ()=> {constapprovalResponse=awaiterc20L1Contract.approve( L1StandardBridgeProxyAddress, tokenAmount );awaitapprovalResponse.wait();})();// Bridge tokens(async ()=> {consttx=awaitL1StandardBridgeContract.populateTransaction.bridgeERC20( l1Erc20ContractAddress, l2Erc20ContractAddress, tokenAmount,80000,// GasLimit'0x'// extra Data );// Use estimated gas limit instead of hardcoding itconstestimatedGasLimit=awaitproviderL1.estimateGas(tx);console.log(`Estimated Gas Limit: ${estimatedGasLimit.toString()}`);// Manually set the gas limit for the transactionconsttransaction= {...tx, gasLimit: estimatedGasLimit // Use estimated gas limit };constbridgeResponse=awaitwalletL1.sendTransaction(transaction);awaitbridgeResponse.wait();})
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
const { ethers } =require("ethers");constoptimism=require("@eth-optimism/sdk");const { CrossChainMessenger,DEFAULT_L2_CONTRACT_ADDRESSES } =require('@eth-optimism/sdk');// Set up providers for L1 and L2 with the correct URLsconstproviderL1=newethers.providers.JsonRpcProvider("L1_RPC");constproviderL2=newethers.providers.JsonRpcProvider("L2_RPC");// Set up signers for transactionsconstprivateKey="YOUR_PRIVATE_KEY";constwalletL1=newethers.Wallet(privateKey, providerL1);constwalletL2=newethers.Wallet(privateKey, providerL2);constL2StandardBridgeAddress="0x4200000000000000000000000000000000000010";// Partial ABIconstL2StandardBridgeAbi= [ {"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" }];constL2StandardBridgeContract=newethers.Contract( L2StandardBridgeAddress, L2StandardBridgeAbi, walletL2);constl2Token='0xL2_Token_Address'; // Replace with actual L2 token addressconstl1Token='0xL1_Token_Address'; // Replace with actual L1 token addressconsttokenAmount=100n; // Amount of Tokens to withdraw// Initiate Withdrawal on L2let withdrawalTransactionHash ='';(async () => {try {constresponse=awaitL2StandardBridgeContract.bridgeERC20( l2Token, l1Token, tokenAmount,500000,// Gas Limit'0x'// data (empty in this case) );constreceipt=awaitresponse.wait();constwithdrawalTransactionHash=receipt.transactionHash;console.log("withdrawalTransactionHash: ", withdrawalTransactionHash); } catch (error) {console.error('Error during token bridging:', error); }})();
The withdrawal process can be completed from the Bridge application just like normal withdrawals, otherwise one can use the Optimism SDK to create a CrossChainMessenger instance which processes the withdrawal. The respective L1 addresses need to be set for this.
// Double check these addresses and insert the Proxy Addresses for the respective Contractconstl1_addresses= { L1CrossDomainMessenger:'0x2De7B7364A37fBB35F946cA7175A1b596710b262', L1StandardBridge:'0x0545c5fe980098C16fcD0eCB5E79753afa6d9af9', OptimismPortal:'0x787f1C8c5924178689E0560a43D848bF8E54b23e', L2OutputOracle:'0x740C2dac453aEf7140809F80b72bf0e647af8148',// Unused but have to be set for unknown chain IDs AddressManager:'0x0000000000000000000000000000000000000000', StateCommitmentChain:'0x0000000000000000000000000000000000000000', CanonicalTransactionChain:'0x0000000000000000000000000000000000000000', BondManager:'0x0000000000000000000000000000000000000000',};constmessenger=newCrossChainMessenger({ l1ChainId: (awaitproviderL1.getNetwork()).chainId, l2ChainId: (awaitproviderL2.getNetwork()).chainId, l1SignerOrProvider: walletL1, l2SignerOrProvider: walletL2, bedrock:true, contracts: { l1: l1_addresses, l2:DEFAULT_L2_CONTRACT_ADDRESSES, },});
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 hourconsole.log("Waiting for Rollup Process to finish");awaitmessenger.waitForMessageStatus(withdrawalTransactionHash,optimism.MessageStatus.READY_TO_PROVE);
Prove the withdrawal on L1
Once the withdrawal is ready to be proven, you'll send an L1 transaction to prove that the withdrawal happened on L2.
The final step to withdrawing tokens from L2 to L1 is to relay the withdrawal on L1. This can only happen after the finalization period has elapsed as well. This finalization period lasts 5 hours on Zircuit. Once the withdrawal is ready to be relayed, you can finally complete the withdrawal process.
// 5 Hours on Zircuit at the momentconsole.log("Waiting for finalization period to elapse");awaitmessenger.waitForMessageStatus(withdrawalTransactionHash,optimism.MessageStatus.READY_FOR_RELAY);// Once the withdrawal is ready to be relayed, you can finally complete the withdrawal process.awaitmessenger.finalizeMessage(withdrawalTransactionHash);// Now you simply wait until the message is relayed.awaitmessenger.waitForMessageStatus(withdrawalTransactionHash,optimism.MessageStatus.RELAYED);console.log("withdrawal finalized");
Congrats! You've just deposited and withdrawn tokens on Zircuit.