Skip to main content

Guide: Depositing funds L1 to L2

This guide provides a complete walkthrough for depositing funds from a L1 (Ethereum) to a L2 (ZKsync). We will use the @dutterbutter/zksync-sdk to simplify the process. The SDK intelligently handles the deposit flow, automatically selecting the correct contract and route based on the token being used.

Prerequisites

Before you begin, ensure you have the following:
  1. Node.js v20+ or Bun.sh installed.
  2. An L1 wallet (private key) with some ETH to pay for the deposit and gas fees.
  3. RPC endpoints for both the L1 and L2 you are targeting.
  4. A project set up with viem or ethers and @dutterbutter/zksync-sdk installed.
    bash title="viem" npm install viem @dutterbutter/zksync-sdk dotenv bash title="ethers" npm install ethers @dutterbutter/zksync-sdk dotenv
  5. An .env file in your project’s root directory with the following variables:
    # .env
    L1_RPC_URL="YOUR_L1_RPC_ENDPOINT"
    L2_RPC_URL="YOUR_L2_RPC_ENDPOINT"
    PRIVATE_KEY="YOUR_WALLET_PRIVATE_KEY"
    

The Deposit Process: Step-by-Step

The process can be broken down into 5 key steps after initial setup.

Step 0: Setup and SDK Initialization

First, we need to connect to the L1 and L2 networks using your preferred library (viem or ethers) and then initialize the ZKsync SDK.
import 'dotenv/config';
import {
  createPublicClient,
  createWalletClient,
  http,
  parseEther,
  type Account,
  type Chain,
  type Transport,
  type WalletClient,
} from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { createViemClient, createViemSdk } from '@dutterbutter/zksync-sdk/viem';
import { ETH_ADDRESS } from '@dutterbutter/zksync-sdk/core';

// Load configuration from .env file
const L1_RPC = process.env.L1_RPC_URL!;
const L2_RPC = process.env.L2_RPC_URL!;
const PRIVATE_KEY = process.env.PRIVATE_KEY!;

// --- Clients (Viem) ---
const account = privateKeyToAccount(PRIVATE_KEY as `0x${string}`);
const l1 = createPublicClient({ transport: http(L1_RPC) });
const l2 = createPublicClient({ transport: http(L2_RPC) });
const l1Wallet: WalletClient<Transport, Chain, Account> = createWalletClient({
account,
transport: http(L1_RPC),
});

// --- 2. Initialize the SDK ---
const client = createViemClient({ l1, l2, l1Wallet });
const sdk = createViemSdk(client);

console.log(`Using account: ${account.address}`);

Step 1: Define Deposit Parameters

Next, define the parameters for your deposit in an object. You’ll specify the deposit amount, the token address — which can represent ETH, a chain’s Base Token (like L1_SOPH_TOKEN_ADDRESS), or any ERC-20 — and the recipient’s address on L2.
const me = signer.address as Address;

const depositParams = {
amount: parseEther('0.01'), // The amount of ETH to deposit (e.g., 0.01 ETH)
token: ETH_ADDRESS, // The address for native ETH
to: me, // The recipient address on L2 (in this case, our own address)
} as const;

You can also specify advanced options for finer control over the transaction:
  • l2GasLimit: The gas limit for the L2 part of the transaction.
  • gasPerPubdata: The gas price per byte of public data.
  • operatorTip: A tip for the L2 operator.
  • refundRecipient: An address to receive any L1 gas refunds.

Step 2: Quote the Deposit

Before sending, it’s best practice to get a cost estimate using sdk.deposits.quote. This returns information about fees and the expected gas required.
Users can skip this step and go directly to create, but quoting helps avoid surprises.
// --- STEP 2: QUOTE ---
const quote = await sdk.deposits.quote(depositParams);
console.log('DEPOSIT QUOTE →', quote);
// Outputs estimated costs and gas limits

Step 3: Prepare the Transaction

The sdk.deposits.prepare method builds the final transaction plan. This step is useful for inspecting the transaction details before execution.
// --- STEP 3: PREPARE ---
const plan = await sdk.deposits.prepare(depositParams);
console.log('TRANSACTION PLAN →', plan);
// Outputs the prepared transaction objects

Step 4: Create the Deposit

The sdk.deposits.create method executes the plan by sending the transaction to the L1 network. It returns a handle which is a unique identifier used to track the deposit’s progress.
// --- STEP 4: CREATE (send tx) ---
const handle = await sdk.deposits.create(depositParams);
console.log('TRANSACTION CREATED →', handle);
// Outputs the handle, e.g., { type: 'deposit', hash: '0x...' }

Step 5: Track the Transaction Status

