Bridging ERC-20 tokens with the Specular SDK

This tutorial teaches you how to use the Specular SDK to transfer ETH between Layer 1 (Sepolia) and Layer 2 (Specular Testnet).


  1. Prerequisites:

  2. Clone this repository and enter it.

    git clone
    cd specular-tutorial/SDK/bridge-eth
  3. Install the necessary packages.

  4. Copy .env.example to .env and edit it:

    1. Set PRIVATE_KEY to point to an account that has ETH on Sepolia.

    2. Set SEPOLIA_RPC_ENDPOINT to your Sepolia RPC url or leave it to the default that we provide.

    3. Set SPECULAR_RPC_ENDPOINT to your Specular RPC url or leave it to the default that we provide.

If you don't have ETH on your wallet fetch some from a Sepolia Faucet and bridge some of those native ETH to Specular;

Run the sample code

The sample code is in index.js, execute it. After you execute it, wait. It is not unusual for each operation to take minutes on Sepolia. On the production network the withdrawals take around a week each, because of the challenge period.

How does it work?

// Transfers between L1 and L2 using the Specular SDK

const ethers = require("ethers");
const specularSDK = require("@specularl2/sdk");

The libraries we need: ethers, dotenv and the Specular SDK.

Configuration, read from .env.

const privateKey = process.env.PRIVATE_KEY;
const l1Url = process.env.SEPOLIA_RPC_ENDPOINT;
const l2Url = process.env.SPECULAR_RPC_ENDPOINT;

The addresses of the ERC-20 token on L1 and L2.

// enter the L1 and the L2 address of the desired ERC20 token
const erc20Addrs = {
  l1Addr: "",
  l2Addr: "",
// Global variable because we need them almost everywhere
let serviceBridge;
let l1ERC20, l2ERC20; // specific ERC20 contracts to show ERC20 to show ERC-20 transfers
let addr; // Our address

The configuration parameters required for transfers.


This function returns the two signers (one for each layer).

Finally, create and return the wallets. We need to use wallets, rather than providers, because we need to sign transactions.

const getSigners = async () => {
    const l1RpcProvider = new ethers.providers.JsonRpcProvider(l1Url)
    const l2RpcProvider = new ethers.providers.JsonRpcProvider(l2Url)
    const l1Wallet = new ethers.Wallet(privateKey, l1RpcProvider)
    const l2Wallet = new ethers.Wallet(privateKey, l2RpcProvider)

    return [l1Wallet, l2Wallet]
}   // getSigners


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.


This function sets up the parameters we need for transfers.

const setup = async() => {
  const [l1Signer, l2Signer] = await getSigners()
  addr = l1Signer.address

Get the signers we need, and our address.

Create the serviceBridge object that we use to transfer assets.

serviceBridge = new specularSDK.serviceBridge({
  l1SignerOrProvider: l1Signer,
  l2SignerOrProvider: l2Signer,
  l1ChainId: 11155111,
  l2ChainId: 93481,

Variables that make it easier to convert between WEI and ETH

Both ETH and DAI are denominated in units that are 10^18 of their basic unit. These variables simplify the conversion.

const gwei = 1000000000n;
const eth = gwei * gwei; // 10^18
const centieth = eth / 100n;


This function reports the ETH balances of the address on both layers.

const reportBalances = async () => {
  const l1Balance = (await serviceBridge.l1Signer.getBalance())
    .slice(0, -9);
  const l2Balance = (await serviceBridge.l2Signer.getBalance())
    .slice(0, -9);

  console.log(`On L1:${l1Balance} Gwei    On L2:${l2Balance} Gwei`);
}; // reportBalances

This tutorial teaches you how to use the Specular SDK to transfer ERC-20 tokens between Layer 1 (Sepolia) and Layer 2 (Specular Testnet).

Warning: The standard bridge does not support certain ERC-20 configurations:

Create the ServiceBridge 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.

// This function reports the ERC-20 balances of the address on both layers.
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(`Token on L1:${l1Balance}     Token on L2:${l2Balance}`)


This function shows how to deposit ETH from Ethereum to Specular.

const oneToken = 1000000000000000000n;

ERC20 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.

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.

// Need the l2 address to know which bridge is responsible
const allowanceResponse = await serviceBridge.approveERC20(
await allowanceResponse.wait();
console.log(`Allowance given by tx ${allowanceResponse.hash}`);

Wait until the allowance transaction is processed and then report the time it took and the hash.

serviceBridge.depositERC20() creates and sends the deposit trasaction on L1.

const depositERC20Response = await serviceBridge.depositERC20(
const depositERC20Receipt = await depositERC20Response.wait(2);

Of course, it takes time for the transaction to actually be processed on L1.

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 getDepositStatus function does this for us.

Once the messageStatus is ready the transaction can be finalized and finally settled on L2.

let messageStatus = await serviceBridge.getDepositStatus(depositERC20Receipt);

 while (messageStatus == 0) {
    await delay(5000);
    console.log("...Waiting for the TX to be ready for finalization...");
    messageStatus = await serviceBridge.getDepositStatus(depositERC20Receipt);

  const finalizeDepositResponse =
    await serviceBridge.finalizeDeposit(depositETHReceipt);

  const finalizeDepositReceipt = await finalizeDepositResponse.wait();


This function shows how to withdraw ERC-20 from Specular to Sepolia. The withdrawal process has these stages:

  1. Submit the withdrawal transaction on Specular Testnet

  2. Wait until the state root with the withdrawal is published (and the status changes to ready).

  3. Finalize to cause the actual withdrawal on L1 using serviceBridge.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 withdrawalERC20Response = await serviceBridge.withdrawERC20(

const withdrawalERC20Receipt = await withdrawalERC20Response.wait();

This is the initial withdrawal transaction Specular.

let messageStatus =
    await serviceBridge.getWithdrawalStatus(withdrawalERC20Receipt);

  while (messageStatus == 0) {
    console.log("...Waiting for the TX to be ready for finalization...");
    await delay(5000);
    messageStatus =
      await serviceBridge.getWithdrawalStatus(withdrawalERC20Receipt);

  const finalizeWithdrawalResponse =
    await serviceBridge.finalizeWithdrawal(withdrawalERC20Receipt);

  const finalizeWithdrawalReceipt = await finalizeWithdrawalResponse.wait();

  console.log({ finalizeWithdrawalReceipt });

Finalize the withdrawal and get back the token to L1.


A main to run the setup followed by both operations.

const main = async () => {
  await setup();
  await depositERC20();
  await withdrawERC20();
}; // main

  .then(() => process.exit(0))
  .catch((error) => {


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.

Last updated