Integrating Ethereum L2s: Bridging Assets Between Mainnet, Arbitrum, and Base

Practical guide to L2 integration — bridging ETH and ERC-20 tokens to Arbitrum and Base, reading bridge state, handling failed transactions, and using the L2 bridge SDK for production applications.

Your users want to interact with your Arbitrum dApp but their ETH is on mainnet. This guide makes bridging a seamless part of your application.

You’ve built the killer dApp on Arbitrum. The contracts are audited, the frontend is slick, and you’re ready for the tidal wave of users. Then reality hits: your first user connects their wallet, sees their shiny 5 ETH on mainnet, and is greeted by your empty “Deposit” button. They don’t know what a “bridge” is, and they shouldn’t have to. That 7-day withdrawal period for optimistic rollups isn’t a feature; it’s a user experience landmine. Your job is to defuse it.

Forget the “multi-chain future” hype. We’re in the multi-L2 present. With Ethereum L2 TVL at $45B total and Arbitrum ($18B) and Base ($12B) leading the pack (L2Beat, Jan 2026), your users’ assets are fragmented. Your application’s job is to reassemble them invisibly. This isn’t about teaching users bridge mechanics; it’s about abstracting them away with robust, programmatic integration.

How Optimistic and ZK Rollups Actually Move Your Money

Before you write a line of bridge code, you need to know what you’re plugging into. Not all L2s are created equal, and their security models dictate your integration complexity.

Optimistic Rollups (Arbitrum, Base, Optimism) operate on a principle of trust-but-verify. They assume all transactions are valid, but allow anyone to submit a fraud proof during a challenge period (that infamous 7 days). Assets are moved via a bridge contract on L1 (Ethereum mainnet) that locks the funds and mints a corresponding representation on L2. It’s faster to deposit, but withdrawing requires waiting out the challenge window unless you use a liquidity provider’s “fast exit” for a fee.

ZK Rollups (zkSync Era, StarkNet, Scroll) use cryptographic validity proofs (ZK-SNARKs/STARKs) for every state transition. The bridge contract on L1 verifies a proof, not a claim of fraud. This means withdrawals can be much faster—limited mainly by proof generation time, which is around 2 minutes for zkSync Era. The trade-off? Generally higher computational cost for the prover and more complex engineering.

Here’s the practical impact on your users:

Bridge CharacteristicOptimistic (Arbitrum One)ZK Rollup (zkSync Era)Ethereum Mainnet
Avg. Tx Cost~$0.02~$0.05~$3.50
Time to Finality~250ms~250ms~12s
Withdrawal to L17-day challenge period~2 min (proof generation)N/A
Security ModelFraud proofs + economic stakeCryptographic validity proofEthereum consensus

The key takeaway? If you’re building on Arbitrum or Base (both optimistic rollups), you must design around the 7-day withdrawal delay. Your users will get stuck without a plan.

Programmatic Bridging: Using the Arbitrum SDK for ETH

The naive way is to send users to a bridge UI. The professional way is to own the experience. For Arbitrum, the @arbitrum/sdk is your primary tool. Let’s move ETH from L1 to L2 programmatically.

First, install the SDK and set up your providers. You’ll need separate RPC endpoints for L1 (mainnet) and L2 (Arbitrum). Using a service like Alchemy or Infura is non-negotiable for reliability.

import { ethers } from 'ethers';
import { ArbitrumSDK } from '@arbitrum/sdk';

// Configure providers
const l1Provider = new ethers.providers.JsonRpcProvider('YOUR_MAINNET_ALCHEMY_URL');
const l2Provider = new ethers.providers.JsonRpcProvider('YOUR_ARBITRUM_ALCHEMY_URL');

// User's wallet (from MetaMask, WalletConnect, etc.)
const userSigner = new ethers.Wallet('USER_PRIVATE_KEY', l1Provider);

// Initialize SDK
const arbitrumSdk = new ArbitrumSDK(userSigner);