A deposit is a two-stage process: inclusion on L1 and then execution on L2. The SDK provides tools to track both stages.
  • sdk.deposits.status(handle): Check the current status of the deposit at any time.
  • sdk.deposits.wait(handle, options): Pause execution until the deposit reaches a specific stage.
// --- STEP 5: TRACK ---
console.log('⏳ Waiting for L1 inclusion...');
const l1Receipt = await sdk.deposits.wait(handle, { for: 'l1' });
console.log('✅ L1 included at block:', l1Receipt?.blockNumber);

// Check the status again after L1 inclusion
const statusAfterL1 = await sdk.deposits.status(handle);
console.log('STATUS (after L1) →', statusAfterL1);

console.log('⏳ Waiting for L2 execution...');
const l2Receipt = await sdk.deposits.wait(handle, { for: 'l2' });
console.log('✅ L2 executed at block:', l2Receipt?.blockNumber);
Once the wait for L2 execution completes, your ETH has successfully been deposited to your L2 address.

Full Code Example

Here is the complete, runnable script for your reference.
/**
 * Example: Deposit ETH into an L2
 */
import 'dotenv/config';
import {
  createPublicClient,
  createWalletClient,
  http,
  parseEther,
  type Account,
  type Chain,
  type Transport,
  type WalletClient,
} from 'viem';
import { privateKeyToAccount } from 'viem/accounts';

import { createViemClient, createViemSdk } from '@dutterbutter/zksync-sdk/viem';
import type { Address } from '@dutterbutter/zksync-sdk/core';
import { ETH_ADDRESS } from '@dutterbutter/zksync-sdk/core';

// --- CONFIG ---
// Use env if available, otherwise fall back to local dev defaults.
const L1_RPC = process.env.L1_RPC_URL ?? 'http://localhost:8545';
const L2_RPC = process.env.L2_RPC_URL ?? 'http://localhost:3050';
const PRIVATE_KEY = process.env.PRIVATE_KEY ?? '';

async function main() {
  if (!PRIVATE_KEY || PRIVATE_KEY.length !== 66) {
    throw new Error('⚠️ Set a 0x-prefixed 32-byte PRIVATE_KEY in your .env');
  }

  // --- Clients (Viem) ---
  const account = privateKeyToAccount(PRIVATE_KEY as `0x${string}`);
  const l1 = createPublicClient({ transport: http(L1_RPC) });
  const l2 = createPublicClient({ transport: http(L2_RPC) });

  const l1Wallet: WalletClient<Transport, Chain, Account> = createWalletClient({
    account,
    transport: http(L1_RPC),
  });

  // Show balances for sanity
  const [balL1, balL2] = await Promise.all([
    l1.getBalance({ address: account.address }),
    l2.getBalance({ address: account.address }),
  ]);
  console.log('Using account:', account.address);
  console.log('L1 balance:', balL1.toString());
  console.log('L2 balance:', balL2.toString());

  // --- Init SDK ---
  const client = createViemClient({ l1, l2, l1Wallet });
  const sdk = createViemSdk(client);

  // --- Deposit params ---
  const me = account.address as Address;
  const params = {
    amount: parseEther('0.01'),
    token: ETH_ADDRESS, // ETH sentinel
    to: me,
    // optional:
    // l2GasLimit: 300_000n,
    // gasPerPubdata: 800n,
    // operatorTip: 0n,
    // refundRecipient: me,
  } as const;

  // 1) QUOTE
  const quote = await sdk.deposits.quote(params);
  console.log('QUOTE →', quote);

  // 2) PREPARE (no txs sent)
  const plan = await sdk.deposits.prepare(params);
  console.log('PREPARE →', plan);

  // 3) CREATE (send deposit)
  const handle = await sdk.deposits.create(params);
  console.log('CREATE →', handle);

  // 4) STATUS
  const status = await sdk.deposits.status(handle);
  console.log('STATUS →', status);

  // 5) WAIT: L1 inclusion
  console.log('⏳ Waiting for L1 inclusion...');
  const l1Receipt = await sdk.deposits.wait(handle, { for: 'l1' });
  console.log('✅ L1 included at block:', l1Receipt?.blockNumber);

  const statusAfterL1 = await sdk.deposits.status(handle);
  console.log('STATUS (after L1) →', statusAfterL1);

  // 6) WAIT: L2 execution
  console.log('⏳ Waiting for L2 execution...');
  const l2Receipt = await sdk.deposits.wait(handle, { for: 'l2' });
  console.log('✅ L2 executed at block:', l2Receipt?.blockNumber);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
For more detailed information on the methods used in this guide, see the official API reference: