Bridging ETH

Bridge ETH from Ethereum to GIWA, and from GIWA to Ethereum.

Requirements

Make sure the following are installed.

Set up development environment

In this tutorial, we’ll use viem. Viem is a Node.js library, so we’ll start by creating a Node.js project.

1

Create a project folder

mkdir giwa-bridging-eth
cd giwa-bridging-eth
2

Initialize the project

pnpm init
3

Install dependencies

pnpm add -D tsx @types/node
pnpm add viem

Prepare a wallet

You’ll need a wallet to bridge ETH.

1

Get Sepolia ETH

For a deposit (Ethereum -> GIWA), you need ETH on the Ethereum Sepolia network. Use this faucet to get Sepolia ETH.

Don’t have a wallet yet? Use cast command to create one.

2

Set the Private Key environment variable

This tutorial requires multiple transaction signatures. To handle this, you’ll need to set your wallet private key as an environment variable.

export TEST_PRIVATE_KEY=0x...

Configure the Chain Client

Set up chain client for ETH bridging.

src/config.ts
import {defineChain, createPublicClient, http, createWalletClient} from "viem";
import {privateKeyToAccount} from "viem/accounts";
import {publicActionsL1, publicActionsL2, walletActionsL1, walletActionsL2} from "viem/op-stack";
import {sepolia} from "viem/chains";

// GIWA Sepolia chain config
export const giwaSepolia = defineChain({
  id: 91342,
  name: 'Giwa Sepolia',
  nativeCurrency: { name: 'Sepolia Ether', symbol: 'ETH', decimals: 18 },
  rpcUrls: {
    default: {
      http: ['https://sepolia-rpc.giwa.io'],
    },
  },
  contracts: {
    multicall3: {
      address: '0xcA11bde05977b3631167028862bE2a173976CA11',
    },
    l2OutputOracle: {},
    disputeGameFactory: {
      [sepolia.id]: {
        address: '0x37347caB2afaa49B776372279143D71ad1f354F6',
      },
    },
    portal: {
      [sepolia.id]: {
        address: '0x956962C34687A954e611A83619ABaA37Ce6bC78A',
      },
    },
    l1StandardBridge: {
      [sepolia.id]: {
        address: '0x77b2ffc0F57598cAe1DB76cb398059cF5d10A7E7',
      },
    },
  },
  testnet: true,
});

// Prepare the wallet
export const PRIVATE_KEY = process.env.TEST_PRIVATE_KEY as `0x${string}`;
export const account = privateKeyToAccount(PRIVATE_KEY);

// Client for reading Ethereum Sepolia chain data
export const publicClientL1 = createPublicClient({
  chain: sepolia,
  transport: http(),
}).extend(publicActionsL1())

// Client for sending transactions on Ethereum Sepolia
export const walletClientL1 = createWalletClient({
  account,
  chain: sepolia,
  transport: http(),
}).extend(walletActionsL1());

// Client for reading GIWA Sepolia chain data
export const publicClientL2 = createPublicClient({
  chain: giwaSepolia,
  transport: http(),
}).extend(publicActionsL2());

// Client for sending transactions on GIWA Sepolia
export const walletClientL2 = createWalletClient({
  account,
  chain: giwaSepolia,
  transport: http(),
}).extend(walletActionsL2());

Deposit ETH (Ethereum -> GIWA)

Let’s bridge ETH from Ethereum to GIWA.

When you run the code below, your ETH on Ethereum is actually sent to the OptimismPortal contract. The same amount is then minted and credited to your GIWA wallet. This process is known as Lock-and-Mint.

1

Flow

  1. Send a deposit transaction on Layer 1

  2. The corresponding Layer 2 deposit transaction is created by the sequencer

  3. Deposit complete

2

Writing the code

src/deposit_eth.ts
import {publicClientL1, publicClientL2, account, walletClientL1} from './config';
import {formatEther, parseEther} from "viem";
import {getL2TransactionHashes} from "viem/op-stack";

async function main() {
  // Before bridging to GIWA, check your ETH balance on your Layer 1 wallet.
  const l1Balance = await publicClientL1.getBalance({ address: account.address });
  console.log(`L1 Balance: ${formatEther(l1Balance)} ETH`);

  // Build the params to send a deposit transaction on Layer 1.
  const depositArgs = await publicClientL2.buildDepositTransaction({
    mint: parseEther("0.001"),
    to: account.address,
  });

  // Send the deposit transaction on Layer 1.
  // In this step, your ETH is sent to the OptimismPortal contract.
  const depositHash = await walletClientL1.depositTransaction(depositArgs);
  console.log(`Deposit transaction hash on L1: ${depositHash}`);

  // Wait until the Layer 1 transaction above is fully processed.
  const depositReceipt = await publicClientL1.waitForTransactionReceipt({ hash: depositHash });
  console.log('L1 transaction confirmed:', depositReceipt);

  // From the Layer 1 receipt, pre-compute the hash of the corresponding Layer 2 deposit transaction.
  const [l2Hash] = getL2TransactionHashes(depositReceipt);
  console.log(`Corresponding L2 transaction hash: ${l2Hash}`);

  // Wait until the computed Layer 2 deposit transaction is processed.
  // This usually takes about 1–3 minutes.
  const l2Receipt = await publicClientL2.waitForTransactionReceipt({
    hash: l2Hash,
  });
  console.log('L2 transaction confirmed:', l2Receipt);
  console.log('Deposit completed successfully!');
}