async function bridgeEthToArbitrum(amountEth) {
  const depositAmount = ethers.utils.parseEther(amountEth.toString());
  
  // 1. Get the L1<>L2 bridge gateway
  const l1Gateway = await arbitrumSdk.getL1ETHGateway();
  
  // 2. Estimate the gas needed on L1. This is critical.
  // The deposit function requires funding an L2 transaction.
  const estimatedGas = await l1Gateway.estimateGas.depositETH(
    '0x0000000000000000000000000000000000000000', // *feeRefundAddress* - set to zero for simplicity
    { value: depositAmount }
  );
  
  // 3. Execute the deposit with EIP-1559 gas.
  // REAL ERROR FIX: Never use legacy `gasPrice` on mainnet.
  // Use EIP-1559: maxFeePerGas = baseFee * 1.5 + maxPriorityFeePerGas
  const feeData = await l1Provider.getFeeData();
  const maxPriorityFeePerGas = ethers.utils.parseUnits('1.5', 'gwei'); // Tip
  const maxFeePerGas = feeData.lastBaseFeePerGas.mul(150).div(100).add(maxPriorityFeePerGas); // baseFee * 1.5 + tip
  
  const tx = await l1Gateway.depositETH(
    '0x0000000000000000000000000000000000000000',
    {
      value: depositAmount,
      maxFeePerGas: maxFeePerGas,
      maxPriorityFeePerGas: maxPriorityFeePerGas,
      gasLimit: estimatedGas.mul(120).div(100) // Add 20% buffer
    }
  );
  
  console.log(`Deposit TX sent on L1: ${tx.hash}`);
  
  // 4. Wait for the L2 side to be funded.
  // The Arbitrum sequencer will pick up the L1 event and credit the L2 account.
  const l2Receipt = await tx.waitForL2(l2Provider);
  console.log(`Funds available on L2 in TX: ${l2Receipt.transactionHash}`);
  return l2Receipt;
}

// Bridge 0.1 ETH
bridgeEthToArbitrum(0.1).catch(console.error);

This code handles the core flow: locking ETH in the L1 gateway contract, which triggers a message to the Arbitrum sequencer to mint the equivalent ETH on L2. Notice the explicit EIP-1559 gas configuration—this is mandatory on mainnet to avoid stuck transactions.

The ERC-20 Gateway: Bridging Tokens is a Two-Step Dance

Bridging standard tokens like USDC or DAI adds a critical step: approval. Unlike ETH, ERC-20 tokens require the user to approve the bridge contract to spend their tokens before the deposit can happen. Miss this, and the transaction will revert.

Each canonical Arbitrum bridge has a dedicated L1 Gateway Router and token-specific L1 ERC20 Gateway. You must use the correct gateway for the token.

import { ethers } from 'ethers';
import { ArbitrumSDK } from '@arbitrum/sdk';

const l1Provider = new ethers.providers.JsonRpcProvider('YOUR_MAINNET_ALCHEMY_URL');
const userSigner = new ethers.Wallet('USER_PRIVATE_KEY', l1Provider);
const arbitrumSdk = new ArbitrumSDK(userSigner);

// USDC on Ethereum Mainnet
const L1_USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const AMOUNT_TO_BRIDGE = ethers.utils.parseUnits('100', 6); // 100 USDC (6 decimals)

async function bridgeUsdcToArbitrum() {
  // 1. Initialize the token contract
  const l1Usdc = new ethers.Contract(
    L1_USDC_ADDRESS,
    ['function approve(address spender, uint256 amount) returns (bool)'],
    userSigner
  );
  
  // 2. Get the correct gateway address for this token
  const gatewayAddress = await arbitrumSdk.getL1ERC20Gateway(L1_USDC_ADDRESS);
  
  // 3. APPROVE: Allow the gateway to spend user's USDC
  console.log('Approving gateway...');
  const approveTx = await l1Usdc.approve(gatewayAddress, AMOUNT_TO_BRIDGE);
  await approveTx.wait();
  console.log(`Approved: ${approveTx.hash}`);
  
  // 4. DEPOSIT: Call the gateway to initiate the bridge
  const l1Gateway = await arbitrumSdk.getL1ERC20GatewayContract(L1_USDC_ADDRESS);
  
  const estimatedGas = await l1Gateway.estimateGas.deposit(
    L1_USDC_ADDRESS, // _token
    userSigner.address, // _to (recipient on L2)
    AMOUNT_TO_BRIDGE,
    { from: userSigner.address }
  );
  
  const depositTx = await l1Gateway.deposit(
    L1_USDC_ADDRESS,
    userSigner.address,
    AMOUNT_TO_BRIDGE,
    { gasLimit: estimatedGas.mul(120).div(100) } // Buffer
  );
  
  console.log(`Deposit TX sent: ${depositTx.hash}`);
  const receipt = await depositTx.wait();
  console.log('Deposit confirmed on L1. Funds will arrive on L2 shortly.');
  return receipt;
}

