Bridging ETH with viem

Bridging ETH to OP Mainnet with Viem

This tutorial explains how you can use Viem (opens in a new tab) to bridge ETH from L1 (Ethereum or Sepolia) to L2 (OP Mainnet or OP Sepolia). Viem is a TypeScript interface for Ethereum that provides low-level stateless primitives for interacting with Ethereum. It offers an easy way to add bridging functionality to your JavaScript-based application.

Behind the scenes, Viem uses the Standard Bridge contracts to transfer ETH and ERC-20 tokens. Make sure to check out the Standard Bridge guide if you want to learn more about how the bridge works under the hood.

Supported networks

Viem supports any of the Superchain networks. The OP Stack networks are included in Viem by default. If you want to use a network that isn't included by default, you can add it to Viem's chain configurations.

Dependencies

Create a demo project

You're going to use Viem for this tutorial. Since Viem is a Node.js (opens in a new tab) library, you'll need to create a Node.js project to use it.

Make a project folder

mkdir bridge-eth
cd bridge-eth

Initialize the project

pnpm init

Install the Viem library

pnpm add viem

Want to create a new wallet for this tutorial? If you have cast (opens in a new tab) installed you can run cast wallet new in your terminal to create a new wallet and get the private key.

Get ETH on Sepolia

This tutorial explains how to bridge ETH from Sepolia to OP Sepolia. You will need to get some ETH on Sepolia to follow along.

You can use this faucet (opens in a new tab) to get ETH on Sepolia.

Add a private key to your environment

You need a private key in order to sign transactions. Set your private key as an environment variable with the export command. Make sure this private key corresponds to an address that has ETH on Sepolia.

export TUTORIAL_PRIVATE_KEY=0x...

Start the Node REPL

You're going to use the Node REPL to interact with Viem. To start the Node REPL run the following command in your terminal:

node

This will bring up a Node REPL prompt that allows you to run JavaScript code.

Import dependencies

You need to import some dependencies into your Node REPL session.

Import Viem and other packages

const { createPublicClient, http, createWalletClient, parseEther, formatEther } = require('viem');
const { sepolia, optimismSepolia } = require('viem/chains');
const { privateKeyToAccount } = require('viem/accounts');
const { getL2TransactionHashes, publicActionsL1, publicActionsL2, walletActionsL1, walletActionsL2 } = require('viem/op-stack');

Load private key and set account

const PRIVATE_KEY = process.env.TUTORIAL_PRIVATE_KEY;
const account = privateKeyToAccount(PRIVATE_KEY);

Create L1 public client for reading from the Sepolia network

const publicClientL1 = createPublicClient({
    chain: sepolia,
    transport: http("https://rpc.ankr.com/eth_sepolia"),
}).extend(publicActionsL1()) 

Create L1 wallet client for sending transactions on Sepolia

const walletClientL1 = createWalletClient({
    account,
    chain: sepolia,
    transport: http("https://rpc.ankr.com/eth_sepolia"),
}).extend(walletActionsL1());

Create L2 public client for interacting with OP Sepolia

const publicClientL2 = createPublicClient({
    chain: optimismSepolia,
    transport: http("https://sepolia.optimism.io"),
}).extend(publicActionsL2());

Create L2 wallet client on OP Sepolia

const walletClientL2 = createWalletClient({
    account,
    chain: optimismSepolia,
    transport: http("https://sepolia.optimism.io"),
}).extend(walletActionsL2());

Get ETH on Sepolia

You're going to need some ETH on L1 that you can bridge to L2. You can get some Sepolia ETH from this faucet (opens in a new tab).

Deposit ETH

Now that you have some ETH on L1 you can deposit that ETH into the L1StandardBridge contract. You'll then receive the same number of ETH on L2 in return.

Check your wallet balance on L1

See how much ETH you have on L1 so you can confirm that the deposit worked later on.

const l1Balance = await publicClientL1.getBalance({ address: account.address });
console.log(`L1 Balance: ${formatEther(l1Balance)} ETH`); 

We used formatEther method from viem to format the balance to ether.

Create the deposit transaction

Use buildDepositTransaction to build the deposit transaction parameters on the L2.

const depositArgs = await publicClientL2.buildDepositTransaction({
    mint: parseEther("0.0001"),
    to: account.address,
});

Send the deposit transaction

Send the deposit transaction on L1 and log the L1 transaction hash.

const depositHash = await walletClientL1.depositTransaction(depositArgs);
console.log(`Deposit transaction hash on L1: ${depositHash}`);

Wait for L1 transaction

Wait for the L1 transaction to be processed and log the receipt.