main().then();
3

Running

node --import=tsx src/deposit_eth.ts

Why does it take a few minutes for an L1 deposit to appear in my L2 wallet balance?

When you send an L1 deposit transaction, the L2 sequencer picks it up and creates a corresponding L2 deposit transaction. Because the L1 chain can experience reorgs, the L2 sequencer waits for the L1 deposit transaction to be finalized by a certain number of blocks (N blocks) before processing it. This ensures system stability and prevents inconsistencies.

Withdraw ETH (GIWA -> Ethereum)

Have you successfully sent ETH from Ethereum to GIWA? Now let’s bridge it back in the opposite direction — from GIWA to Ethereum.

When you run the code below, your ETH on GIWA is actually sent to the L2ToL1MessagePasser contract. The same amount is then released to your Ethereum wallet. In this case, the ETH that was originally locked in the OptimismPortal contract during the deposit gets unlocked. This process is called Burn-and-Unlock.

The ETH sent to the L2ToL1MessagePasser contract is effectively considered burned. While the balance still appears when queried, the contract includes a burn() function that can be called at any time to destroy the entire balance.

1

Flow

  1. Send a withdrawal initiation transaction on Layer 2

  2. Send a withdrawal prove transaction on Layer 1

  3. Send a withdrawal finalize transaction on Layer 1

  4. Withdrawal complete

2

Writing the code

src/withdraw_eth.ts
import {publicClientL1, publicClientL2, account, walletClientL1, walletClientL2} from './config';
import {formatEther, parseEther} from "viem";

async function main() {
  // Before bridging back to Ethereum, check your ETH balance on your Layer 2 wallet.
  const l2Balance = await publicClientL2.getBalance({ address: account.address });
  console.log(`L2 Balance: ${formatEther(l2Balance)} ETH`);

  // Build the params to initiate a withdrawal on Layer 2.
  const withdrawalArgs = await publicClientL1.buildInitiateWithdrawal({
    to: account.address,
    value: parseEther("0.00005"),
  });

  // Initiate the withdrawal on Layer 2.
  // In this step, your ETH is sent to the L2ToL1MessagePasser contract.
  const withdrawalHash = await walletClientL2.initiateWithdrawal(withdrawalArgs);
  console.log(`Withdrawal transaction hash on L2: ${withdrawalHash}`);

  // Wait until the Layer 2 transaction above is fully processed.
  const withdrawalReceipt = await publicClientL2.waitForTransactionReceipt({ hash: withdrawalHash });
  console.log('L2 transaction confirmed:', withdrawalReceipt);

  // Wait until the L2 withdrawal transaction can be proven on L1.
  // This can take up to 2 hours.
  const { output, withdrawal } = await publicClientL1.waitToProve({
    receipt: withdrawalReceipt,
    targetChain: walletClientL2.chain
  });

  // Build the params to prove the withdrawal on Layer 1.
  const proveArgs = await publicClientL2.buildProveWithdrawal({
    output,
    withdrawal,
  });

  // Prove the withdrawal on Layer 1.
  const proveHash = await walletClientL1.proveWithdrawal(proveArgs);
  console.log(`Prove transaction hash on L1: ${proveHash}`);

  // Wait until the Layer 1 transaction above is fully processed.
  const proveReceipt = await publicClientL1.waitForTransactionReceipt({ hash: proveHash });
  console.log('Prove transaction confirmed:', proveReceipt);

  // Wait until the withdrawal can be finalized.
  // This period is called the challenge period and takes about 7 days.
  await publicClientL1.waitToFinalize({
    targetChain: walletClientL2.chain,
    withdrawalHash: withdrawal.withdrawalHash,
  });

  // Finalize the withdrawal on Layer 1.
  const finalizeHash = await walletClientL1.finalizeWithdrawal({
    targetChain: walletClientL2.chain,
    withdrawal,
  });
  console.log(`Finalize transaction hash on L1: ${finalizeHash}`);

  // Wait until the Layer 1 transaction above is fully processed.
  const finalizeReceipt = await publicClientL1.waitForTransactionReceipt({
    hash: finalizeHash
  });
  console.log('Finalize transaction confirmed:', finalizeReceipt);

  // Read the withdrawal status on Layer 1.
  // Because withdrawals take a long time to complete, you can query status with this if needed.
  const status = await publicClientL1.getWithdrawalStatus({
    receipt: withdrawalReceipt,
    targetChain: walletClientL2.chain,
  });
  console.log('Withdrawal completed successfully!');
}

main().then();
3

Running

node --import=tsx src/withdraw_eth.ts

Learn more

Check out the viem docs for additional resources.

Last updated