Critical Gotcha: The token on L2 will have a different address. You must use the Arbitrum SDK or a public list to resolve the L2 counterpart. The bridge mints a new “Arbified” token contract on L2.

Surviving the 7-Day Withdrawal and Fast Exits

A user wants their money back on mainnet. With optimistic rollups, you tell them: “Sure, come back next week.” This is a product killer. You have two technical solutions:

  1. The Standard Withdrawal: Initiate the exit on L2, which starts the 7-day challenge period. After the week, the user must finalize the withdrawal on L1 with a second transaction. This is two TXs and a massive delay.
  2. Fast Exit via Liquidity Pool: Third-party providers (like bridges or DEXs) will give the user their ETH on L1 immediately, for a fee (often 0.1-0.5%). They take on the risk of the 7-day window. You integrate their API or contract.

For a seamless experience, you should integrate a fast exit service. Here’s a conceptual flow using a hypothetical liquidity provider:

// Pseudocode for Fast Exit Integration
async function initiateFastWithdrawal(userSigner, amountL2, l2TokenAddress) {
  // 1. User approves the fast exit provider's contract on L2
  // 2. Your backend calls the provider's API to get a quote (fee, exchange rate)
  // 3. User signs a transaction on L2 sending funds to the provider's contract
  // 4. Provider's bot detects this and immediately sends funds from its L1 liquidity to the user's L1 address
  // 5. The provider later completes the standard 7-day withdrawal to replenish its L1 liquidity.
}

Your UI should present both options: “Withdraw Instantly (0.3% fee)” and “Withdraw Standard (7 days, lower cost).”

Debugging the Inevitable: Stuck Transactions and Manual Claims

Bridge transactions can fail. The L1 deposit succeeds, but the L2 credit never appears. The user is in limbo. You need recovery tools.

Real Error Fix: Event log not found on L2.

  • Cause: You’re querying your mainnet RPC endpoint for L2 events, or your L2 provider is out of sync.
  • Fix: Use an L2-specific RPC endpoint and verify the chain ID matches (Arbitrum=42161, Base=8453). Always use the provider for the chain you’re querying.

The Arbitrum SDK provides a Ticket system for retrying. Sometimes you need to manually “claim” the failed deposit.

import { L1ToL2MessageStatus } from '@arbitrum/sdk';

async function retryOrClaimStuckDeposit(l1TxHash) {
  const arbitrumSdk = new ArbitrumSDK(userSigner);
  
  // Re-fetch the L1 transaction receipt
  const l1Receipt = await l1Provider.getTransactionReceipt(l1TxHash);
  
  // Get all L1-to-L2 messages from the receipt
  const messages = await arbitrumSdk.getL1ToL2Messages(l1Receipt);
  
  for (const message of messages) {
    const status = await message.status();
    
    if (status === L1ToL2MessageStatus.FUNDS_DEPOSITED_ON_L2) {
      console.log("Funds are already on L2. Check the user's L2 balance.");
    } else if (status === L1ToL2MessageStatus.CREATION_FAILED) {
      console.log("Message creation failed. User needs to claim a refund on L1.");
      // Guide user to claim via the Arbitrum Nitro dashboard
    } else if (status === L1ToL2MessageStatus.EXPIRED) {
      console.log("Message expired. Manual claiming required.");
      // This is the manual claim flow:
      try {
        const claimTx = await message.claimFunds();
        await claimTx.wait();
        console.log(`Manually claimed via TX: ${claimTx.hash}`);
      } catch (error) {
        console.error("Claim failed:", error);
      }
    }
  }
}

