Bridging ERC20 Tokens manually

This page explains how to create and bridge ERC20 tokens from Sepolia L1 to Zircuit Testnet 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 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 providers for L1 and L2 with the correct URLs
const providerL1 = new ethers.providers.JsonRpcProvider("L1_RPC_URL");
const providerL2 = new ethers.providers.JsonRpcProvider("L2_RPC_URL");

// Set up signers for transactions
const privateKey = "YOUR_PRIVATE_KEY";
const walletL1 = new ethers.Wallet(privateKey, providerL1);
const walletL2 = new ethers.Wallet(privateKey, providerL2);

// Create a contract instance
const erc20L1Contract = new ethers.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 Testnet
// 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
    }
];

// Connect to L2 Predeploy
const L2OptimismMintableERC20FactoryContract = new ethers.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 {
    const response = await L2OptimismMintableERC20FactoryContract.createOptimismMintableERC20WithDecimals(
      l1Erc20ContractAddress, 
      tokenName,
      tokenSymbol,
      tokenDecimals
    );
    const receipt = await response.wait();
    // Fetch newly returned Address from Event Logs
    const l2Erc20ContractAddress = await receipt.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 Testnet 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 = "0x0545c5fe980098C16fcD0eCB5E79753afa6d9af9";

// 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"
  }
];

// Load the L1StandardBridge
const L1StandardBridgeContract = new ethers.Contract(
    L1StandardBridgeProxyAddress,
    L1StandardBridgeABI,
    walletL1
  );

// Approve ERC bridge amount for L1StandardBridge
(async ()=> {
  const approvalResponse = await erc20L1Contract.approve(
    L1StandardBridgeProxyAddress,
    tokenAmount
    );
    
  await approvalResponse.wait();
  
})();

// Bridge tokens
(async ()=> {
  const tx = await L1StandardBridgeContract.populateTransaction.bridgeERC20(
    l1Erc20ContractAddress,
    l2Erc20ContractAddress,
    tokenAmount,
    80000, // GasLimit
    '0x'   // extra Data
  );
  
  // Use estimated gas limit instead of hardcoding it
  const estimatedGasLimit = await providerL1.estimateGas(tx);
  console.log(`Estimated Gas Limit: ${estimatedGasLimit.toString()}`);

  // Manually set the gas limit for the transaction
  const transaction = {
    ...tx,
    gasLimit: estimatedGasLimit // Use estimated gas limit
  };
  
  const bridgeResponse = await walletL1.sendTransaction(transaction);
  await bridgeResponse.wait();
})

Congratulations, the ERC20 Tokens are now bridged to Zircuit Testnet. The complete code can be found in the bridge.js file below.

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");
const optimism = require("@eth-optimism/sdk");
const { CrossChainMessenger, DEFAULT_L2_CONTRACT_ADDRESSES } = require('@eth-optimism/sdk');


// Set up providers for L1 and L2 with the correct URLs
const providerL1 = new ethers.providers.JsonRpcProvider("L1_RPC");
const providerL2 = new ethers.providers.JsonRpcProvider("L2_RPC");

// Set up signers for transactions
const privateKey = "YOUR_PRIVATE_KEY";
const walletL1 = new ethers.Wallet(privateKey, providerL1);
const walletL2 = new ethers.Wallet(privateKey, providerL2);

const L2StandardBridgeAddress = "0x4200000000000000000000000000000000000010";

// Partial ABI
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 L2StandardBridgeContract = new ethers.Contract(
  L2StandardBridgeAddress,
  L2StandardBridgeAbi,
  walletL2
);

const l2Token = '0xL2_Token_Address'; // Replace with actual L2 token address
const l1Token = '0xL1_Token_Address'; // Replace with actual L1 token address
const tokenAmount = 100n; // Amount of Tokens to withdraw
  
// Initiate Withdrawal on L2
let withdrawalTransactionHash = '';
(async () => {
  try {
    const response = await L2StandardBridgeContract.bridgeERC20(
      l2Token,
      l1Token,
      tokenAmount,
      500000, // Gas Limit
      '0x'    // data (empty in this case)
    );

    const receipt = await response.wait();
    const withdrawalTransactionHash = 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 Contract
const l1_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',
};

const messenger = new CrossChainMessenger({
    l1ChainId: (await providerL1.getNetwork()).chainId,
    l2ChainId: (await providerL2.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 hour
console.log("Waiting for Rollup Process to finish");
await messenger.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.

const proveResponse = await messenger.proveMessage(withdrawalTransactionHash);
await proveResponse.wait();
console.log(`proveMessage tx: ${proveResponse.hash}`);

Wait until the withdrawal is Ready For Relay

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 minutes on Zircuit Testnet . Once the withdrawal is ready to be relayed, you can finally complete the withdrawal process.

// 5 Minutes on Testnet
console.log("Waiting for finalization period to elapse");
await messenger.waitForMessageStatus(withdrawalTransactionHash, optimism.MessageStatus.READY_FOR_RELAY);

// Once the withdrawal is ready to be relayed, you can finally complete the withdrawal process.
await messenger.finalizeMessage(withdrawalTransactionHash);

// Now you simply wait until the message is relayed.
await messenger.waitForMessageStatus(withdrawalTransactionHash, optimism.MessageStatus.RELAYED);

console.log("withdrawal finalized");

Congrats! You've just deposited and withdrawn tokens on Zircuit Testnet. The complete withdrawal code can be found in the withdrawal.js file below.

Last updated