Bridging ETH 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.

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

Configuration, read from .env.

// Global variable because we need them almost everywhere
let serviceBridge;
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


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 function shows how to deposit ETH from Ethereum to Specular.

To show that the deposit actually happened we show before and after balances.

const depositETH = async () => {

  console.log("Deposit ETH")
  await reportBalances()

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

 const response = await serviceBridge.depositETH(gwei);

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

After the transaction is processed on L1 it needs to be picked up by an offchain service and relayed to L2 by the L1Oracle contract.

To show that the deposit actually happened we need to wait until the message is relayed. The waitForMessageStatus function does this for us.

 const depositETHReceipt = await depositETHResponse.wait(2);
 let messageStatus = await serviceBridge.getDepositStatus(depositETHReceipt);

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

Once the message is relayed by the L1Oracle contract the messageStatus will be different from 0 implying that it will be ready to be finalized.

serviceBridge.finalizeDeposit(transactionReceipt) finalizes the deposit settling the funds on L2.

 const finalizeDepositResponse = await serviceBridge.finalizeDeposit(depositETHReceipt);

 // waiting for the finalization receipt
 const finalizeDepositReceipt = await finalizeDepositResponse.wait();
 console.log({ finalizeDepositReceipt });


For deposits it was enough to transfer 1 gwei to show that the L2 balance increases.

However, in the case of withdrawals the withdrawing account needs to pay on L1 for finalizing the message, which costs more than that.

By sending 0.01 ETH it is guaranteed that the withdrawal will actually increase the L1 ETH balance instead of decreasing it.

const withdrawETH = async () => {
  console.log("Withdraw ETH")
  await reportBalances()

  const withdrawalETHResponse = await serviceBridge.withdrawETH(centieth);

  const withdrawalETHReceipt = await withdrawalETHResponse.wait();

This is the initial withdrawal transaction on Specular.

  let messageStatus =
    await serviceBridge.getWithdrawalStatus(withdrawalETHReceipt);

  // while the message status is pending, keep waiting
  while (messageStatus == 0) {
    console.log("...Waiting for the TX to be ready for finalization...");
    await delay(5000);
    messageStatus =
      await serviceBridge.getWithdrawalStatus(withdrawalETHReceipt);

Finalize the withdrawal and actually get back the 0.01 ETH.

 const finalizeWithdrawalResponse = await serviceBridge.finalizeWithdrawal(withdrawalETHReceipt);

 const finalizeWithdrawalReceipt = await finalizeWithdrawalResponse.wait();

 console.log({ finalizeWithdrawalReceipt })

}  // withdrawETH()


A main to run the setup followed by both operations.

const main = async () => {
  await setup();
  await depositETH();
  await withdrawETH();
}; // main

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


You should now be able to write applications that use our SDK and bridge to transfer ETH between layer 1 and layer 2.

Last updated