Base Bridge: The Optimism Superchain Shortcut

If you’re building on Base, the integration is conceptually similar but simpler due to the shared Optimism Superchain architecture. Base, Optimism, and other OP Chain L2s share a common bridge protocol. You can often use the Optimism SDK (@eth-optimism/sdk).

The major advantage? The Superchain aims for shared liquidity and messaging, potentially simplifying cross-L2 transfers in the future. The bridging flow for ETH is nearly identical: approve, deposit on L1, wait for L2 inclusion.

// Example using Viem for Base (Conceptual)
import { createPublicClient, createWalletClient, http } from 'viem';
import { base, mainnet } from 'viem/chains';
import { standardBridgeAbi } from './abis'; // You'd import the actual ABI

const l1Client = createPublicClient({ chain: mainnet, transport: http() });
const l2Client = createPublicClient({ chain: base, transport: http() });
const walletClient = createWalletClient({ ... }); // Configured with user account

async function bridgeToBase(amount) {
  // 1. The L1 Standard Bridge contract address is constant.
  const l1StandardBridge = '0x3154Cf16ccdb4C6d922629664174b904d80F2C35';
  
  // 2. Call the `depositETH` function.
  const hash = await walletClient.writeContract({
    address: l1StandardBridge,
    abi: standardBridgeAbi,
    functionName: 'depositETH',
    args: [200000n, '0x'], // _gasLimit, _data
    value: parseEther(amount),
    chain: mainnet,
  });
  
  console.log(`Deposited to Base via TX: ${hash}`);
}

Key Difference: Base transactions are even cheaper, often below $0.01, thanks in part to the broader adoption of EIP-4844 (proto-danksharding), which reduced L2 transaction fees by 90% since March 2024 by providing dedicated, cheap blob space for rollup data.

Abstracting the Bridge: The Frontend Illusion of Instantaneity

Your frontend should never scream “BRIDGE.” The flow should be: “Connect Wallet” -> “Select Amount” -> “Deposit” (or “Withdraw”). The bridge is an implementation detail.

  1. Auto-Detect Need: On connection, check the user’s balance on your target L2. If it’s below the required amount, check their L1 balance. Present a unified “Add Funds” button that triggers your bridge flow.
  2. Status Abstraction: Use a robust transaction status library like wagmi or build your own listener. Show a single progress bar: “Moving funds to Arbitrum…” that encompasses the L1 confirm, the L2 inclusion, and the final L2 confirmation.
  3. Gas Sponsorship: Consider sponsoring gas for the L2 side of the deposit using meta-transactions (if your business model allows it). It removes another step for the user.
  4. Error Handling UI: Don’t show raw RPC errors. Map common errors: “Insufficient L1 balance,” “Allowance required (one-time),” “Network busy, retrying…”

Next Steps: From Bridge to Cross-L2 Mesh

Integrating a single L2 bridge is just the start. The endgame is a mesh where users move between Arbitrum, Base, zkSync, and others as easily as switching tabs. This is where cross-chain messaging protocols (LayerZero, CCIP, Hyperlane) and universal SDKs (Socket, LI.FI) come into play.

Your immediate next steps:

  1. Implement Fallbacks: Integrate at least one fast exit liquidity provider for withdrawals.
  2. Add Chain Agnosticism: Use wagmi or ethers to cleanly support multiple L2s in your frontend. Detect the chain and switch the bridge logic accordingly.
  3. Monitor EIP-7702: This proposed standard for external owned account (EOA) session keys could revolutionize how we batch bridge approvals with other actions, further smoothing the UX.
  4. Load Test with Real Gas: Don’t test with Sepolia only. Use a service that provides funded testnet ETH on Arbitrum Goerli and Base Sepolia. Simulate a mainnet gas environment—remember, the L1 deposit transaction will cost real mainnet gas prices (5–15 gwei on average), even if the L2 side is cheap.

Bridging isn’t a feature; it’s plumbing. The best plumbing is invisible. Handle the complexity in your code so your users never have to see the 7-day challenge period, the gas estimation failures, or the wrong network error. Just the smooth, fast, cheap transaction that makes them wonder why they ever put up with mainnet in the first place.