const depositReceipt = await publicClientL1.waitForTransactionReceipt({ depositHash });
console.log('L1 transaction confirmed:', depositReceipt);

Extract the L2 transaction hash

Extracts the corresponding L2 transaction hash from the L1 receipt, and logs it. This hash represents the deposit transaction on L2.

const [l2Hash] = getL2TransactionHashes(depositReceipt);
console.log(`Corresponding L2 transaction hash: ${l2Hash}`);

Wait for the L2 transaction to be processed

Wait for the L2 transaction to be processed and confirmed and logs the L2 receipt to verify completion.

const l2Receipt = await publicClientL2.waitForTransactionReceipt({
    hash: l2Hash,
}); 
console.log('L2 transaction confirmed:', l2Receipt);
console.log('Deposit completed successfully!');
💡

Using a smart contract wallet? As a safety measure, depositETH will fail if you try to deposit ETH from a smart contract wallet without specifying a recipient. Add the recipient option to the depositETH call to fix this.

Withdraw ETH

You just bridged some ETH from L1 to L2. Nice! Now you're going to repeat the process in reverse to bridge some ETH from L2 to L1.

Create the withdrawal transaction

Uses buildWithdrawalTransaction to create the withdrawal parameters. Converts the withdrawal amount to wei and specifies the recipient on L1.

//Add the same imports used in DepositETH function
const withdrawalArgs = await publicClientL2.buildWithdrawalTransaction({
value: parseEther('0.0001'),
to: account.address,
});

Executing the withdrawal

This sends the withdrawal transaction on L2, which initiates the withdrawal process on L2 and logs a transaction hash for tracking the withdrawal.

const withdrawalHash = await walletClientL2.initiateWithdrawal(withdrawalArgs);
console.log(`Withdrawal transaction hash on L2: ${withdrawalHash}`);

Confirming L2 transaction

Wait one hour (max) for the L2 Output containing the transaction to be proposed, and log the receipt, which contains important details like the block number etc.

const withdrawalReceipt = await publicClientL2.waitForTransactionReceipt({ hash: withdrawalHash });
console.log('L2 transaction confirmed:', withdrawalReceipt);

Wait for withdrawal prove

Next, is to prove to the bridge on L1 that the withdrawal happened on L2. To achieve that, you first need to wait until the withdrawal is ready to prove.

const { output, withdrawal } = await publicClientL1.waitToProve({
withdrawalReceipt,
targetChain: walletClientL2.chain
});

Build parameters to prove the withdrawal on the L2.

const proveArgs = await publicClientL2.buildProveWithdrawal({
output,
withdrawal,
});

Prove the withdrawal on the L1

Once the withdrawal is ready to be proven, you'll send an L1 transaction to prove that the withdrawal happened on L2.

const proveHash = await walletClientL1.proveWithdrawal(proveArgs);
 
const proveReceipt = await publicClientL1.waitForTransactionReceipt({ hash: proveHash });

Wait for withdrawal finalization

Before a withdrawal transaction can be finalized, you will need to wait for the finalization period. This can only happen after the fault proof period has elapsed. On OP Mainnet, this takes 7 days.

const awaitWithdrawal = await publicClientL1.waitToFinalize({
targetChain: walletClientL2.chain,
withdrawalHash: withdrawal.withdrawalHash,
});
💡

We're currently testing fault proofs on OP Sepolia, so withdrawal times reflect Mainnet times.

Finalize the withdrawal

const finalizeHash = await walletClientL1.finalizeWithdrawal({
targetChain: walletClientL2.chain,
withdrawal,
});

Wait until the withdrawal is finalized

const finalizeReceipt = await publicClientL1.waitForTransactionReceipt({
hash: finalizeHash
});

Check the withdrawal status

const status = await publicClientL1.getWithdrawalStatus({
receipt,
targetChain: walletClientL2.chain
})
console.log('Withdrawal completed successfully!');

Important Considerations

⚠️
  • Challenge period: The 7-day withdrawal challenge Period is crucial for security.
  • Gas costs: Withdrawals involve transactions on both L2 and L1, each incurring gas fees.
  • Private Key handling: Use secure key management practices in real applications.
  • RPC endpoint security: Keep your API key (or any RPC endpoint) secure.

Next Steps

  • Develop a user interface for easier interaction with these bridging functions.
  • Implement robust error handling and retry mechanisms for production use.

You've just deposited and withdrawn ETH using viem/op-stack. You should now be able to write applications that use viem/op-stack to transfer ETH between L1 and L2. Although this tutorial used Sepolia and OP Sepolia, the same process works for Ethereum and OP Mainnet.