Welcome

Learn what the zksync-js is and how it simplifies ZKsync cross-chain flows for viem and ethers.

Introduction

The zksync-js is a lightweight extension for viem and ethers that makes ZKsync cross-chain actions simple and consistent.

Instead of re-implementing accounts or low-level RPC logic, this SDK focuses only on ZKsync-specific flows:

  • Deposits (L1 → L2)
  • Withdrawals (L2 → L1, including finalization)
  • Try variants for functional error handling (e.g. tryCreate)
  • Status and wait helpers
  • ZKsync-specific JSON-RPC methods

[!INFO] The SDK doesn’t replace your existing Ethereum libraries — it extends them with ZKsync-only capabilities while keeping your current tooling intact.

Key Supported Features

Deposits (L1 → L2)

Supports ETH, Custom Base Token, and ERC-20.

  • Initiate on L1: Build and send the deposit transaction from Ethereum.
  • Track progress: Query intermediate states (queued, included, executed).
  • Verify completion on L2: Confirm funds credited and available on ZKsync.

Withdrawals (L2 → L1)

Supports ETH, Custom Base Token, and ERC-20.

  • Initiate on L2: Create the withdrawal transaction on ZKsync.
  • Track progress: Monitor execution and finalization availability.
  • Finalize on L1: Finalize withdrawal to release funds back to Ethereum.

ZKsync RPC Extensions

  • getBridgehubAddress (zks_getBridgehubContract) — resolve the canonical Bridgehub contract address.
  • getL2ToL1LogProof (zks_getL2ToL1LogProof) — retrieve the log proof for an L2 → L1 transaction.
  • getReceiptWithL2ToL1 — returns a standard Ethereum TransactionReceipt augmented with l2ToL1Logs.
  • getGenesis (zks_getGenesis) - returns Genesis json.

What You’ll Find Here


Next Steps

👉 Ready to build? Start with the Quickstart.

Mental Model

The SDK is designed around a predictable and layered API for handling L1-L2, and L2-L1 operations. Every action, whether it's a deposit or a withdrawal, follows a consistent lifecycle. Understanding this lifecycle is key to using the SDK effectively.

The complete lifecycle for any action is:

quote → prepare → create → status → wait → (finalize*)
  • The first five steps are common to both Deposits and Withdrawals.
  • Withdrawals require an additional finalize step to prove and claim the funds on L1.

You can enter this lifecycle at different stages depending on how much control you need.

The Core API: A Layered Approach

The core methods are designed to give you progressively more automation. You can start by just getting information (quote), move to building transactions without sending them (prepare), or execute the entire flow with a single call (create).

quote(params)

"What will this operation involve and cost?"

This is a read-only dry run. It performs no transactions and has no side effects. It inspects the parameters and returns a Quote object containing the estimated fees, gas costs, and the steps the SDK will take to complete the action.

➡️ Best for: Displaying a confirmation screen to a user with a cost estimate before they commit.

prepare(params)

"Build the transactions for me, but let me send them."

This method constructs all the necessary transactions for the operation and returns them as an array of TransactionRequest objects in a Plan. It does not sign or send them. This gives you full control over the final execution.

➡️ Best for: Custom workflows where you need to inspect transactions before signing, use a unique signing method, or submit them through a separate system (like a multisig).

create(params)

"Prepare, sign, and send in one go."

This is the most common entry point for a one-shot operation. It internally calls prepare, then uses your configured signer to sign and dispatch the transactions. It returns a Handle object, which is a lightweight tracker containing the transaction hash(es) needed for the next steps.

➡️ Best for: Most standard use cases where you simply want to initiate the deposit or withdrawal.

status(handle | txHash)

"Where is my transaction right now?"

This is a non-blocking check to get the current state of an operation. It takes a Handle from the create method or a transaction hash and returns a structured status object, such as:

  • Deposits: { phase: 'L1_PENDING' | 'L2_EXECUTED' }
  • Withdrawals: { phase: 'L1_INCLUDED','L2_PENDING' | 'READY_TO_FINALIZE' | 'FINALIZED' }

➡️ Best for: Polling in a UI to show a user the live progress of their transaction without blocking the interface.

wait(handle, { for })

"Pause until a specific checkpoint is reached."

This is a blocking (asynchronous) method that polls for you. It pauses execution until the operation reaches a desired checkpoint and then resolves with the relevant transaction receipt.

  • Deposits: Wait for L1 inclusion ('l1') or L2 execution ('l2').
  • Withdrawals: Wait for L2 inclusion ('l2'), finalization availability ('ready'), or final L1 finalization ('finalized').

➡️ Best for: Scripts or backend processes where you need to ensure one step is complete before starting the next.

finalize(l2TxHash)

(Withdrawals Only)

"My funds are ready on L1. Finalize and release them."

This method executes the final step of a withdrawal. After status reports READY_TO_FINALIZE, you call this method with the L2 transaction hash to submit the finalization transaction on L1, which releases the funds to the recipient.

➡️ Best for: The final step of any withdrawal flow.

Error Handling: The try* Philosophy

For more robust error handling without try/catch blocks, every core method has a try* variant (e.g., tryQuote, tryCreate).

Instead of throwing an error on failure, these methods return a result object that enforces explicit error handling:

// Instead of this:
try {
  const handle = await sdk.withdrawals.create(params);
  // ... happy path
} catch (error) {
  // ... sad path
}

// You can do this:
const result = await sdk.withdrawals.tryCreate(params);

if (result.ok) {
  // Safe to use result.value, which is the WithdrawHandle
  const handle = result.value;
} else {
  // Handle the error explicitly
  console.error('Withdrawal failed:', result.error);
}

➡️ Best for: Applications that prefer a functional error-handling pattern and want to avoid uncaught exceptions.

Putting It All Together

These primitives allow you to compose flows that are as simple or as complex as you need.

Simple Flow

Use create and wait for the most straightforward path.

// 1. Create the deposit
const depositHandle = await sdk.deposits.create(params);

// 2. Wait for it to be finalized on L2
const receipt = await sdk.deposits.wait(depositHandle, { for: 'l2' });

console.log('Deposit complete!');

Adapters: viem & ethers

The SDK is designed to work with the tools you already know and love. It's not a standalone library, but rather an extension that plugs into your existing viem or ethers.js setup.

Think of it like a power adapter 🔌. You have your device (viem or ethers client), and this SDK adapts it to work seamlessly with zkSync's unique features. You bring your own client, and the SDK enhances it.

Why an Adapter Model?

This approach offers several key advantages:

  • Bring Your Own Stack: You don't have to replace your existing setup. The SDK integrates directly with the viem clients (PublicClient, WalletClient) or ethers providers and signers you're already using.
  • 📚 Familiar Developer Experience (DX): You continue to handle connections, accounts, and signing just as you always have.
  • 🧩 Lightweight & Focused: The SDK remains small and focused on one thing: providing a robust API for ZKsync-specific actions like deposits and withdrawals.

Installation

First, install the core SDK, then add the adapter that matches your project's stack.

# For viem users
npm install @matterlabs/zksync-js viem

# For ethers.js users
npm install @matterlabs/zksync-js ethers

How to Use

The SDK extends your existing client. Configure viem or ethers as you normally would, then pass them into the adapter’s client factory and create the SDK surface.

viem (public + wallet client)

import { createPublicClient, createWalletClient, http, parseEther } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { createViemClient, createViemSdk } from '@matterlabs/zksync-js/viem';
import { ETH_ADDRESS } from '@matterlabs/zksync-js/core';

const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);

const l1 = createPublicClient({ transport: http(process.env.L1_RPC!) });
const l2 = createPublicClient({ transport: http(process.env.L2_RPC!) });
const l1Wallet = createWalletClient({ account, transport: http(process.env.L1_RPC!) });

const client = createViemClient({ l1, l2, l1Wallet });
const sdk = createViemSdk(client);

const params = {
  amount: parseEther('0.01'),
  to: account.address,
  token: ETH_ADDRESS,
} as const;

const handle = await sdk.deposits.create(params);
await sdk.deposits.wait(handle, { for: 'l2' }); // funds available on L2

ethers (providers + signer)

import { JsonRpcProvider, Wallet, parseEther } from 'ethers';
import { createEthersClient, createEthersSdk } from '@matterlabs/zksync-js/ethers';
import { ETH_ADDRESS } from '@matterlabs/zksync-js/core';

const l1 = new JsonRpcProvider(process.env.L1_RPC!);
const l2 = new JsonRpcProvider(process.env.L2_RPC!);
const signer = new Wallet(process.env.PRIVATE_KEY!, l1);

const client = await createEthersClient({ l1, l2, signer });
const sdk = createEthersSdk(client);

const params = {
  amount: parseEther('0.01'),
  to: await signer.getAddress(),
  token: ETH_ADDRESS,
} as const;

const handle = await sdk.deposits.create(params);
await sdk.deposits.wait(handle, { for: 'l2' }); // funds available on L2

Key Principles

  • No Key Management: The SDK never asks for or stores private keys. All signing operations are delegated to the viem WalletClient or ethers Signer you provide.
  • API Parity: Both adapters expose the exact same API. The code you write to call client.deposits.quote() is identical whether you're using viem or ethers.
  • Easy Migration: Because the API is the same, switching your project from ethers to viem (or vice versa) is incredibly simple. You only need to change the initialization code.

Status vs Wait

Snapshot progress with status(...) or block until a checkpoint with wait(..., { for }) for deposits and withdrawals.

The SDK exposes two complementary ways to track progress:

  • status(...) — returns a non-blocking snapshot of where an operation is.
  • wait(..., { for })blocks/polls until a specified checkpoint is reached.

Both apply to deposits and withdrawals. Use status(...) for UI refreshes; use wait(...) when you need to gate logic on inclusion/finality.

[!NOTE] You can pass either a handle returned from create(...) or a raw transaction hash.

Withdrawals

withdrawals.status(h | l2TxHash): Promise<WithdrawalStatus>

Input

  • h: a WithdrawalWaitable (e.g., from create) or the L2 transaction hash Hex.

Phases

PhaseMeaning
UNKNOWNHandle doesn’t contain an L2 hash yet.
L2_PENDINGL2 transaction not yet included.
PENDINGL2 included, not yet ready to finalize on L1.
READY_TO_FINALIZEFinalization on L1 would succeed now.
FINALIZEDFinalized on L1; funds released.

Notes

  • No L2 receipt ⇒ L2_PENDING
  • Finalization key derivable but not ready ⇒ PENDING
  • Already finalized ⇒ FINALIZED
withdrawals-status.ts
const s = await sdk.withdrawals.status(handleOrHash);
// s.phase ∈ 'UNKNOWN' | 'L2_PENDING' | 'PENDING' | 'READY_TO_FINALIZE' | 'FINALIZED'

withdrawals.wait(h | l2TxHash, { for, pollMs?, timeoutMs? })

Targets

TargetResolves with
{ for: 'l2' }L2 receipt (`TransactionReceiptnull`)
{ for: 'ready' }null when finalization becomes possible
{ for: 'finalized' }L1 receipt when finalized, or null if finalized but receipt not found

Behavior

  • If the handle has no L2 hash, returns null immediately.
  • Default polling interval: 5500 ms (override with pollMs).
  • timeoutMs → returns null on deadline.
withdrawals-wait.ts
// Wait for L2 inclusion → get L2 receipt (augmented with l2ToL1Logs if available)
const l2Rcpt = await sdk.withdrawals.wait(handle, { for: 'l2', pollMs: 5000 });

// Wait until it becomes finalizable (no side effects)
await sdk.withdrawals.wait(handle, { for: 'ready' });

// Wait for L1 finalization → L1 receipt (or null if not retrievable)
const l1Rcpt = await sdk.withdrawals.wait(handle, { for: 'finalized', timeoutMs: 15 * 60_000 });

[!TIP] Building a UI? Use status(...) to paint the current phase and enable/disable the Finalize button when the phase is READY_TO_FINALIZE.

Deposits

deposits.status(h | l1TxHash): Promise<DepositStatus>

Input

  • h: a DepositWaitable (from create) or L1 transaction hash Hex.

Phases

PhaseMeaning
UNKNOWNNo L1 hash present on the handle.
L1_PENDINGL1 receipt missing.
L1_INCLUDEDL1 included; L2 hash not yet derivable from logs.
L2_PENDINGL2 hash known but L2 receipt missing.
L2_EXECUTEDL2 receipt present with status === 1.
L2_FAILEDL2 receipt present with status !== 1.
deposits-status.ts
const s = await sdk.deposits.status(handleOrL1Hash);
// s.phase ∈ 'UNKNOWN' | 'L1_PENDING' | 'L1_INCLUDED' | 'L2_PENDING' | 'L2_EXECUTED' | 'L2_FAILED'

deposits.wait(h | l1TxHash, { for: 'l1' | 'l2' })

Targets

TargetResolves with
{ for: 'l1' }L1 receipt or null
{ for: 'l2' }L2 receipt or null (waits L1 inclusion then L2 execution)
deposits-wait.ts
const l1Rcpt = await sdk.deposits.wait(handle, { for: 'l1' });
const l2Rcpt = await sdk.deposits.wait(handle, { for: 'l2' });

[!NOTE] wait(..., { for: 'l2' }) waits for both L1 inclusion and canonical L2 execution.

Practical Patterns

Pick the Right Tool

  • Use status(...) for poll-less UI refreshes (e.g., on page focus or controlled intervals).
  • Use wait(...) for workflow gating (scripts, jobs, or “continue when X happens”).

Timeouts & Polling

polling.ts
const ready = await sdk.withdrawals.wait(handle, {
  for: 'ready',
  pollMs: 5500, // minimum enforced internally
  timeoutMs: 30 * 60_000, // 30 minutes → returns null on deadline
});
if (ready === null) {
  // timeout or not yet finalizable — decide whether to retry or show a hint
}

Error Handling

  • Network hiccup while fetching receipts ⇒ throws ZKsyncError of kind RPC.
  • Internal decode issue ⇒ throws ZKsyncError of kind INTERNAL.

Prefer no-throw variants if you want explicit flow control:

no-throw.ts
const r = await sdk.withdrawals.tryWait(handle, { for: 'finalized' });
if (!r.ok) {
  console.error('Finalize wait failed:', r.error);
} else {
  console.log('Finalized L1 receipt:', r.value);
}

Tips & Edge Cases

  • Handles vs hashes: Passing a handle without the relevant hash yields UNKNOWN / null. If you already have a hash, pass it directly.
  • Finalization windows: For withdrawals, READY_TO_FINALIZE may take a while. Use status(...) for responsive UI and reserve wait(..., { for: 'finalized' }) for blocking logic.
  • Retries: If a wait returns null because of timeoutMs, safely call status(...) to decide whether to retry or surface user guidance.

Finalization (Withdrawals)

Withdrawals from ZKsync (L2) only complete on Ethereum (L1) after you explicitly call finalize.

When withdrawing from ZKsync (L2) back to Ethereum (L1), funds are not automatically released on L1 after your L2 transaction is included.

Withdrawals are a two-step process:

  1. Initiate on L2 — call withdraw() (via the SDK’s create) to start the withdrawal. This burns or locks funds on L2 and emits logs; funds are still unavailable on L1.
  2. Finalize on L1 — call finalize(l2TxHash) to release funds on L1. This submits an L1 transaction; only then does your ETH or token balance increase on Ethereum.

[!WARNING] If you never finalize, your funds remain locked — visible as “ready to withdraw,” but unavailable on L1. Anyone can finalize on your behalf, but typically you should do it.

Why Finalization Matters

  • Funds remain locked until finalized.
  • Anyone can finalize — typically the withdrawer does.
  • Finalization costs L1 gas — budget for it.

Finalization Methods

MethodPurposeReturns
withdrawals.status(h | l2TxHash)Snapshot phase (UNKNOWNFINALIZED)WithdrawalStatus
withdrawals.wait(h | l2TxHash, { for })Block until a checkpoint ('l2' | 'ready' | 'finalized')Receipt or null
withdrawals.finalize(l2TxHash)Send the L1 finalize transaction{ status, receipt }

[!NOTE] All methods accept either a handle (from create) or a raw L2 transaction hash. If you only have the hash, you can still finalize.

Phases

PhaseMeaning
UNKNOWNHandle doesn’t contain an L2 hash yet.
L2_PENDINGL2 transaction not yet included.
PENDINGL2 included, but not yet ready to finalize on L1.
READY_TO_FINALIZEFinalization on L1 would succeed now.
FINALIZEDFinalized on L1; funds released.

Examples

finalize-by-handle.ts
// 1) Create on L2
const withdrawal = await sdk.withdrawals.create({
  token: ETH_ADDRESS,
  amount: parseEther('0.1'),
  to: myAddress,
});

// 2) Wait until finalizable (no side effects)
await sdk.withdrawals.wait(withdrawal, { for: 'ready', pollMs: 5500 });

// 3) Finalize on L1
const { status, receipt } = await sdk.withdrawals.finalize(withdrawal.l2TxHash);

console.log(status.phase); // "FINALIZED"
console.log(receipt?.transactionHash); // L1 finalize tx hash
finalize-by-hash.ts
// If you only have the L2 tx hash:
const l2TxHash = '0x...';

// Optionally confirm readiness first
const s = await sdk.withdrawals.status(l2TxHash);
if (s.phase !== 'READY_TO_FINALIZE') {
  await sdk.withdrawals.wait(l2TxHash, { for: 'ready', timeoutMs: 30 * 60_000 });
}

// Then finalize
const { status, receipt } = await sdk.withdrawals.finalize(l2TxHash);

Prefer "no-throw" variants in UI/services that need explicit flow control.

const r = await sdk.withdrawals.tryFinalize(l2TxHash);
if (!r.ok) {
  console.error('Finalize failed:', r.error);
} else {
  console.log('Finalized on L1:', r.value.receipt?.transactionHash);
}

Operational Tips

  • Gate UX with phases: Display a Finalize button only when status.phase === 'READY_TO_FINALIZE'.
  • Polling cadence: wait(..., { for: 'ready' }) defaults to ~5500 ms. Adjust with pollMs as needed.
  • Timeouts: Use timeoutMs for long windows and fall back to status(...) to keep UIs responsive.
  • Receipts may be null: wait(..., { for: 'finalized' }) can resolve to null if finalized but receipt is unavailable; show an L1 explorer link based on the submitted transaction hash.

Common Errors

TypeDescriptionAction
RPCRPC or network hiccup (ZKsyncError: RPC)Retry with backoff.
INTERNALDecode or internal issue (ZKsyncError: INTERNAL)Capture logs and report.

See Also

Quickstart

The Quickstart guides help you get your first ZKsync deposit action running in minutes.
You’ll learn how to install the SDK, connect a client, and perform a deposit.

Choose your adapter

This SDK extends existing Ethereum libraries. Pick the Quickstart that matches your stack:

What you’ll do

Each Quickstart walks you through:

  1. Install the adapter package.
  2. Configure a client or signer.
  3. Run a deposit (L1 → L2) as a working example.
  4. Track the status until it’s complete.

👉 Once you’re set up, continue to the How-to Guides for more detailed usage.

Choosing Your Adapter: viem vs. ethers

The SDK is designed to work with both viem and ethers.js, the two most popular Ethereum libraries. Since the SDK offers identical functionality for both, the choice comes down to your project's needs and your personal preference.

The Short Answer (TL;DR)

  • If you're adding the SDK to an existing project: Use the adapter for the library you're already using.
  • If you're starting a new project: The choice is yours. viem is generally recommended for new projects due to its modern design, smaller bundle size, and excellent TypeScript support.

You can't make a wrong choice. Both adapters are fully supported and provide the same features.

Code Comparison

The only difference in your code is the initial setup. All subsequent SDK calls are identical.

viem

import { createPublicClient, createWalletClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { createViemClient, createViemSdk } from '@matterlabs/zksync-js/viem';

const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);

const l1 = createPublicClient({ transport: http(process.env.L1_RPC!) });
const l2 = createPublicClient({ transport: http(process.env.L2_RPC!) });
const l1Wallet = createWalletClient({ account, transport: http(process.env.L1_RPC!) });

const client = createViemClient({ l1, l2, l1Wallet });
const sdk = createViemSdk(client);

ethers

import { JsonRpcProvider, Wallet } from 'ethers';
import { createEthersClient, createEthersSdk } from '@matterlabs/zksync-js/ethers';

const l1 = new JsonRpcProvider(process.env.L1_RPC!);
const l2 = new JsonRpcProvider(process.env.L2_RPC!);
const signer = new Wallet(process.env.PRIVATE_KEY!, l1);

const client = await createEthersClient({ l1, l2, signer });
const sdk = createEthersSdk(client);

Identical SDK Usage

Once the adapter is set up, your application logic is the same:

const quote = await sdk.deposits.quote({
  token: ETH_ADDRESS,
  amount: parseEther('0.1'),
  to: '0xYourAddress',
});

console.log('Total fee:', quote.totalFee.toString());

Conclusion

The adapter model is designed to give you flexibility without adding complexity. Your choice of adapter is a low-stakes decision that's easy to change later.

Ready to start building? 🚀

Quickstart (viem): ETH Deposit (L1 → L2)

This guide gets you to a working ETH deposit from Ethereum to ZKsync (L2) using the viem adapter.

You’ll set up your environment, write a short script, and run it.

1. Prerequisites

  • You have Bun (or Node + tsx) installed.
  • You have an L1 wallet funded with ETH to cover the deposit amount and L1 gas.

2. Installation & Setup

Install packages:

bun install @matterlabs/zksync-js viem dotenv
# or: npm i @matterlabs/zksync-js viem dotenv

Create an .env in your project root (never commit this):

# Your funded L1 private key (0x + 64 hex)
PRIVATE_KEY=0xYOUR_PRIVATE_KEY_HERE

# RPC endpoints
L1_RPC_URL=https://sepolia.infura.io/v3/YOUR_INFURA_ID
L2_RPC_URL=ZKSYNC-OS-TESTNET-RPC

3. The Deposit Script

Save as deposit-viem.ts:

import 'dotenv/config'; // Load environment variables from .env
import { createPublicClient, createWalletClient, http, parseEther } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { createViemClient, createViemSdk } from '@matterlabs/zksync-js/viem';
import { ETH_ADDRESS } from '@matterlabs/zksync-js/core';

const PRIVATE_KEY = process.env.PRIVATE_KEY;
const L1_RPC_URL = process.env.L1_RPC_URL;
const L2_RPC_URL = process.env.L2_RPC_URL;

async function main() {
  if (!PRIVATE_KEY || !L1_RPC_URL || !L2_RPC_URL) {
    throw new Error('Please set your PRIVATE_KEY, L1_RPC_URL, and L2_RPC_URL in a .env file');
  }

  // 1. SET UP CLIENTS AND ACCOUNT
  // The SDK needs connections to both L1 and L2 to function.
  const account = privateKeyToAccount(PRIVATE_KEY as `0x${string}`);

  const l1 = createPublicClient({ transport: http(L1_RPC_URL) });
  const l2 = createPublicClient({ transport: http(L2_RPC_URL) });
  const l1Wallet = createWalletClient({ account, transport: http(L1_RPC_URL) });

  // 2. INITIALIZE THE SDK CLIENT
  // The client bundles your viem clients; the SDK surface exposes deposits/withdrawals helpers.
  const client = createViemClient({ l1, l2, l1Wallet });
  const sdk = createViemSdk(client);

  const L1balance = await l1.getBalance({ address: account.address });
  const L2balance = await l2.getBalance({ address: account.address });

  console.log('Wallet balance on L1:', L1balance);
  console.log('Wallet balance on L2:', L2balance);

  // 3. PERFORM THE DEPOSIT
  // The create() method prepares and sends the transaction.
  // The wait() method polls until the transaction is complete.
  console.log('Sending deposit transaction...');
  const depositHandle = await sdk.deposits.create({
    token: ETH_ADDRESS,
    amount: parseEther('0.001'), // 0.001 ETH
    to: account.address,
  });

  console.log(`L1 transaction hash: ${depositHandle.l1TxHash}`);
  console.log('Waiting for the deposit to be confirmed on L1...');

  // Wait for L1 inclusion
  const l1Receipt = await sdk.deposits.wait(depositHandle, { for: 'l1' });
  console.log(`Deposit confirmed on L1 in block ${l1Receipt?.blockNumber}`);

  console.log('Waiting for the deposit to be executed on L2...');

  // Wait for L2 execution
  const l2Receipt = await sdk.deposits.wait(depositHandle, { for: 'l2' });
  console.log(`Deposit executed on L2 in block ${l2Receipt?.blockNumber}`);
  console.log('Deposit complete! ✅');

  const L1balanceAfter = await l1.getBalance({ address: account.address });
  const L2balanceAfter = await l2.getBalance({ address: account.address });

  console.log('Wallet balance on L1 after:', L1balanceAfter);
  console.log('Wallet balance on L2 after:', L2balanceAfter);

  /*
    // OPTIONAL: ADVANCED CONTROL
    // The SDK also lets you inspect a transaction before sending it.
    // This follows the Mental Model: quote -> prepare -> create.
    // Uncomment the code below to see it in action.

    const params = {
      token: ETH_ADDRESS,
      amount: parseEther('0.001'),
      to: account.address,
      // Optional gas control:
      // l1TxOverrides: {
      //   gasLimit: 280_000n,
      //   maxFeePerGas: parseEther('0.00000002'),
      //   maxPriorityFeePerGas: parseEther('0.000000002'),
      // },
    };

    // Get a quote for the fees
    const quote = await sdk.deposits.quote(params);
    console.log('Fee quote:', quote);

    // Prepare the transaction without sending
    const plan = await sdk.deposits.prepare(params);
    console.log('Transaction plan:', plan);
  */
}

main().catch((error) => {
  console.error('An error occurred:', error);
  process.exit(1);
});

4. Run the Script

bun run deposit-viem.ts
# or with tsx:
# npx tsx deposit-viem.ts

You’ll see logs for the L1 transaction, then L2 execution, and a final status snapshot.

5. Troubleshooting

  • Insufficient funds on L1: Ensure enough ETH for the deposit and L1 gas.
  • Invalid PRIVATE_KEY: Must be 0x + 64 hex chars.
  • Stuck at wait(..., { for: 'l2' }): Verify L2_RPC_URL and network health; check sdk.deposits.status(handle) to see the current phase.
  • ERC-20 deposits: May require an L1 approve(); quote() will surface required steps.

Quickstart (ethers): ETH Deposit (L1 → L2)

This guide will get you from zero to a working ETH deposit from Ethereum to ZKsync (L2) in minutes using the ethers adapter. 🚀

You'll set up your environment, write a short script to make a deposit, and run it.

1. Prerequisites

  • You have Bun installed.
  • You have an L1 wallet (e.g., Sepolia testnet) funded with some ETH to pay for gas and the deposit.

2. Installation & Setup

First, install the necessary packages.

bun install @matterlabs/zksync-js ethers dotenv

Next, create a .env file in your project's root directory to store your private key and RPC endpoints. Never commit this file to Git.

.env file:

# Your funded L1 wallet private key (e.g., from MetaMask)
PRIVATE_KEY=0xYOUR_PRIVATE_KEY_HERE

# RPC endpoints
L1_RPC_URL=https://sepolia.infura.io/v3/YOUR_INFURA_ID
L2_RPC_URL="ZKSYNC-OS-TESTNET-RPC"

3. The Deposit Script

The following script will connect to the networks, create a deposit transaction, send it, and wait for it to be confirmed on both L1 and L2.

Save this code as deposit-ethers.ts:

import 'dotenv/config'; // Load environment variables from .env
import { JsonRpcProvider, Wallet, parseEther } from 'ethers';
import { createEthersClient } from '@matterlabs/zksync-js/ethers';
import { ETH_ADDRESS } from '@matterlabs/zksync-js/core';

const PRIVATE_KEY = process.env.PRIVATE_KEY;
const L1_RPC_URL = process.env.L1_RPC_URL;
const L2_RPC_URL = process.env.L2_RPC_URL;

async function main() {
  if (!PRIVATE_KEY || !L1_RPC_URL || !L2_RPC_URL) {
    throw new Error('Please set your PRIVATE_KEY, L1_RPC_URL, and L2_RPC_URL in a .env file');
  }

  // 1. SET UP PROVIDERS AND SIGNER
  // The SDK needs connections to both L1 and L2 to function.
  const l1Provider = new JsonRpcProvider(L1_RPC_URL);
  const l2Provider = new JsonRpcProvider(L2_RPC_URL);
  const signer = new Wallet(PRIVATE_KEY, l1Provider);

  // 2. INITIALIZE THE SDK CLIENT
  // The client is the low-level interface for interacting with the API.
  const client = await createEthersClient({
    l1Provider,
    l2Provider,
    signer,
  });

  const L1balance = await l1.getBalance({ address: signer.address });
  const L2balance = await l2.getBalance({ address: signer.address });

  console.log('Wallet balance on L1:', L1balance);
  console.log('Wallet balance on L2:', L2balance);

  // 3. PERFORM THE DEPOSIT
  // The create() method prepares and sends the transaction.
  // The wait() method polls until the transaction is complete.
  console.log('Sending deposit transaction...');
  const depositHandle = await sdk.deposits.create({
    token: ETH_ADDRESS,
    amount: parseEther('0.001'), // 0.001 ETH
    to: account.address,
  });

  console.log(`L1 transaction hash: ${depositHandle.l1TxHash}`);
  console.log('Waiting for the deposit to be confirmed on L1...');

  // Wait for L1 inclusion
  const l1Receipt = await sdk.deposits.wait(depositHandle, { for: 'l1' });
  console.log(`Deposit confirmed on L1 in block ${l1Receipt?.blockNumber}`);

  console.log('Waiting for the deposit to be executed on L2...');

  // Wait for L2 execution
  const l2Receipt = await sdk.deposits.wait(depositHandle, { for: 'l2' });
  console.log(`Deposit executed on L2 in block ${l2Receipt?.blockNumber}`);
  console.log('Deposit complete! ✅');

  const L1balanceAfter = await l1.getBalance({ address: signer.address });
  const L2balanceAfter = await l2.getBalance({ address: signer.address });

  console.log('Wallet balance on L1 after:', L1balanceAfter);
  console.log('Wallet balance on L2 after:', L2balanceAfter);

  /*
    // OPTIONAL: ADVANCED CONTROL
    // The SDK also lets you inspect a transaction before sending it.
    // This follows the Mental Model: quote -> prepare -> create.
    // Uncomment the code below to see it in action.

    const params = {
      token: ETH_ADDRESS,
      amount: parseEther('0.001'),
      to: account.address,
      // Optional: pin gas fees instead of using provider estimates
      // l1TxOverrides: {
      //   gasLimit: 280_000n,
      //   maxFeePerGas: parseEther('0.00000002'), // 20 gwei
      //   maxPriorityFeePerGas: parseEther('0.000000002'), // 2 gwei
      // },
    };

    // Get a quote for the fees
    const quote = await sdk.deposits.quote(params);
    console.log('Fee quote:', quote);

    // Prepare the transaction without sending
    const plan = await sdk.deposits.prepare(params);
    console.log('Transaction plan:', plan);
  */
}

main().catch((error) => {
  console.error('An error occurred:', error);
  process.exit(1);
});

4. Run the Script

Execute the script using bun.

bun run deposit-ethers.ts

You should see output confirming the L1 transaction, the wait periods, and finally the successful L2 verification.

5. Troubleshooting

  • Insufficient funds on L1: Make sure your wallet has enough ETH on L1 to cover both the deposit amount (0.001 ETH) and the L1 gas fees.
  • Invalid PRIVATE_KEY: Ensure it’s a 64-character hex string, prefixed with 0x.
  • Stuck waiting for L2: This can take a few minutes. If it takes too long, check that your L2_RPC_URL is correct and the network is operational.

How-to Guides

This section provides task-focused recipes for deposits and withdrawals with the adapter of your choice.

Each guide shows the minimal steps to accomplish a task using the SDK, with real code you can copy, paste, and run.

When to use Guides

  • Use these guides if you want to get something working quickly (e.g., deposit ETH, withdraw ERC-20).
  • If you need a deeper explanation of the SDK’s design, check Concepts.

Available Guides

Deposits

Withdrawals

Deposits (viem)

A fast path to deposit ETH / ERC-20 from L1 → ZKsync (L2) using the viem adapter.

Prerequisites

  • A funded L1 account (gas + amount).
  • RPC URLs: L1_RPC_URL, L2_RPC_URL.
  • Installed: @matterlabs/zksync-js + viem.

Parameters (quick reference)

ParamRequiredMeaning
tokenYesETH_ADDRESS or ERC-20 address
amountYesBigInt/wei (e.g. parseEther('0.01'))
toYesL2 recipient address
l2GasLimitNoL2 execution gas cap
gasPerPubdataNoPubdata price hint
operatorTipNoOptional tip to operator
refundRecipientNoL2 address to receive fee refunds
l1TxOverridesNoL1 tx overrides (e.g. gasLimit, maxFeePerGas, maxPriorityFeePerGas)

ERC-20 deposits may require an L1 approve(). quote() surfaces required steps.

Fast path (one-shot)

// examples/deposit-eth.ts
import { createPublicClient, createWalletClient, http, parseEther, WalletClient } from 'viem';
import type { Account, Chain, Transport } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';

import { createViemSdk, createViemClient } from '@matterlabs/zksync-js/viem';
import { ETH_ADDRESS } from '@matterlabs/zksync-js/core';

const L1_RPC = 'http://localhost:8545'; // e.g. https://sepolia.infura.io/v3/XXX
const L2_RPC = 'http://localhost:3050'; // your L2 RPC
const PRIVATE_KEY = process.env.PRIVATE_KEY || '';

async function main() {
  if (!PRIVATE_KEY || PRIVATE_KEY.length !== 66) {
    throw new Error('Set your PRIVATE_KEY in the .env file');
  }

  // --- Viem clients ---
  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),
  });

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

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

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

  // Quote
  const quote = await sdk.deposits.quote(params);
  console.log('QUOTE response:', quote);

  // Prepare (route + steps, no sends)
  const prepared = await sdk.deposits.prepare(params);
  console.log('PREPARE response:', prepared);

  // Create (prepare + send)
  const created = await sdk.deposits.create(params);
  console.log('CREATE response:', created);

  // Status (quick check)
  const status = await sdk.deposits.status(created);
  console.log('STATUS response:', status);

  // Wait (L1 inclusion)
  const l1Receipt = await sdk.deposits.wait(created, { for: 'l1' });
  console.log(
    'L1 Included at block:',
    l1Receipt?.blockNumber,
    'status:',
    l1Receipt?.status,
    'hash:',
    l1Receipt?.transactionHash,
  );

  // Status again
  const status2 = await sdk.deposits.status(created);
  console.log('STATUS2 response:', status2);

  // Wait for L2 execution
  const l2Receipt = await sdk.deposits.wait(created, { for: 'l2' });
  console.log(
    'L2 Included at block:',
    l2Receipt?.blockNumber,
    'status:',
    l2Receipt?.status,
    'hash:',
    l2Receipt?.transactionHash,
  );
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});
  • create() prepares and sends.
  • wait(..., { for: 'l1' }) ⇒ included on L1.
  • wait(..., { for: 'l2' }) ⇒ executed on L2 (funds available).

Inspect & customize (quote → prepare → create)

1. Quote (no side-effects)

Preview fees/steps and whether an approve is required.

const quote = await sdk.deposits.quote(params);

2. Prepare (build txs, don’t send) Get TransactionRequest[] for signing/UX.

const plan = await sdk.deposits.prepare(params);

3. Create (send) Use defaults, or send your prepared txs if you customized.

const handle = await sdk.deposits.create(params);

Track progress (status vs wait)

Non-blocking snapshot

const s = await sdk.deposits.status(handle /* or l1TxHash */);
// 'UNKNOWN' | 'L1_PENDING' | 'L1_INCLUDED' | 'L2_PENDING' | 'L2_EXECUTED' | 'L2_FAILED'

Block until checkpoint

const l1Receipt = await sdk.deposits.wait(handle, { for: 'l1' });
const l2Receipt = await sdk.deposits.wait(handle, { for: 'l2' });

Error handling patterns

Exceptions

try {
  const handle = await sdk.deposits.create(params);
} catch (e) {
  // normalized error envelope (type, operation, message, context, revert?)
}

No-throw style

Every method has a try* variant (e.g. tryQuote, tryPrepare, tryCreate).
These never throw—so you don’t need a try/catch. Instead they return:

  • { ok: true, value: ... } on success
  • { ok: false, error: ... } on failure

This is useful for UI flows or services where you want explicit control over errors.

const r = await sdk.deposits.tryCreate(params);

if (!r.ok) {
  // handle the error gracefully
  console.error('Deposit failed:', r.error);
  // maybe show a toast, retry, etc.
} else {
  const handle = r.value;
  console.log('Deposit sent. L1 tx hash:', handle.l1TxHash);
}

Troubleshooting

  • Stuck at L1: check L1 gas and RPC health.
  • No L2 execution: verify L2 RPC; re-check status() (should move to L2_EXECUTED).
  • L2 failed: status.phase === 'L2_FAILED' → inspect revert info via your error envelope/logs.

See also

Deposits (ethers)

A fast path to deposit ETH / ERC-20 from L1 → ZKsync (L2) using the ethers adapter.

Prerequisites

  • A funded L1 account (gas + amount).
  • RPC URLs: L1_RPC_URL, L2_RPC_URL.
  • Installed: @matterlabs/zksync-js + ethers.

Parameters (quick reference)

ParamRequiredMeaning
tokenYesETH_ADDRESS or ERC-20 address
amountYesBigInt/wei (e.g. parseEther('0.01'))
toYesL2 recipient address
l2GasLimitNoL2 execution gas cap
gasPerPubdataNoPubdata price hint
operatorTipNoOptional tip to operator
refundRecipientNoL2 address to receive fee refunds
l1TxOverridesNoL1 tx overrides (e.g. gasLimit, maxFeePerGas, maxPriorityFeePerGas)

ERC-20 deposits may require an L1 approve(). quote() surfaces required steps.


Fast path (one-shot)

// examples/deposit-eth.ts
import { JsonRpcProvider, Wallet, parseEther } from 'ethers';
import { createEthersClient, createEthersSdk } from '@matterlabs/zksync-js/ethers';
import { ETH_ADDRESS } from '@matterlabs/zksync-js/core';

const L1_RPC = 'http://localhost:8545'; // e.g. https://sepolia.infura.io/v3/XXX
const L2_RPC = 'http://localhost:3050'; // your L2 RPC
const PRIVATE_KEY = process.env.PRIVATE_KEY || '';

async function main() {
  if (!PRIVATE_KEY) {
    throw new Error('Set your PRIVATE_KEY in the .env file');
  }
  const l1 = new JsonRpcProvider(L1_RPC);
  const l2 = new JsonRpcProvider(L2_RPC);
  const signer = new Wallet(PRIVATE_KEY, l1);

  const balance = await l1.getBalance(signer.address);
  console.log('L1 balance:', balance.toString());

  const balanceL2 = await l2.getBalance(signer.address);
  console.log('L2 balance:', balanceL2.toString());

  const client = await createEthersClient({ l1, l2, signer });
  const sdk = createEthersSdk(client);

  const me = (await signer.getAddress());
  const params = {
    amount: parseEther('.01'), // 0.01 ETH
    to: me,
    token: ETH_ADDRESS,
    // optional:
    // l2GasLimit: 300_000n,
    // gasPerPubdata: 800n,
    // operatorTip: 0n,
    // refundRecipient: me,
  } as const;

  // Quote
  const quote = await sdk.deposits.quote(params);
  console.log('QUOTE response: ', quote);

  const prepare = await sdk.deposits.prepare(params);
  console.log('PREPARE response: ', prepare);

  // Create (prepare + send)
  const create = await sdk.deposits.create(params);
  console.log('CREATE response: ', create);

  const status = await sdk.deposits.status(create);
  console.log('STATUS response: ', status);

  // Wait (for now, L1 inclusion)
  const receipt = await sdk.deposits.wait(create, { for: 'l1' });
  console.log(
    'Included at block:',
    receipt?.blockNumber,
    'status:',
    receipt?.status,
    'hash:',
    receipt?.hash,
  );

  const status2 = await sdk.deposits.status(create);
  console.log('STATUS2 response: ', status2);

  // Wait (for now, L2 inclusion)
  const l2Receipt = await sdk.deposits.wait(create, { for: 'l2' });
  console.log(
    'Included at block:',
    l2Receipt?.blockNumber,
    'status:',
    l2Receipt?.status,
    'hash:',
    l2Receipt?.hash,
  );
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});
  • create() prepares and sends.
  • wait(..., { for: 'l1' }) ⇒ included on L1.
  • wait(..., { for: 'l2' }) ⇒ executed on L2 (funds available).

Inspect & customize (quote → prepare → create)

1. Quote (no side-effects) Preview fees/steps and whether an approve is required.

const quote = await sdk.deposits.quote(params);

2. Prepare (build txs, don’t send) Get TransactionRequest[] for signing/UX.

const plan = await sdk.deposits.prepare(params);

3. Create (send) Use defaults, or send your prepared txs if you customized.

const handle = await sdk.deposits.create(params);

Track progress (status vs wait)

Non-blocking snapshot

const s = await sdk.deposits.status(handle /* or l1TxHash */);
// 'UNKNOWN' | 'L1_PENDING' | 'L1_INCLUDED' | 'L2_PENDING' | 'L2_EXECUTED' | 'L2_FAILED'

Block until checkpoint

const l1Receipt = await sdk.deposits.wait(handle, { for: 'l1' });
const l2Receipt = await sdk.deposits.wait(handle, { for: 'l2' });

Error handling patterns

Exceptions

try {
  const handle = await sdk.deposits.create(params);
} catch (e) {
  // normalized error envelope (type, operation, message, context, revert?)
}

No-throw style

Every method has a try* variant (e.g. tryQuote, tryPrepare, tryCreate).
These never throw—so you don’t need a try/catch. Instead they return:

  • { ok: true, value: ... } on success
  • { ok: false, error: ... } on failure

This is useful for UI flows or services where you want explicit control over errors.

const r = await sdk.deposits.tryCreate(params);

if (!r.ok) {
  // handle the error gracefully
  console.error('Deposit failed:', r.error);
  // maybe show a toast, retry, etc.
} else {
  const handle = r.value;
  console.log('Deposit sent. L1 tx hash:', handle.l1TxHash);
}

Troubleshooting

  • Stuck at L1: check L1 gas and RPC health.
  • No L2 execution: verify L2 RPC; re-check status() (should move to L2_EXECUTED).
  • L2 failed: status.phase === 'L2_FAILED' → inspect revert info via your error envelope/logs.

See also

Withdrawals (viem)

A fast path to withdraw ETH / ERC-20 from ZKsync (L2) → Ethereum (L1) using the viem adapter.

Withdrawals are a two-step process:

  1. Initiate on L2.
  2. Finalize on L1 to release funds.

Prerequisites

  • A funded L2 account to initiate the withdrawal.
  • A funded L1 account for finalization.
  • RPC URLs: L1_RPC_URL, L2_RPC_URL.
  • Installed: @matterlabs/zksync-js + viem.

Parameters (quick reference)

ParamRequiredMeaning
tokenYesETH_ADDRESS or ERC-20 address
amountYesBigInt/wei (e.g. parseEther('0.01'))
toYesL1 recipient address
refundRecipientNoL2 address to receive fee refunds (if applicable)
l2TxOverridesNoL2 tx overrides (e.g. gasLimit, maxFeePerGas, maxPriorityFeePerGas)

Fast path (one-shot)

// examples/viem/withdrawals-eth.ts
import {
  createPublicClient,
  createWalletClient,
  http,
  parseEther,
  type Account,
  type Chain,
  type Transport,
} from 'viem';
import { privateKeyToAccount, nonceManager } from 'viem/accounts';

import { createViemSdk, createViemClient } from '@matterlabs/zksync-js/viem';
import { ETH_ADDRESS } from '@matterlabs/zksync-js/core';

const L1_RPC = 'http://localhost:8545'; // e.g. https://sepolia.infura.io/v3/XXX
const L2_RPC = 'http://localhost:3050'; // your L2 RPC
const PRIVATE_KEY = process.env.PRIVATE_KEY || '';

async function main() {
  if (!PRIVATE_KEY) {
    throw new Error('Set your PRIVATE_KEY (0x-prefixed 32-byte) in env');
  }

  // --- Viem clients  ---
  const account = privateKeyToAccount(PRIVATE_KEY as `0x${string}`);

  const l1 = createPublicClient({ transport: http(L1_RPC) });
  const l2 = createPublicClient({ transport: http(L2_RPC) });

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

  const client = createViemClient({ l1, l2, l1Wallet, l2Wallet });
  const sdk = createViemSdk(client);

  const me = account.address;

  // Withdraw ETH
  const params = {
    token: ETH_ADDRESS,
    amount: parseEther('0.01'),
    to: me,
    // l2GasLimit: 300_000n, // optional
  } as const;

  // Quote (dry run)
  const quote = await sdk.withdrawals.quote(params);
  console.log('QUOTE:', quote);

  // Prepare (no sends)
  const plan = await sdk.withdrawals.prepare(params);
  console.log('PREPARE:', plan);

  // Create (send L2 withdraw)
  const created = await sdk.withdrawals.create(params);
  console.log('CREATE:', created);

  // Quick status
  console.log('STATUS (initial):', await sdk.withdrawals.status(created.l2TxHash));

  // Wait for L2 inclusion
  const l2Receipt = await sdk.withdrawals.wait(created, { for: 'l2' });
  console.log(
    'L2 included: block=',
    l2Receipt?.blockNumber,
    'status=',
    l2Receipt?.status,
    'hash=',
    l2Receipt?.transactionHash,
  );

  // Wait until ready to finalize
  await sdk.withdrawals.wait(created.l2TxHash, { for: 'ready' });
  console.log('STATUS (ready):', await sdk.withdrawals.status(created.l2TxHash));

  // Try to finalize on L1
  const fin = await sdk.withdrawals.tryFinalize(created.l2TxHash);
  console.log('TRY FINALIZE:', fin);

  const l1Receipt = await sdk.withdrawals.wait(created.l2TxHash, { for: 'finalized' });
  if (l1Receipt) {
    console.log('L1 finalize receipt:', l1Receipt.transactionHash);
  } else {
    console.log('Finalized (no local L1 receipt — possibly finalized by someone else).');
  }
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});
  • create() prepares and sends the L2 withdrawal.
  • wait(..., { for: 'l2' }) ⇒ included on L2.
  • wait(..., { for: 'ready' }) ⇒ ready for finalization.
  • finalize(l2TxHash) ⇒ required to release funds on L1.

Inspect & customize (quote → prepare → create)

1. Quote (no side-effects)

Preview fees/steps and whether extra approvals are required.

const quote = await sdk.withdrawals.quote(params);

2. Prepare (build txs, don’t send)

Get TransactionRequest[] for signing/UX.

const plan = await sdk.withdrawals.prepare(params);

3. Create (send)

Use defaults, or send your prepared txs if you customized.

const handle = await sdk.withdrawals.create(params);

Track progress (status vs wait)

Non-blocking snapshot

const s = await sdk.withdrawals.status(handle /* or l2TxHash */);
// 'UNKNOWN' | 'L2_PENDING' | 'PENDING' | 'READY_TO_FINALIZE' | 'FINALIZED'

Block until checkpoint

const l2Receipt = await sdk.withdrawals.wait(handle, { for: 'l2' });
await sdk.withdrawals.wait(handle, { for: 'ready' }); // becomes finalizable

Finalization (required step)

To actually release funds on L1, call finalize. Note the transaction needs to be ready for finalization.

const result = await sdk.withdrawals.finalize(handle.l2TxHash);
console.log('Finalization status:', result.status.phase);

Error handling patterns

Exceptions

try {
  const handle = await sdk.withdrawals.create(params);
} catch (e) {
  // normalized error envelope (type, operation, message, context, optional revert)
}

No-throw style

Every method has a try* variant (e.g. tryQuote, tryPrepare, tryCreate, tryFinalize). These never throw—so you don’t need try/catch. Instead they return:

  • { ok: true, value: ... } on success
  • { ok: false, error: ... } on failure

This is useful for UI flows or services where you want explicit control over errors.

const r = await sdk.withdrawals.tryCreate(params);

if (!r.ok) {
  console.error('Withdrawal failed:', r.error);
} else {
  const handle = r.value;
  const f = await sdk.withdrawals.tryFinalize(handle.l2TxHash);
  if (!f.ok) {
    console.error('Finalize failed:', f.error);
  } else {
    console.log('Withdrawal finalized on L1:', f.value.receipt?.transactionHash);
  }
}

Troubleshooting

  • Never reaches READY_TO_FINALIZE: proofs may not be available yet; poll status() or wait(..., { for: 'ready' }).
  • Finalize fails: ensure you have L1 gas and check revert info in the error envelope.

See also

Withdrawals (ethers)

A fast path to withdraw ETH / ERC-20 from ZKsync (L2) → Ethereum (L1) using the ethers adapter.

Withdrawals are a two-step process:

  1. Initiate on L2.
  2. Finalize on L1 to release funds.

Prerequisites

  • A funded L2 account to initiate the withdrawal.
  • A funded L1 account for finalization.
  • RPC URLs: L1_RPC_URL, L2_RPC_URL.
  • Installed: @matterlabs/zksync-js + ethers.

Parameters (quick reference)

ParamRequiredMeaning
tokenYesETH_ADDRESS or ERC-20 address
amountYesBigInt/wei (e.g. parseEther('0.01'))
toYesL1 recipient address
refundRecipientNoL2 address to receive fee refunds (if applicable)
l2TxOverridesNoL2 tx overrides (e.g. gasLimit, maxFeePerGas, maxPriorityFeePerGas)

Fast path (one-shot)

// examples/withdrawals-eth.ts
import { JsonRpcProvider, Wallet, parseEther } from 'ethers';
import { createEthersClient, createEthersSdk } from '@matterlabs/zksync-js/ethers';
import { ETH_ADDRESS } from '@matterlabs/zksync-js/core';

const L1_RPC = 'http://localhost:8545'; // e.g. https://sepolia.infura.io/v3/XXX
const L2_RPC = 'http://localhost:3050'; // your L2 RPC
const PRIVATE_KEY = process.env.PRIVATE_KEY || '';

async function main() {
  const l1 = new JsonRpcProvider(L1_RPC);
  const l2 = new JsonRpcProvider(L2_RPC);
  const signer = new Wallet(PRIVATE_KEY, l1);

  const client = createEthersClient({ l1, l2, signer });
  const sdk = createEthersSdk(client);

  const me = (await signer.getAddress());

  // Withdraw params (ETH)
  const params = {
    token: ETH_ADDRESS,
    amount: parseEther('0.01'), // 0.001 ETH
    to: me,
    // l2GasLimit: 300_000n,
  } as const;

  // Quote (dry-run only)
  const quote = await sdk.withdrawals.quote(params);
  console.log('QUOTE: ', quote);

  const prepare = await sdk.withdrawals.prepare(params);
  console.log('PREPARE: ', prepare);

  const created = await sdk.withdrawals.create(params);
  console.log('CREATE:', created);

  // Quick status check
  console.log('STATUS (initial):', await sdk.withdrawals.status(created.l2TxHash));

  // wait for L2 inclusion
  const l2Receipt = await sdk.withdrawals.wait(created, { for: 'l2' });
  console.log(
    'L2 included: block=',
    l2Receipt?.blockNumber,
    'status=',
    l2Receipt?.status,
    'hash=',
    l2Receipt?.hash,
  );

  // Optional: check status again
  console.log('STATUS (post-L2):', await sdk.withdrawals.status(created.l2TxHash));

  // finalize on L1
  // Use tryFinalize to avoid throwing in an example script
  await sdk.withdrawals.wait(created.l2TxHash, { for: 'ready' });
  console.log('STATUS (ready):', await sdk.withdrawals.status(created.l2TxHash));

  const fin = await sdk.withdrawals.tryFinalize(created.l2TxHash);
  console.log('TRY FINALIZE: ', fin);

  const l1Receipt = await sdk.withdrawals.wait(created.l2TxHash, { for: 'finalized' });
  if (l1Receipt) {
    console.log('L1 finalize receipt:', l1Receipt.hash);
  } else {
    console.log('Finalized (no local L1 receipt available, possibly finalized by another actor).');
  }
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});
  • create() prepares and sends the L2 withdrawal.
  • wait(..., { for: 'l2' }) ⇒ included on L2.
  • wait(..., { for: 'ready' }) ⇒ ready for finalization.
  • finalize(l2TxHash) ⇒ required to release funds on L1.

Inspect & customize (quote → prepare → create)

1. Quote (no side-effects)

const quote = await sdk.withdrawals.quote(params);

2. Prepare (build txs, don’t send)

const plan = await sdk.withdrawals.prepare(params);

3. Create (send)

const handle = await sdk.withdrawals.create(params);

Track progress (status vs wait)

Non-blocking snapshot

const s = await sdk.withdrawals.status(handle /* or l2TxHash */);
// 'UNKNOWN' | 'L2_PENDING' | 'PENDING' | 'READY_TO_FINALIZE' | 'FINALIZED'

Block until checkpoint

const l2Receipt = await sdk.withdrawals.wait(handle, { for: 'l2' });
await sdk.withdrawals.wait(handle, { for: 'ready' });

Finalization (required step)

const result = await sdk.withdrawals.finalize(handle.l2TxHash);
console.log('Finalization result:', result);

Error handling patterns

Exceptions

try {
  const handle = await sdk.withdrawals.create(params);
} catch (e) {
  // normalized error envelope (type, operation, message, context, optional revert)
}

No-throw style

Use try* methods to avoid exceptions. They return { ok, value } or { ok, error }. Perfect for UIs or services that prefer explicit flow control.

const r = await sdk.withdrawals.tryCreate(params);

if (!r.ok) {
  console.error('Withdrawal failed:', r.error);
} else {
  const handle = r.value;
  const f = await sdk.withdrawals.tryFinalize(handle.l2TxHash);
  if (!f.ok) {
    console.error('Finalize failed:', f.error);
  } else {
    console.log('Withdrawal finalized on L1:', f.value.receipt?.transactionHash);
  }
}

Troubleshooting

  • Never reaches READY_TO_FINALIZE: proofs may not be available yet.
  • Finalize reverts: ensure enough L1 gas; inspect revert info.
  • Finalized but no receipt: wait(..., { for: 'finalized' }) may return null; retry or rely on finalize() result.

See also

Introduction

Public, typed API surface for ZKsyncOSIncorruptible Financial Infrastructure.

What Is This?

The zksync-js provides lightweight adapters for ethers and viem to build L1 ↔ L2 flows — deposits and withdrawals — with a small, focused API. You’ll work with:

  • Adapter-level Clients (providers/wallets, resolved addresses, convenience contracts)
  • High-level SDKs (resources for deposits & withdrawals + helpers)
  • ZKsync-specific RPC helpers (client.zks.*)
  • A consistent, typed Error model (ZKsyncError, try* results)

Quick Start

Ethers Example
import { JsonRpcProvider, Wallet, parseEther } from 'ethers';
import { createEthersClient, createEthersSdk, ETH_ADDRESS } from '@matterlabs/zksync-js/ethers';

const l1 = new JsonRpcProvider(process.env.ETH_RPC!);
const l2 = new JsonRpcProvider(process.env.ZKSYNC_RPC!);
const signer = new Wallet(process.env.PRIVATE_KEY!, l1);

// Low-level client + high-level SDK
const client = createEthersClient({ l1, l2, signer });
const sdk = createEthersSdk(client);

// Deposit 0.05 ETH L1 → L2 and wait for L2 execution
const handle = await sdk.deposits.create({
  token: ETH_ADDRESS,
  amount: parseEther('0.001'),
  to: await signer.getAddress(),
});

const l2Receipt = await sdk.deposits.wait(handle, { for: 'l2' });

// ZKsync-specific RPC is available via client.zks
const bridgehub = await client.zks.getBridgehubAddress();
Viem Example
import {
  createPublicClient,
  http,
  createWalletClient,
  privateKeyToAccount,
  parseEther,
} from 'viem';
import { createViemClient, createViemSdk, ETH_ADDRESS } from '@matterlabs/zksync-js/viem';

const account = privateKeyToAccount(process.env.PRIVATE_KEY! as `0x${string}`);
const l1 = createPublicClient({ transport: http(process.env.ETH_RPC!) });
const l2 = createPublicClient({ transport: http(process.env.ZKSYNC_RPC!) });
const l1Wallet = createWalletClient({ account, transport: http(process.env.ETH_RPC!) });

const client = createViemClient({ l1, l2, l1Wallet });
const sdk = createViemSdk(client);

const handle = await sdk.withdrawals.create({
  token: ETH_ADDRESS,
  amount: parseEther('0.001'),
  to: account.address, // L1 recipient
});

await sdk.withdrawals.wait(handle, { for: 'l2' }); // inclusion on L2
const { status } = await sdk.withdrawals.finalize(handle.l2TxHash); // finalize on L1

const bridgehub = await client.zks.getBridgehubAddress();

What's Documented Here

AreaDescription
Ethers · ClientLow-level handle: providers/signer, resolved addresses, convenience contracts, ZK RPC access.
Ethers · SDKHigh-level deposits/withdrawals plus helpers for addresses, contracts, and token mapping.
Ethers · DepositsL1 → L2 flow with quote, prepare, create, status, and wait.
Ethers · WithdrawalsL2 → L1 flow with quote, prepare, create, status, wait, and finalize.
Viem · ClientPublicClient / WalletClient integration, resolved addresses, contracts, ZK RPC access.
Viem · SDKSame high-level surface as ethers, typed to viem contracts.
Viem · DepositsL1 → L2 flow with quote, prepare, create, status, and wait.
Viem · WithdrawalsL2 → L1 flow with quote, prepare, create, status, wait, and finalize.
Core · ZK RPCZKsync-specific RPC: getBridgehubAddress, getL2ToL1LogProof, enhanced receipts.
Core · Error modelTyped ZKsyncError envelope and try* result helpers.

Notes & Conventions

[!NOTE] Standard eth_* RPC should always be performed through your chosen base library (ethers or viem). The SDK only adds ZKsync-specific RPC methods via client.zks.* (e.g. getBridgehubAddress, getL2ToL1LogProof, getGenesis).

  • Every resource method has a try* variant (e.g. tryCreate) that returns a result object instead of throwing. When errors occur, the SDK throws ZKsyncError with a stable, structured envelope (see Error model).
  • Address resolution comes from on-chain lookups and well-known constants, but can be overridden in the client constructor for forks/tests.

zks_ RPC

Public ZKsync zks_* RPC methods exposed on the adapters via client.zks (Bridgehub address, L2→L1 log proofs, receipts with l2ToL1Logs).

Standard Ethereum RPC (eth_*)

Use your base library for all eth_* methods. The client.zks surface only covers ZKsync-specific RPC (zks_*). For standard Ethereum JSON-RPC (e.g., eth_call, eth_getLogs, eth_getBalance), call them through your chosen library (ethers or viem).

zks_ Interface

interface ZksRpc {
  getBridgehubAddress(): Promise<Address>;
  getL2ToL1LogProof(txHash: Hex, index: number): Promise<ProofNormalized>;
  getReceiptWithL2ToL1(txHash: Hex): Promise<ReceiptWithL2ToL1 | null>;
  getGenesis(): Promise<GenesisInfo>;
}

Methods

getBridgehubAddress() → Promise<Address>

Fetch the on-chain Bridgehub contract address.

const addr = await client.zks.getBridgehubAddress();

getL2ToL1LogProof(txHash: Hex, index: number) → Promise<ProofNormalized>

Return a normalized proof for the L2→L1 log at index in txHash.

Parameters

NameTypeRequiredDescription
txHashHexyesL2 transaction hash that emitted one or more L2→L1 logs.
indexnumberyesZero-based index of the target L2→L1 log within the tx.
const proof = await client.zks.getL2ToL1LogProof(l2TxHash, 0);
/*
{
  id: bigint,
  batchNumber: bigint,
  proof: Hex[]
}
*/

[!INFO] If a proof isn’t available yet, this method throws a typed STATE error. Poll according to your app’s cadence.


getReceiptWithL2ToL1(txHash: Hex) → Promise<ReceiptWithL2ToL1 | null>

Fetch the transaction receipt; the returned object always includes l2ToL1Logs (empty array if none).

const rcpt = await client.zks.getReceiptWithL2ToL1(l2TxHash);
console.log(rcpt?.l2ToL1Logs); // always an array

Types (overview)

type ProofNormalized = {
  id: bigint;
  batchNumber: bigint;
  proof: Hex[];
};

type ReceiptWithL2ToL1 = {
  // …standard receipt fields…
  l2ToL1Logs: unknown[];
};

getGenesis()

What it does Retrieves the L2 genesis configuration exposed by the node, including initial contract deployments, storage patches, execution version, and the expected genesis root.

Example

const genesis = await client.zks.getGenesis();

for (const contract of genesis.initialContracts) {
  console.log('Contract at', contract.address, 'with bytecode', contract.bytecode);
}

console.log('Execution version:', genesis.executionVersion);
console.log('Genesis root:', genesis.genesisRoot);

Returns

type GenesisInput = {
  initialContracts: {
    address: Address;
    bytecode: `0x${string}`;
  }[];
  additionalStorage: {
    key: `0x${string}`;
    value: `0x${string}`;
  }[];
  executionVersion: number;
  genesisRoot: `0x${string}`;
};

Usage

Ethers
import { JsonRpcProvider, Wallet } from 'ethers';
import { createEthersClient } from '@matterlabs/zksync-js/ethers';

const l1 = new JsonRpcProvider(process.env.ETH_RPC!);
const l2 = new JsonRpcProvider(process.env.ZKSYNC_RPC!);
const signer = new Wallet(process.env.PRIVATE_KEY!, l1);

const client = createEthersClient({ l1, l2, signer });

// Public RPC surface:
const bridgehub = await client.zks.getBridgehubAddress();
Viem
import { createPublicClient, http } from 'viem';
import { createViemClient } from '@matterlabs/zksync-js/viem';

const l1 = createPublicClient({ transport: http(process.env.ETH_RPC!) });
const l2 = createPublicClient({ transport: http(process.env.ZKSYNC_RPC!) });

// Provide a WalletClient with an account for L1 operations.
const l1Wallet = /* your WalletClient w/ account */;

const client = createViemClient({ l1, l2, l1Wallet });

// Public RPC surface:
const bridgehub = await client.zks.getBridgehubAddress();

Error Model

Typed, structured errors with a stable envelope across viem and ethers adapters.

Overview

All SDK operations either:

  1. Throw a ZKsyncError whose .envelope gives you a structured, stable payload, or
  2. Return a result object from the try* variants: { ok: true, value } | { ok: false, error }

This is consistent across both ethers and viem adapters.

[!TIP] Prefer the try* variants when you want to avoid exceptions and branch on success/failure.

What Gets Thrown

When the SDK throws, it throws an instance of ZKsyncError. Use isZKsyncError(e) to narrow and read the error envelope.

import { isZKsyncError } from '@matterlabs/zksync-js/core';

try {
  const handle = await sdk.deposits.create(params);
} catch (e) {
  if (isZKsyncError(e)) {
    const err = e; // type-narrowed
    const { type, resource, operation, message, context, revert } = err.envelope;

    switch (type) {
      case 'VALIDATION':
      case 'STATE':
        // user/action fixable (bad input, not-ready, etc.)
        break;
      case 'EXECUTION':
      case 'RPC':
        // network/tx/provider issues
        break;
    }

    console.error(JSON.stringify(err.toJSON())); // structured log
  } else {
    throw e; // non-SDK error
  }
}

Envelope Shape

Instance Type

'ZKsyncError'

ZKsyncError.envelope: ErrorEnvelope

type ErrorEnvelope = {
  /** Resource surface that raised the error. */
  resource: 'deposits' | 'withdrawals' | 'withdrawal-finalization' | 'helpers' | 'zksrpc';

  /** Specific operation, e.g. "withdrawals.finalize" or "deposits.create". */
  operation: string;

  /** Broad category (see table below). */
  type: 'VALIDATION' | 'STATE' | 'EXECUTION' | 'RPC' | 'INTERNAL' | 'VERIFICATION' | 'CONTRACT';

  /** Stable, human-readable message for developers. */
  message: string;

  /** Optional contextual fields (tx hash, nonce, step key, etc.). */
  context?: Record<string, unknown>;

  /** If the error is a contract revert, adapters include decoded info when available. */
  revert?: {
    selector: `0x${string}`; // 4-byte selector
    name?: string; // Decoded Solidity error name
    args?: unknown[]; // Decoded args
    contract?: string; // Best-effort contract label
    fn?: string; // Best-effort function label
  };

  /** Originating error (provider/transport/etc.), sanitized for safe logging. */
  cause?: unknown;
};

Categories (When to Expect Them)

TypeMeaning (how to react)
VALIDATIONInputs are invalid — fix parameters and retry.
STATEOperation not possible yet (e.g., not finalizable). Wait or change state.
EXECUTIONA send/revert happened (tx reverted or couldn’t be confirmed). Inspect revert/cause.
RPCProvider/transport failure. Retry with backoff or check infra.
VERIFICATIONProof/verification issue (e.g., unable to find deposit log).
CONTRACTContract read/encode/allowance failed. Check addresses & ABI.
INTERNALSDK internal issue — report with operation and selector.

Result Style (try*) Helpers

Every resource method has a try* sibling that never throws and returns a TryResult<T>.

const res = await sdk.withdrawals.tryCreate(params);
if (!res.ok) {
  // res.error is a ZKsyncError
  console.warn(res.error.envelope.message, res.error.envelope.operation);
} else {
  console.log('l2TxHash', res.value.l2TxHash);
}

This is especially useful for UI flows where you want inline validation/state messages without try/catch.

Revert Details (When Transactions Fail)

If the provider exposes revert data, the adapters decode common error types and ABIs so you can branch on them:

try {
  await sdk.withdrawals.finalize(l2TxHash);
} catch (e) {
  if (isZKsyncError(e) && e.envelope.revert) {
    const { selector, name, args } = e.envelope.revert;
    // e.g., name === 'InvalidProof' or 'TransferAmountExceedsBalance'
  }
}

Notes

  • The SDK always includes the 4-byte selector.
  • name / args appear when decodable; coverage will expand over time.
  • A revert implying “not ready yet” appears as a STATE error with a clear message.

Ethers & Viem Examples

Ethers
import { JsonRpcProvider, Wallet } from 'ethers';
import { createEthersClient, createEthersSdk } from '@matterlabs/zksync-js/ethers';
import { isZKsyncError } from '@matterlabs/zksync-js/core';

const l1 = new JsonRpcProvider(process.env.ETH_RPC!);
const l2 = new JsonRpcProvider(process.env.ZKSYNC_RPC!);
const signer = new Wallet(process.env.PRIVATE_KEY!, l1);

const client = createEthersClient({ l1, l2, signer });
const sdk = createEthersSdk(client);

const res = await sdk.deposits.tryCreate({ token, amount, to });
if (!res.ok) {
  console.error(res.error.envelope); // structured envelope
}
Viem
import { createPublicClient, http, createWalletClient, privateKeyToAccount } from 'viem';
import { createViemClient, createViemSdk } from '@matterlabs/zksync-js/viem';
import { isZKsyncError } from '@matterlabs/zksync-js/core';

const account = privateKeyToAccount(process.env.PRIVATE_KEY! as `0x${string}`);
const l1 = createPublicClient({ transport: http(process.env.ETH_RPC!) });
const l2 = createPublicClient({ transport: http(process.env.ZKSYNC_RPC!) });
const l1Wallet = createWalletClient({ account, transport: http(process.env.ETH_RPC!) });
const l2Wallet = createWalletClient({ account, transport: http(process.env.ZKSYNC_RPC!) });

const client = createViemClient({ l1, l2, l1Wallet, l2Wallet });
const sdk = createViemSdk(client);

try {
  await sdk.withdrawals.finalize(l2TxHash);
} catch (e) {
  if (isZKsyncError(e)) {
    console.log(e.envelope.message, e.envelope.operation);
  } else {
    throw e;
  }
}

Logging & Observability

  • err.toJSON() → returns a safe, structured object suitable for telemetry.
  • Logging err directly prints a compact summary: category, operation, context, optional revert/cause.

[!WARNING] Avoid parsing err.message for logic — use typed fields on err.envelope instead.

EthersClient

Low-level client for the Ethers adapter. Carries providers/signer, resolves core contract addresses, and exposes connected ethers.Contract instances.


At a Glance

  • Factory: createEthersClient({ l1, l2, signer, overrides? }) → EthersClient
  • Provides: cached core addresses, connected contracts, L2-bound ZKsync RPC (zks), and a signer force-bound to L1.
  • Usage: Create this first, then pass it into createEthersSdk(client).

Import

import { createEthersClient } from '@matterlabs/zksync-js/ethers';

Quick Start

import { JsonRpcProvider, Wallet } from 'ethers';
import { createEthersClient } from '@matterlabs/zksync-js/ethers';

const l1 = new JsonRpcProvider(process.env.ETH_RPC!);
const l2 = new JsonRpcProvider(process.env.ZKSYNC_RPC!);
const signer = new Wallet(process.env.PRIVATE_KEY!, l1);

const client = createEthersClient({ l1, l2, signer });

// Resolve core addresses (cached)
const addrs = await client.ensureAddresses();

// Connected contracts
const { bridgehub, l1AssetRouter } = await client.contracts();

[!TIP] The signer is force-bound to the L1 provider so that L1 finalization flows work out of the box.

createEthersClient(args) → EthersClient

Parameters

NameTypeRequiredDescription
args.l1ethers.AbstractProviderL1 provider for reads and L1 transactions.
args.l2ethers.AbstractProviderL2 (ZKsync) provider for reads and ZK RPC.
args.signerethers.SignerSigner for sends. If not connected to args.l1, it will be automatically connected.
args.overridesPartial<ResolvedAddresses>Optional address overrides (forks/tests).

Returns: EthersClient

EthersClient Interface

PropertyTypeDescription
kind'ethers'Adapter discriminator.
l1ethers.AbstractProviderPublic L1 provider.
l2ethers.AbstractProviderPublic L2 (ZKsync) provider.
signerethers.SignerSigner (bound to l1 for sends).
zksZksRpcZKsync-specific RPC surface bound to l2.

Methods

ensureAddresses() → Promise<ResolvedAddresses>

Resolve and cache core contract addresses from chain state (merges any overrides).

const a = await client.ensureAddresses();
/*
{
  bridgehub, l1AssetRouter, l1Nullifier, l1NativeTokenVault,
  l2AssetRouter, l2NativeTokenVault, l2BaseTokenSystem
}
*/

contracts() → Promise<{ ...contracts }>

Return connected ethers.Contract instances for all core contracts.

const c = await client.contracts();
const bh = c.bridgehub;
await bh.getAddress();

refresh(): void

Clear cached addresses/contracts. Subsequent calls re-resolve.

client.refresh();
await client.ensureAddresses();

baseToken(chainId: bigint) → Promise<Address>

Return the L1 base-token address for a given L2 chain via Bridgehub.baseToken(chainId).

const base = await client.baseToken(324n);

Types

ResolvedAddresses

type ResolvedAddresses = {
  bridgehub: Address;
  l1AssetRouter: Address;
  l1Nullifier: Address;
  l1NativeTokenVault: Address;
  l2AssetRouter: Address;
  l2NativeTokenVault: Address;
  l2BaseTokenSystem: Address;
};

Notes & Pitfalls

  • Provider roles: l1 handles L1 lookups and finalization sends; l2 handles ZKsync reads/RPC via zks.

  • Signer binding: The signer is always connected to l1 to ensure L1 transactions (e.g., finalization) succeed without manual setup.

  • Caching: ensureAddresses() and contracts() are cached. Call refresh() after network changes or applying new overrides.

  • Overrides: For forks or custom deployments, pass overrides during construction — they merge with on-chain resolution.

EthersSdk

High-level SDK built on top of the Ethers adapter — provides deposits, withdrawals, and chain-aware helpers.


At a Glance

  • Factory: createEthersSdk(client) → EthersSdk
  • Composed resources: sdk.deposits, sdk.withdrawals, sdk.helpers
  • Client vs SDK: The client wires RPC/signing, while the SDK adds high-level flows (quote → prepare → create → wait) and convenience helpers.

Import

import { createEthersClient, createEthersSdk } from '@matterlabs/zksync-js/ethers';

Quick Start

import { JsonRpcProvider, Wallet, parseEther } from 'ethers';
import { createEthersClient, createEthersSdk } from '@matterlabs/zksync-js/ethers';

const l1 = new JsonRpcProvider(process.env.ETH_RPC!);
const l2 = new JsonRpcProvider(process.env.ZKSYNC_RPC!);
const signer = new Wallet(process.env.PRIVATE_KEY!, l1);

const client = createEthersClient({ l1, l2, signer });
const sdk = createEthersSdk(client);

// Example: deposit 0.05 ETH L1 → L2 and wait for L2 execution
const handle = await sdk.deposits.create({
  token: ETH_ADDRESS, // 0x…00 sentinel for ETH supported
  amount: parseEther('0.05'),
  to: await signer.getAddress(),
});

await sdk.deposits.wait(handle, { for: 'l2' });

// Example: resolve core contracts
const { l1NativeTokenVault } = await sdk.helpers.contracts();

[!TIP] The SDK composes the client with resources: deposits, withdrawals, and convenience helpers.

createEthersSdk(client) → EthersSdk

Parameters

NameTypeRequiredDescription
clientEthersClientInstance returned by createEthersClient({ l1, l2, signer }).

Returns: EthersSdk

EthersSdk Interface

deposits: DepositsResource

L1 → L2 flows. See Deposits.

withdrawals: WithdrawalsResource

L2 → L1 flows. See Withdrawals.

helpers

Utilities for chain addresses, connected contracts, and L1↔L2 token mapping.

addresses() → Promise<ResolvedAddresses>

Resolve core addresses (Bridgehub, routers, vaults, base-token system).

const a = await sdk.helpers.addresses();

contracts() → Promise<{ ...contracts }>

Return connected ethers.Contract instances for all core contracts.

const c = await sdk.helpers.contracts();

One-off Contract Getters

MethodReturnsDescription
l1AssetRouter()Promise<Contract>Connected L1 Asset Router contract.
l1NativeTokenVault()Promise<Contract>Connected L1 Native Token Vault.
l1Nullifier()Promise<Contract>Connected L1 Nullifier contract.
const nullifier = await sdk.helpers.l1Nullifier();

baseToken(chainId?: bigint) → Promise<Address>

L1 address of the base token for the current (or provided) L2 chain.

const base = await sdk.helpers.baseToken(); // infers from client.l2

l2TokenAddress(l1Token: Address) → Promise<Address>

Return the L2 token address for a given L1 token.

  • Handles ETH special case (L2 ETH placeholder).
  • If token is the chain’s base token, returns the L2 base-token system address.
  • Otherwise queries IL2NativeTokenVault.l2TokenAddress.
const l2Crown = await sdk.helpers.l2TokenAddress(CROWN_ERC20_ADDRESS);

l1TokenAddress(l2Token: Address) → Promise<Address>

Return the L1 token corresponding to an L2 token via IL2AssetRouter.l1TokenAddress. ETH placeholder resolves to canonical ETH.

const l1Crown = await sdk.helpers.l1TokenAddress(L2_CROWN_ADDRESS);

assetId(l1Token: Address) → Promise<Hex>

Get the bytes32 asset ID via L1NativeTokenVault.assetId (handles ETH canonically).

const id = await sdk.helpers.assetId(CROWN_ERC20_ADDRESS);

Notes & Pitfalls

  • Client first: Always construct the client with { l1, l2, signer } before creating the SDK.

  • Chain-derived behavior: Helper methods pull from on-chain data — results vary by network.

  • Error model: All resource methods throw typed errors. Prefer try* variants (e.g., tryCreate) for structured results.

Deposits

L1 → L2 deposits for ETH and ERC-20 tokens with quote, prepare, create, status, and wait helpers.


At a Glance

  • Resource: sdk.deposits
  • Typical flow: quote → create → wait({ for: 'l2' })
  • Auto-routing: ETH vs ERC-20 and base-token vs non-base handled automatically
  • Error style: Throwing methods (quote, prepare, create, wait) + safe variants (tryQuote, tryPrepare, tryCreate, tryWait)

Import

import { JsonRpcProvider, Wallet, parseEther } from 'ethers';
import { createEthersClient, createEthersSdk } from '@matterlabs/zksync-js/ethers';

const l1 = new JsonRpcProvider(process.env.ETH_RPC!);
const l2 = new JsonRpcProvider(process.env.ZKSYNC_RPC!);
const signer = new Wallet(process.env.PRIVATE_KEY!, l1);

const client = createEthersClient({ l1, l2, signer });
const sdk = createEthersSdk(client);
// sdk.deposits → DepositsResource

Quick Start

Deposit 0.1 ETH from L1 → L2 and wait for L2 execution:

const handle = await sdk.deposits.create({
  token: ETH_ADDRESS, // 0x…00 for ETH
  amount: parseEther('0.1'),
  to: await signer.getAddress(),
});

const l2Receipt = await sdk.deposits.wait(handle, { for: 'l2' }); // null only if no L1 hash

[!TIP] For UX that never throws, use the try* variants and branch on ok.

Route Selection (Automatic)

RouteMeaning
eth-baseETH when L2 base token is ETH
eth-nonbaseETH when L2 base token ≠ ETH
erc20-baseERC-20 that is the L2 base token
erc20-nonbaseERC-20 that is not the L2 base token

You don’t pass a route manually; it’s derived from network metadata and the token.

Method Reference

quote(p: DepositParams) → Promise<DepositQuote>

Estimate the operation (route, approvals, gas hints). Does not send transactions.

Parameters

NameTypeRequiredDescription
tokenAddressL1 token address. Use 0x…00 for ETH.
amountbigintAmount in wei to deposit.
toAddressL2 recipient address. Defaults to the signer’s address if omitted.
refundRecipientAddressOptional address on L1 to receive refunds for unspent gas.
l2GasLimitbigintOptional manual L2 gas limit override.
gasPerPubdatabigintOptional custom gas-per-pubdata value.
operatorTipbigintOptional operator tip (in wei) for priority execution.
l1TxOverridesEip1559GasOverridesOptional EIP-1559 gas settings for the L1 transaction.

Returns: DepositQuote

const q = await sdk.deposits.quote({
  token: ETH_L1,
  amount: parseEther('0.25'),
  to: await signer.getAddress(),
});
/*
{
  route: "eth-base" | "eth-nonbase" | "erc20-base" | "erc20-nonbase",
  approvalsNeeded: [{ token, spender, amount }],
  baseCost?: bigint,
  mintValue?: bigint,
  suggestedL2GasLimit?: bigint,
  gasPerPubdata?: bigint
}
*/

[!TIP] If approvalsNeeded is non-empty (ERC-20), create() will include those approval steps automatically.

tryQuote(p) → Promise<{ ok: true; value: DepositQuote } | { ok: false; error }>

Result-style version of quote.

prepare(p: DepositParams) → Promise<DepositPlan<TransactionRequest>>

Build the plan (ordered steps + unsigned transactions) without sending.

Returns: DepositPlan

const plan = await sdk.deposits.prepare({ token: ETH_L1, amount: parseEther('0.05'), to });
/*
{
  route,
  summary: DepositQuote,
  steps: [
    { key: "approve:USDC", kind: "approve", tx: TransactionRequest },
    { key: "bridge", kind: "bridge", tx: TransactionRequest }
  ]
}
*/

tryPrepare(p) → Promise<{ ok: true; value: DepositPlan } | { ok: false; error }>

Result-style prepare.

create(p: DepositParams) → Promise<DepositHandle<TransactionRequest>>

Prepares and executes all required L1 steps. Returns a handle with the L1 transaction hash and per-step hashes.

Returns: DepositHandle

const handle = await sdk.deposits.create({ token, amount, to });
/*
{
  kind: "deposit",
  l1TxHash: Hex,
  stepHashes: Record<string, Hex>,
  plan: DepositPlan
}
*/

[!WARNING] If any step reverts, create() throws a typed error. Prefer tryCreate() to avoid exceptions.

tryCreate(p) → Promise<{ ok: true; value: DepositHandle } | { ok: false; error }>

Result-style create.

status(handleOrHash) → Promise<DepositStatus>

Resolve the current phase for a deposit. Accepts either the DepositHandle from create() or a raw L1 transaction hash.

Phases

PhaseMeaning
UNKNOWNNo L1 hash provided
L1_PENDINGL1 receipt not yet found
L1_INCLUDEDIncluded on L1; L2 hash not derivable yet
L2_PENDINGL2 hash known; waiting for L2 receipt
L2_EXECUTEDL2 receipt found with status === 1
L2_FAILEDL2 receipt found with status !== 1
const s = await sdk.deposits.status(handle);
// { phase, l1TxHash, l2TxHash? }

wait(handleOrHash, { for: 'l1' | 'l2' }) → Promise<TransactionReceipt | null>

Block until the specified checkpoint.

  • { for: 'l1' } → L1 receipt (or null if no L1 hash)
  • { for: 'l2' } → L2 receipt after canonical execution (or null if no L1 hash)
const l1Receipt = await sdk.deposits.wait(handle, { for: 'l1' });
const l2Receipt = await sdk.deposits.wait(handle, { for: 'l2' });

tryWait(handleOrHash, opts) → Result<TransactionReceipt>

Result-style wait.

End-to-End Examples

ETH Deposit (Typical)

const handle = await sdk.deposits.create({
  token: ETH_ADDRESS,
  amount: parseEther('0.001'),
  to: await signer.getAddress(),
});

await sdk.deposits.wait(handle, { for: 'l2' });

ERC-20 Deposit

const handle = await sdk.deposits.create({
  token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Example: USDC
  amount: 1_000_000n, // 1.0 USDC (6 decimals)
  to: await signer.getAddress(),
});

const l1Receipt = await sdk.deposits.wait(handle, { for: 'l1' });

Types (Overview)

type DepositParams = {
  token: Address; // 0x…00 for ETH
  amount: bigint; // wei
  to?: Address; // L2 recipient
  refundRecipient?: Address;
  l2GasLimit?: bigint;
  gasPerPubdata?: bigint;
  operatorTip?: bigint;
  l1TxOverrides?: Eip1559GasOverrides;
};

type Eip1559GasOverrides = {
  gasLimit?: bigint;
  maxFeePerGas?: bigint;
  maxPriorityFeePerGas?: bigint;
};

type DepositQuote = {
  route: 'eth-base' | 'eth-nonbase' | 'erc20-base' | 'erc20-nonbase';
  approvalsNeeded: Array<{ token: Address; spender: Address; amount: bigint }>;
  baseCost?: bigint;
  mintValue?: bigint;
  suggestedL2GasLimit?: bigint;
  gasPerPubdata?: bigint;
};

type DepositPlan<TTx = TransactionRequest> = {
  route: DepositQuote['route'];
  summary: DepositQuote;
  steps: Array<{ key: string; kind: string; tx: TTx }>;
};

type DepositHandle<TTx = TransactionRequest> = {
  kind: 'deposit';
  l1TxHash: Hex;
  stepHashes: Record<string, Hex>;
  plan: DepositPlan<TTx>;
};

type DepositStatus =
  | { phase: 'UNKNOWN'; l1TxHash: Hex }
  | { phase: 'L1_PENDING'; l1TxHash: Hex }
  | { phase: 'L1_INCLUDED'; l1TxHash: Hex }
  | { phase: 'L2_PENDING'; l1TxHash: Hex; l2TxHash: Hex }
  | { phase: 'L2_EXECUTED'; l1TxHash: Hex; l2TxHash: Hex }
  | { phase: 'L2_FAILED'; l1TxHash: Hex; l2TxHash: Hex };

[!TIP] Prefer the try* variants if you want to avoid exceptions and work with structured result objects.


Notes & Pitfalls

  • ETH sentinel: Use the canonical 0x…00 address when passing ETH as token.
  • Receipt timing: wait({ for: 'l2' }) resolves only after canonical L2 execution — it can take longer than L1 inclusion.

Withdrawals

L2 → L1 withdrawals for ETH and ERC-20 tokens with quote, prepare, create, status, wait, and finalize helpers.


At a Glance

  • Resource: sdk.withdrawals
  • Typical flow: quote → create → wait({ for: 'l2' }) → wait({ for: 'ready' }) → finalize
  • Auto-routing: ETH vs ERC-20 and base-token vs non-base handled internally
  • Error style: Throwing methods (quote, prepare, create, status, wait, finalize) + safe variants (tryQuote, tryPrepare, tryCreate, tryWait, tryFinalize)

Import

import { JsonRpcProvider, Wallet, parseEther } from 'ethers';
import { createEthersClient, createEthersSdk } from '@matterlabs/zksync-js/ethers';

const l1 = new JsonRpcProvider(process.env.ETH_RPC!);
const l2 = new JsonRpcProvider(process.env.ZKSYNC_RPC!);
const signer = new Wallet(process.env.PRIVATE_KEY!, l1);

const client = createEthersClient({ l1, l2, signer });
const sdk = createEthersSdk(client);
// sdk.withdrawals → WithdrawalsResource

Quick Start

Withdraw 0.1 ETH from L2 → L1 and finalize on L1:

const handle = await sdk.withdrawals.create({
  token: ETH_ADDRESS, // ETH sentinel supported
  amount: parseEther('0.1'),
  to: await signer.getAddress(), // L1 recipient
});

// 1) L2 inclusion (adds l2ToL1Logs if available)
await sdk.withdrawals.wait(handle, { for: 'l2' });

// 2) Wait until finalizable (no side effects)
await sdk.withdrawals.wait(handle, { for: 'ready', pollMs: 6000 });

// 3) Finalize on L1 (no-op if already finalized)
const { status, receipt: l1Receipt } = await sdk.withdrawals.finalize(handle.l2TxHash);

[!INFO] Withdrawals are two-phase: inclusion on L2, then finalization on L1. You can call finalize directly; it will throw if not yet ready. Prefer wait(..., { for: 'ready' }) to avoid that.

Route Selection (Automatic)

RouteMeaning
eth-baseBase token is ETH on L2
eth-nonbaseBase token is not ETH on L2
erc20-nonbaseWithdrawing an ERC-20 that is not the base token

You don’t pass a route manually; it’s derived from network metadata and the token.

Method Reference

quote(p: WithdrawParams) → Promise<WithdrawQuote>

Estimate the operation (route, approvals, gas hints). Does not send transactions.

Parameters

NameTypeRequiredDescription
tokenAddressL2 token (ETH sentinel supported).
amountbigintAmount in wei to withdraw.
toAddressL1 recipient. Defaults to the signer’s address.
l2GasLimitbigintOptional custom gas limit override for the L2 withdrawal transaction.
l2TxOverridesEip1559GasOverridesOptional EIP-1559 overrides for the L2 withdrawal transaction.

Returns: WithdrawQuote

const q = await sdk.withdrawals.quote({ token, amount, to });
/*
{
  route: "eth-base" | "eth-nonbase" | "erc20-nonbase",
  approvalsNeeded: [{ token, spender, amount }],
  suggestedL2GasLimit?: bigint
}
*/

tryQuote(p) → Promise<{ ok: true; value: WithdrawQuote } | { ok: false; error }>

Result-style quote.

prepare(p: WithdrawParams) → Promise<WithdrawPlan<TransactionRequest>>

Build the plan (ordered L2 steps + unsigned transactions) without sending.

Returns: WithdrawPlan

const plan = await sdk.withdrawals.prepare({ token, amount, to });
/*
{
  route,
  summary: WithdrawQuote,
  steps: [
    { key, kind, tx: TransactionRequest },
    // …
  ]
}
*/

tryPrepare(p) → Promise<{ ok: true; value: WithdrawPlan } | { ok: false; error }>

Result-style prepare.

create(p: WithdrawParams) → Promise<WithdrawHandle<TransactionRequest>>

Prepares and executes all required L2 steps. Returns a handle containing the L2 transaction hash.

Returns: WithdrawHandle

const handle = await sdk.withdrawals.create({ token, amount, to });
/*
{
  kind: "withdrawal",
  l2TxHash: Hex,
  stepHashes: Record<string, Hex>,
  plan: WithdrawPlan
}
*/

[!WARNING] If any L2 step reverts, create() throws a typed error. Prefer tryCreate() to avoid exceptions.

tryCreate(p) → Promise<{ ok: true; value: WithdrawHandle } | { ok: false; error }>

Result-style create.

status(handleOrHash) → Promise<WithdrawalStatus>

Return the current phase of a withdrawal. Accepts either a WithdrawHandle or a raw L2 transaction hash.

Phases

PhaseMeaning
UNKNOWNNo L2 hash provided
L2_PENDINGL2 receipt missing
PENDINGIncluded on L2 but not yet finalizable
READY_TO_FINALIZECan be finalized on L1 now
FINALIZEDAlready finalized on L1
const s = await sdk.withdrawals.status(handle);
// { phase, l2TxHash, key? }

wait(handleOrHash, { for: 'l2' | 'ready' | 'finalized', pollMs?, timeoutMs? })

Block until a target phase is reached.

  • { for: 'l2' } → resolves L2 receipt (TransactionReceiptZKsyncOS) or null
  • { for: 'ready' } → resolves null once finalizable
  • { for: 'finalized' } → resolves L1 receipt (if found) or null
const l2Rcpt = await sdk.withdrawals.wait(handle, { for: 'l2' });
await sdk.withdrawals.wait(handle, { for: 'ready', pollMs: 6000, timeoutMs: 15 * 60_000 });
const l1Rcpt = await sdk.withdrawals.wait(handle, { for: 'finalized', pollMs: 7000 });

[!TIP] Default polling is 5500 ms (minimum 1000 ms). Use timeoutMs to bound long waits gracefully.

tryWait(handleOrHash, opts) → Result<TransactionReceipt | null>

Result-style wait.

finalize(l2TxHash: Hex) → Promise<{ status: WithdrawalStatus; receipt?: TransactionReceipt }>

Send the L1 finalize transaction — only if ready. If already finalized, returns the current status without sending.

const { status, receipt } = await sdk.withdrawals.finalize(handle.l2TxHash);
if (status.phase === 'FINALIZED') {
  console.log('L1 tx:', receipt?.transactionHash);
}

[!INFO] If not ready, finalize() throws a typed STATE error. Use status() or wait(..., { for: 'ready' }) first to avoid that.

tryFinalize(l2TxHash) → Promise<{ ok: true; value: { status: WithdrawalStatus; receipt?: TransactionReceipt } } | { ok: false; error }>

Result-style finalize.

End-to-End Example

Minimal Happy Path

const handle = await sdk.withdrawals.create({ token, amount, to });

// L2 inclusion
await sdk.withdrawals.wait(handle, { for: 'l2' });

// Option A: finalize immediately (will throw if not ready)
await sdk.withdrawals.finalize(handle.l2TxHash);

// Option B: wait for readiness, then finalize
await sdk.withdrawals.wait(handle, { for: 'ready' });
await sdk.withdrawals.finalize(handle.l2TxHash);

Types (Overview)

export interface WithdrawParams {
  token: Address; // L2 token (ETH sentinel supported)
  amount: bigint; // wei
  to?: Address; // L1 recipient
  l2GasLimit?: bigint;
  l2TxOverrides?: Eip1559GasOverrides;
}

export interface Eip1559GasOverrides {
  gasLimit?: bigint;
  maxFeePerGas?: bigint;
  maxPriorityFeePerGas?: bigint;
}

export interface WithdrawQuote {
  route: 'eth-base' | 'eth-nonbase' | 'erc20-nonbase';
  approvalsNeeded: Array<{ token: Address; spender: Address; amount: bigint }>;
  suggestedL2GasLimit?: bigint;
}

export interface WithdrawPlan<TTx = TransactionRequest> {
  route: WithdrawQuote['route'];
  summary: WithdrawQuote;
  steps: Array<{ key: string; kind: string; tx: TTx }>;
}

export interface WithdrawHandle<TTx = TransactionRequest> {
  kind: 'withdrawal';
  l2TxHash: Hex;
  stepHashes: Record<string, Hex>;
  plan: WithdrawPlan<TTx>;
}

export type WithdrawalStatus =
  | { phase: 'UNKNOWN'; l2TxHash: Hex }
  | { phase: 'L2_PENDING'; l2TxHash: Hex }
  | { phase: 'PENDING'; l2TxHash: Hex; key?: unknown }
  | { phase: 'READY_TO_FINALIZE'; l2TxHash: Hex; key: unknown }
  | { phase: 'FINALIZED'; l2TxHash: Hex; key: unknown };

// L2 receipt augmentation returned by wait({ for: 'l2' })
export type TransactionReceiptZKsyncOS = TransactionReceipt & {
  l2ToL1Logs?: Array<unknown>;
};

Notes & Pitfalls

  • Two chains, two receipts: Inclusion on L2 and finalization on L1 are independent events.
  • Polling strategy: For production UIs, prefer wait({ for: 'ready' }) then finalize() to avoid premature finalization.
  • Approvals: If an ERC-20 requires allowances, create() automatically includes those approval steps.

Finalization Services

Helpers for building and executing L1 finalization of L2 withdrawals using the Ethers adapter. These utilities fetch the required L2→L1 proof data, check readiness, and submit finalizeDeposit on the L1 Nullifier contract.

Use these services when you need fine-grained control (e.g., preflight simulations, custom gas, external orchestration). For the high-level path, see sdk.withdrawals.finalize(...).


At a Glance

  • Factory: createFinalizationServices(client) → FinalizationServices
  • Workflow: fetch paramsoptionally check statussimulate readinesssubmit finalize tx
  • Prereq: An initialized EthersClient (bound to L1 for signing).

Import & Setup

import { JsonRpcProvider, Wallet } from 'ethers';
import {
  createEthersClient,
  createEthersSdk,
  createFinalizationServices
} from '@matterlabs/zksync-js/ethers';

const l1 = new JsonRpcProvider(process.env.ETH_RPC!);
const l2 = new JsonRpcProvider(process.env.ZKSYNC_RPC!);
const signer = new Wallet(process.env.PRIVATE_KEY!, l1);

const client = createEthersClient({ l1, l2, signer });
// optional: const sdk = createEthersSdk(client);

const svc = createFinalizationServices(client);

Minimal Usage Example

const l2TxHash: Hex = '0x...';

// 1) Build finalize params + discover the L1 Nullifier to call
const { params, nullifier } = await svc.fetchFinalizeDepositParams(l2TxHash);

// 2) (Optional) check finalization
const already = await svc.isWithdrawalFinalized(params);
if (already) {
  console.log('Already finalized on L1');
} else {
  // 3) Dry-run on L1 to confirm readiness (no gas spent)
  const readiness = await svc.simulateFinalizeReadiness(params, nullifier);

  if (readiness.kind === 'READY') {
    // 4) Submit finalize tx
    const { hash, wait } = await svc.finalizeDeposit(params, nullifier);
    console.log('L1 finalize tx:', hash);
    const rcpt = await wait();
    console.log('Finalized in block:', rcpt.blockNumber);
  } else {
    console.warn('Not ready to finalize:', readiness);
  }
}

[!TIP] If you prefer the SDK to handle readiness checks automatically, call sdk.withdrawals.finalize(l2TxHash) instead.

API

fetchFinalizeDepositParams(l2TxHash) → Promise<{ params, nullifier }>

Builds the inputs required by Nullifier.finalizeDeposit for a given L2 withdrawal tx.

Parameters

NameTypeRequiredDescription
l2TxHashHexL2 withdrawal transaction hash.

Returns

FieldTypeDescription
paramsFinalizeDepositParamsCanonical finalize input (proof, indices, message).
nullifierAddressL1 Nullifier contract address to call.

isWithdrawalFinalized(key) → Promise<boolean>

Reads the Nullifier mapping to determine whether a withdrawal has already been finalized.

Parameters

NameTypeRequiredDescription
keyWithdrawalKeyUnique key for the withdrawal.

Returns: true if finalized; otherwise false.

simulateFinalizeReadiness(params, nullifier) → Promise<FinalizeReadiness>

Performs a static call on the L1 Nullifier to check whether a finalizeDeposit would succeed now (no gas spent).

Parameters

NameTypeRequiredDescription
paramsFinalizeDepositParamsPrepared finalize input.
nullifierAddressL1 Nullifier address.

Returns: FinalizeReadiness

Readiness states (see Types) include:

  • { kind: 'READY' }
  • { kind: 'FINALIZED' }
  • { kind: 'NOT_READY', reason, detail? } (temporary)
  • { kind: 'UNFINALIZABLE', reason, detail? } (permanent)

finalizeDeposit(params, nullifier) → Promise<{ hash: string; wait: () => Promise<TransactionReceipt> }>

Sends the L1 finalize transaction to the Nullifier with the provided params.

Parameters

NameTypeRequiredDescription
paramsFinalizeDepositParamsPrepared finalize input.
nullifierAddressL1 Nullifier address.

Returns

FieldTypeDescription
hashstringSubmitted L1 transaction hash.
wait() => Promise<TransactionReceipt>Helper to await on-chain inclusion of the tx.

[!WARNING] This method will revert if the withdrawal is not ready or invalid. Prefer calling simulateFinalizeReadiness or using sdk.withdrawals.wait(..., { for: 'ready' }) first.

Status & Phases

If you are also using sdk.withdrawals.status(...), the phases align conceptually with readiness:

Withdrawal PhaseMeaningReadiness interpretation
L2_PENDINGL2 tx not in a block yetNot ready
L2_INCLUDEDL2 receipt is availableNot ready (proof not derivable yet)
PENDINGInclusion known; proof data not yet derivable/availableNOT_READY
READY_TO_FINALIZEProof posted; can be finalized on L1READY
FINALIZINGL1 finalize tx sent but not yet indexedBetween READY and FINALIZED
FINALIZEDWithdrawal finalized on L1FINALIZED
FINALIZE_FAILEDPrior L1 finalize revertedLikely UNFINALIZABLE until state changes
UNKNOWNNo L2 hash or insufficient dataN/A

Types

// Finalize call input
export interface FinalizeDepositParams {
  chainId: bigint;
  l2BatchNumber: bigint;
  l2MessageIndex: bigint;
  l2Sender: Address;
  l2TxNumberInBatch: number;
  message: Hex;
  merkleProof: Hex[];
}

// Key that identifies a withdrawal in the Nullifier mapping
export type WithdrawalKey = {
  chainIdL2: bigint;
  l2BatchNumber: bigint;
  l2MessageIndex: bigint;
};

// Overall withdrawal state (used by higher-level status helpers)
type WithdrawalPhase =
  | 'L2_PENDING'
  | 'L2_INCLUDED'
  | 'PENDING'
  | 'READY_TO_FINALIZE'
  | 'FINALIZING'
  | 'FINALIZED'
  | 'FINALIZE_FAILED'
  | 'UNKNOWN';

export type WithdrawalStatus = {
  phase: WithdrawalPhase;
  l2TxHash: Hex;
  l1FinalizeTxHash?: Hex;
  key?: WithdrawalKey;
};

// Readiness result returned by simulateFinalizeReadiness(...)
export type FinalizeReadiness =
  | { kind: 'READY' }
  | { kind: 'FINALIZED' }
  | {
      kind: 'NOT_READY';
      // temporary, retry later
      reason: 'paused' | 'batch-not-executed' | 'root-missing' | 'unknown';
      detail?: string;
    }
  | {
      kind: 'UNFINALIZABLE';
      // permanent, won’t become ready
      reason: 'message-invalid' | 'invalid-chain' | 'settlement-layer' | 'unsupported';
      detail?: string;
    };

// Ethers-bound service surface
export interface FinalizationServices {
  fetchFinalizeDepositParams(
    l2TxHash: Hex,
  ): Promise<{ params: FinalizeDepositParams; nullifier: Address }>;

  isWithdrawalFinalized(key: WithdrawalKey): Promise<boolean>;

  simulateFinalizeReadiness(
    params: FinalizeDepositParams,
    nullifier: Address,
  ): Promise<FinalizeReadiness>;

  finalizeDeposit(
    params: FinalizeDepositParams,
    nullifier: Address,
  ): Promise<{ hash: string; wait: () => Promise<TransactionReceipt> }>;
}

Notes & Pitfalls

  • Anyone can finalize: It’s permissionless; your backend or a third-party relayer can call finalizeDeposit.
  • Delay is normal: Proof availability and posting introduce lag between L2 inclusion and readiness.
  • Gas/accounting: Since finalizeDeposit is an L1 tx, ensure the L1 signer has ETH for gas.
  • Error model: Underlying calls may throw typed errors (e.g., STATE, RPC, VERIFICATION). Use readiness checks to avoid avoidable failures.

Cross-References

ViemClient

Low-level client for the Viem adapter. Provides cached core contract addresses, typed contract access, convenience wallet derivation, and ZKsync RPC integration.


At a Glance

  • Factory: createViemClient({ l1, l2, l1Wallet, l2Wallet?, overrides? }) → ViemClient
  • Provides: cached core addresses, typed contracts, convenience wallet access, and ZKsync RPC bound to l2.
  • Usage: create this first, then pass it to createViemSdk(client).

Import

import { createViemClient } from '@matterlabs/zksync-js/viem';

Quick Start

import { createPublicClient, createWalletClient, http } from 'viem';

// Public clients (reads)
const l1 = createPublicClient({ transport: http(process.env.ETH_RPC!) });
const l2 = createPublicClient({ transport: http(process.env.ZKSYNC_RPC!) });

// Wallet clients (writes)
const l1Wallet = createWalletClient({
  account: /* your L1 account */,
  transport: http(process.env.ETH_RPC!),
});

// Optional dedicated L2 wallet (required for L2 sends, e.g., withdrawals)
const l2Wallet = createWalletClient({
  account: /* can be same key as L1 */,
  transport: http(process.env.ZKSYNC_RPC!),
});

const client = createViemClient({ l1, l2, l1Wallet, l2Wallet });

// Resolve core addresses (cached)
const addrs = await client.ensureAddresses();

// Typed contracts (viem getContract)
const { bridgehub, l1AssetRouter } = await client.contracts();

[!TIP] l1Wallet.account is required. If you omit l2Wallet, use client.getL2Wallet() — it will lazily derive one using the L1 account over the L2 transport.

createViemClient(args) → ViemClient

Parameters

NameTypeRequiredDescription
l1viem.PublicClientL1 client for reads and chain metadata.
l2viem.PublicClientL2 (ZKsync) client for reads and ZK RPC access.
l1Walletviem.WalletClient<Transport, Chain, Account>L1 wallet (must include an account) used for L1 transactions.
l2Walletviem.WalletClient<Transport, Chain, Account>Optional dedicated L2 wallet for L2 sends. Needed for withdrawals.
overridesPartial<ResolvedAddresses>Optional contract address overrides (forks/tests).

Returns: ViemClient

ViemClient Interface

PropertyTypeDescription
kind'viem'Adapter discriminator.
l1viem.PublicClientPublic L1 client.
l2viem.PublicClientPublic L2 (ZKsync) client.
l1Walletviem.WalletClient<T, C, A>Wallet bound to L1 (carries default account).
l2Walletviem.WalletClient<T, C, A> | undefinedOptional pre-supplied L2 wallet.
accountviem.AccountDefault account (from l1Wallet).
zksZksRpcZKsync-specific RPC surface bound to l2.

Methods

ensureAddresses() → Promise<ResolvedAddresses>

Resolve and cache core contract addresses from chain state (merging any provided overrides).

const a = await client.ensureAddresses();
/*
{
  bridgehub, l1AssetRouter, l1Nullifier, l1NativeTokenVault,
  l2AssetRouter, l2NativeTokenVault, l2BaseTokenSystem
}
*/

contracts() → Promise<{ ...contracts }>

Return typed Viem contracts (getContract) connected to the current clients.

const c = await client.contracts();
const bh = c.bridgehub; // bh.read.*, bh.write.*, bh.simulate.*

refresh(): void

Clear cached addresses and contracts. Subsequent calls to ensureAddresses() or contracts() will re-resolve.

client.refresh();
await client.ensureAddresses();

baseToken(chainId: bigint) → Promise<Address>

Return the L1 base-token address for a given L2 chain via Bridgehub.baseToken(chainId).

const base = await client.baseToken(324n /* example chain ID */);

getL2Wallet() → viem.WalletClient

Return or lazily derive an L2 wallet from the same account as the L1 wallet.

const w = client.getL2Wallet(); // ensures L2 writes are possible

Types

ResolvedAddresses

type ResolvedAddresses = {
  bridgehub: Address;
  l1AssetRouter: Address;
  l1Nullifier: Address;
  l1NativeTokenVault: Address;
  l2AssetRouter: Address;
  l2NativeTokenVault: Address;
  l2BaseTokenSystem: Address;
};

Notes & Pitfalls

  • Wallet roles:

    • Deposits sign on L1
    • Withdrawals sign on L2
    • Finalization signs on L1
  • Caching: ensureAddresses() and contracts() are cached. Use refresh() after network or override changes.

  • Overrides: For forks or custom deployments, pass overrides during construction. They merge with on-chain lookups.

  • Error handling: Low-level client methods may throw typed SDK errors. For structured results, prefer the SDK’s try* variants on higher-level resources.

ViemSdk

High-level SDK built on top of the Viem adapter — provides deposits, withdrawals, and chain-aware helpers.


At a Glance

  • Factory: createViemSdk(client) → ViemSdk

  • Composed resources: sdk.deposits, sdk.withdrawals, sdk.helpers

  • Client vs SDK: The client wires RPC/signing; the SDK adds high-level flows (quote → prepare → create → wait) and convenience helpers.

  • Wallets by flow:

    • Deposits (L1 tx): l1Wallet required
    • Withdrawals (L2 tx): l2Wallet required
    • Finalize (L1 tx): l1Wallet required

Import

import { createViemClient, createViemSdk } from '@matterlabs/zksync-js/viem';

Quick Start

import { createPublicClient, createWalletClient, http } from 'viem';
import { createViemClient, createViemSdk, ETH_ADDRESS } from '@matterlabs/zksync-js/viem';

// Public clients (reads)
const l1 = createPublicClient({ transport: http(process.env.ETH_RPC!) });
const l2 = createPublicClient({ transport: http(process.env.ZKSYNC_RPC!) });

// Wallet clients (writes)
const l1Wallet = createWalletClient({
  account: /* your L1 Account */,
  transport: http(process.env.ETH_RPC!),
});

const l2Wallet = createWalletClient({
  account: /* your L2 Account (can be the same key) */,
  transport: http(process.env.ZKSYNC_RPC!),
});

const client = createViemClient({ l1, l2, l1Wallet, l2Wallet });
const sdk = createViemSdk(client);

// Example: deposit 0.05 ETH L1 → L2, wait for L2 execution
const handle = await sdk.deposits.create({
  token: ETH_ADDRESS,               // 0x…00 sentinel for ETH
  amount: 50_000_000_000_000_000n,  // 0.05 ETH in wei
  to: l2Wallet.account.address,
});
await sdk.deposits.wait(handle, { for: 'l2' });

// Example: resolve contracts and map an L1 token to its L2 address
const { l1NativeTokenVault } = await sdk.helpers.contracts();
const l2Crown = await sdk.helpers.l2TokenAddress(CROWN_ERC20_ADDRESS);

[!TIP] You can construct the client with only the wallets you need for a given flow (e.g., just l2Wallet to create withdrawals; add l1Wallet when you plan to finalize).

createViemSdk(client) → ViemSdk

Parameters

NameTypeRequiredDescription
clientViemClientInstance returned by createViemClient({ l1, l2, l1Wallet?, l2Wallet? }).

Returns: ViemSdk

[!TIP] The SDK composes the client with resources: deposits, withdrawals, and convenience helpers.

ViemSdk Interface

deposits: DepositsResource

L1 → L2 flows. See Deposits.

withdrawals: WithdrawalsResource

L2 → L1 flows. See Withdrawals.

helpers

Utilities for chain addresses, connected contracts, and L1↔L2 token mapping.

addresses() → Promise<ResolvedAddresses>

Resolve core addresses (Bridgehub, routers, vaults, base-token system).

const a = await sdk.helpers.addresses();

contracts() → Promise<{ ...contracts }>

Typed Viem contracts for all core components (each exposes .read / .write / .simulate).

const c = await sdk.helpers.contracts();
const bridgehub = c.bridgehub;

One-off Contract Getters

MethodReturnsDescription
l1AssetRouter()Promise<Contract>Connected L1 Asset Router.
l1NativeTokenVault()Promise<Contract>Connected L1 Native Token Vault.
l1Nullifier()Promise<Contract>Connected L1 Nullifier contract.
const nullifier = await sdk.helpers.l1Nullifier();

baseToken(chainId?: bigint) → Promise<Address>

L1 address of the base token for the current (or supplied) L2 chain.

const base = await sdk.helpers.baseToken(); // infers from the L2 client

l2TokenAddress(l1Token: Address) → Promise<Address>

L2 token address for an L1 token.

  • Handles ETH special case (L2 ETH placeholder).
  • If the token is the chain’s base token, returns the L2 base-token system address.
  • Otherwise queries IL2NativeTokenVault.l2TokenAddress.
const l2Crown = await sdk.helpers.l2TokenAddress(CROWN_ERC20_ADDRESS);

l1TokenAddress(l2Token: Address) → Promise<Address>

L1 token for an L2 token via IL2AssetRouter.l1TokenAddress. ETH placeholder resolves to canonical ETH.

const l1Crown = await sdk.helpers.l1TokenAddress(L2_CROWN_ADDRESS);

assetId(l1Token: Address) → Promise<Hex>

bytes32 asset ID via L1NativeTokenVault.assetId (ETH handled canonically).

const id = await sdk.helpers.assetId(CROWN_ERC20_ADDRESS);

Notes & Pitfalls

  • Wallet placement matters: Deposits sign on L1; withdrawals sign on L2; finalization signs on L1.
  • Chain-derived behavior: Helpers read from on-chain sources; results depend on connected networks.
  • Error model: Resource methods throw typed errors; prefer try* variants on resources for result objects.

Deposits

L1 → L2 deposits for ETH and ERC-20 tokens with quote, prepare, create, status, and wait helpers using the Viem adapter.


At a Glance

  • Resource: sdk.deposits
  • Common flow: quote → create → wait({ for: 'l2' })
  • Auto-routing: ETH vs ERC-20 and base-token vs non-base handled automatically
  • Error style: Throwing methods (quote, prepare, create, wait) + safe variants (tryQuote, tryPrepare, tryCreate, tryWait)

Import

import {
  createPublicClient,
  createWalletClient,
  http,
  parseEther,
  type Account,
  type Chain,
  type Transport,
  type WalletClient,
} from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { createViemClient, createViemSdk } from '@matterlabs/zksync-js/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),
});

// Initialize
const client = createViemClient({ l1, l2, l1Wallet });
const sdk = createViemSdk(client);
// sdk.deposits → DepositsResource

Quick Start

Deposit 0.1 ETH from L1 → L2 and wait for L2 execution:

const handle = await sdk.deposits.create({
  token: ETH_ADDRESS, // 0x…00 for ETH
  amount: parseEther('0.1'),
  to: account.address,
});

const l2Receipt = await sdk.deposits.wait(handle, { for: 'l2' }); // null only if no L1 hash

[!TIP] For UX that never throws, use the try* variants and branch on ok.


Route Selection (Automatic)

RouteMeaning
eth-baseETH when L2 base token is ETH
eth-nonbaseETH when L2 base token ≠ ETH
erc20-baseERC-20 that is the L2 base token
erc20-nonbaseERC-20 that is not the L2 base token

You do not pass a route; it’s derived automatically from chain metadata + token.

Method Reference

quote(p: DepositParams) → Promise<DepositQuote>

Estimate the deposit operation (route, approvals, gas hints). Does not send transactions.

Parameters

NameTypeRequiredDescription
tokenAddressL1 token address. Use 0x…00 for ETH.
amountbigintAmount in wei to deposit.
toAddressL2 recipient address. Defaults to the signer’s address if omitted.
refundRecipientAddressOptional address on L1 to receive refunds for unspent gas.
l2GasLimitbigintOptional manual L2 gas limit override.
gasPerPubdatabigintOptional custom gas-per-pubdata value.
operatorTipbigintOptional operator tip (in wei) for priority execution.
l1TxOverridesEip1559GasOverridesOptional EIP-1559 gas settings for the L1 transaction.

Returns: DepositQuote

const q = await sdk.deposits.quote({
  token: ETH_L1,
  amount: parseEther('0.25'),
  to: account.address,
});
/*
{
  route: "eth-base" | "eth-nonbase" | "erc20-base" | "erc20-nonbase",
  approvalsNeeded: [{ token, spender, amount }],
  baseCost?: bigint,
  mintValue?: bigint,
  suggestedL2GasLimit?: bigint,
  gasPerPubdata?: bigint
}
*/

[!TIP] If approvalsNeeded is non-empty (ERC-20), create() will automatically include those steps.

tryQuote(p) → Promise<{ ok: true; value: DepositQuote } | { ok: false; error }>

Result-style quote.

prepare(p: DepositParams) → Promise<DepositPlan<TransactionRequest>>

Build a plan (ordered steps + unsigned txs) without sending.

Returns: DepositPlan

const plan = await sdk.deposits.prepare({ token: ETH_L1, amount: parseEther('0.05'), to });
/*
{
  route,
  summary: DepositQuote,
  steps: [
    { key: "approve:USDC", kind: "approve", tx: TransactionRequest },
    { key: "bridge",       kind: "bridge",  tx: TransactionRequest }
  ]
}
*/

tryPrepare(p) → Promise<{ ok: true; value: DepositPlan } | { ok: false; error }>

Result-style prepare.

create(p: DepositParams) → Promise<DepositHandle<TransactionRequest>>

Prepares and executes all required L1 steps. Returns a handle with the L1 tx hash and per-step hashes.

Returns: DepositHandle

const handle = await sdk.deposits.create({ token, amount, to });
/*
{
  kind: "deposit",
  l1TxHash: Hex,
  stepHashes: Record<string, Hex>,
  plan: DepositPlan
}
*/

[!WARNING] If any step reverts, create() throws a typed error. Prefer tryCreate() to avoid exceptions.

tryCreate(p) → Promise<{ ok: true; value: DepositHandle } | { ok: false; error }>

Result-style create.

status(handleOrHash) → Promise<DepositStatus>

Resolve current phase for a deposit. Accepts either a DepositHandle or a raw L1 tx hash.

PhaseMeaning
UNKNOWNNo L1 hash provided
L1_PENDINGL1 receipt not yet found
L1_INCLUDEDIncluded on L1; L2 hash not derivable yet
L2_PENDINGL2 hash known; waiting for L2 receipt
L2_EXECUTEDL2 receipt found with status === 1
L2_FAILEDL2 receipt found with status !== 1
const s = await sdk.deposits.status(handle);
// { phase, l1TxHash, l2TxHash? }

wait(handleOrHash, { for: 'l1' | 'l2' }) → Promise<TransactionReceipt | null>

Block until a checkpoint is reached.

  • { for: 'l1' } → L1 receipt (or null if no L1 hash)
  • { for: 'l2' } → L2 receipt after canonical execution (or null if no L1 hash)
const l1Receipt = await sdk.deposits.wait(handle, { for: 'l1' });
const l2Receipt = await sdk.deposits.wait(handle, { for: 'l2' });

tryWait(handleOrHash, opts) → Result<TransactionReceipt>

Result-style wait.


End-to-End Examples

ETH Deposit (Typical)

const handle = await sdk.deposits.create({
  token: ETH_ADDRESS,
  amount: parseEther('0.1'),
  to: account.address,
});

await sdk.deposits.wait(handle, { for: 'l2' });

ERC-20 Deposit (with Automatic Approvals)

const handle = await sdk.deposits.create({
  token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC example
  amount: 1_000_000n, // 1 USDC (6 dp)
  to: account.address,
});

const l1Receipt = await sdk.deposits.wait(handle, { for: 'l1' });

Types (Overview)

export interface DepositParams {
  token: Address; // 0x…00 for ETH
  amount: bigint; // wei
  to?: Address; // L2 recipient
  refundRecipient?: Address;
  l2GasLimit?: bigint;
  gasPerPubdata?: bigint;
  operatorTip?: bigint;
  l1TxOverrides?: Eip1559GasOverrides;
}

export interface Eip1559GasOverrides {
  gasLimit?: bigint;
  maxFeePerGas?: bigint;
  maxPriorityFeePerGas?: bigint;
}

export interface DepositQuote {
  route: 'eth-base' | 'eth-nonbase' | 'erc20-base' | 'erc20-nonbase';
  approvalsNeeded: Array<{ token: Address; spender: Address; amount: bigint }>;
  baseCost?: bigint;
  mintValue?: bigint;
  suggestedL2GasLimit?: bigint;
  gasPerPubdata?: bigint;
}

export interface DepositPlan<TTx = TransactionRequest> {
  route: DepositQuote['route'];
  summary: DepositQuote;
  steps: Array<{ key: string; kind: string; tx: TTx }>;
}

export interface DepositHandle<TTx = TransactionRequest> {
  kind: 'deposit';
  l1TxHash: Hex;
  stepHashes: Record<string, Hex>;
  plan: DepositPlan<TTx>;
}

export type DepositStatus =
  | { phase: 'UNKNOWN'; l1TxHash: Hex }
  | { phase: 'L1_PENDING'; l1TxHash: Hex }
  | { phase: 'L1_INCLUDED'; l1TxHash: Hex }
  | { phase: 'L2_PENDING'; l1TxHash: Hex; l2TxHash: Hex }
  | { phase: 'L2_EXECUTED'; l1TxHash: Hex; l2TxHash: Hex }
  | { phase: 'L2_FAILED'; l1TxHash: Hex; l2TxHash: Hex };

[!TIP] Prefer the try* variants to avoid exceptions and work with structured result objects.


Notes & Pitfalls

  • ETH sentinel: Always use the canonical 0x…00 address when passing ETH as token.
  • Receipts timing: wait({ for: 'l2' }) resolves after canonical L2 execution — may take longer than L1 inclusion.
  • Gas hints: suggestedL2GasLimit and gasPerPubdata are informational; advanced users can override via the prepared plan.

Withdrawals

L2 → L1 withdrawals for ETH and ERC-20 tokens with quote, prepare, create, status, wait, and finalize helpers using the Viem adapter.


At a Glance

  • Resource: sdk.withdrawals
  • Typical flow: quote → create → wait({ for: 'l2' }) → wait({ for: 'ready' }) → finalize
  • Auto-routing: ETH vs ERC-20 and base-token vs non-base handled automatically
  • Error style: Throwing methods (quote, prepare, create, status, wait, finalize) + safe result variants (tryQuote, tryPrepare, tryCreate, tryWait, tryFinalize)

Import

import {
  createPublicClient,
  createWalletClient,
  http,
  parseEther,
  type Account,
  type Chain,
  type Transport,
  type WalletClient,
} from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { createViemClient, createViemSdk } from '@matterlabs/zksync-js/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),
});

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

Quick Start

Withdraw 0.1 ETH from L2 → L1 and finalize on L1:

const handle = await sdk.withdrawals.create({
  token: ETH_ADDRESS, // ETH sentinel supported
  amount: parseEther('0.1'),
  to: account.address, // L1 recipient
});

// 1) L2 inclusion (adds l2ToL1Logs if available)
await sdk.withdrawals.wait(handle, { for: 'l2' });

// 2) Wait until finalizable (no side effects)
await sdk.withdrawals.wait(handle, { for: 'ready', pollMs: 6000 });

// 3) Finalize on L1 (no-op if already finalized)
const { status, receipt: l1Receipt } = await sdk.withdrawals.finalize(handle.l2TxHash);

[!INFO] Withdrawals are two-phase: inclusion on L2, then finalization on L1. You can call finalize directly, but it will throw if not yet ready. Prefer wait(..., { for: 'ready' }) to avoid premature finalization errors.

Route Selection (Automatic)

RouteMeaning
eth-baseBase token is ETH on L2
eth-nonbaseBase token is not ETH on L2
erc20-nonbaseWithdrawing an ERC-20 that is not the base token

Routes are derived automatically from network metadata and the supplied token.

Method Reference

quote(p: WithdrawParams) → Promise<WithdrawQuote>

Estimate the operation (route, approvals, gas hints). Does not send transactions.

Parameters

NameTypeRequiredDescription
tokenAddressL2 token (ETH sentinel supported).
amountbigintAmount in wei to withdraw.
toAddressL1 recipient. Defaults to the signer’s address.
l2GasLimitbigintOptional custom gas limit override for the L2 withdrawal transaction.
l2TxOverridesEip1559GasOverridesOptional EIP-1559 overrides for the L2 withdrawal transaction.

Returns: WithdrawQuote

const q = await sdk.withdrawals.quote({ token, amount, to });
/*
{
  route: "eth-base" | "eth-nonbase" | "erc20-nonbase",
  approvalsNeeded: [{ token, spender, amount }],
  suggestedL2GasLimit?: bigint
}
*/

tryQuote(p) → Promise<{ ok: true; value: WithdrawQuote } | { ok: false; error }>

Result-style quote.

prepare(p: WithdrawParams) → Promise<WithdrawPlan<TransactionRequest>>

Builds the plan (ordered L2 steps + unsigned txs) without sending.

Returns: WithdrawPlan

const plan = await sdk.withdrawals.prepare({ token, amount, to });
/*
{
  route,
  summary: WithdrawQuote,
  steps: [
    { key, kind, tx: TransactionRequest },
    // …
  ]
}
*/

tryPrepare(p) → Promise<{ ok: true; value: WithdrawPlan } | { ok: false; error }>

Result-style prepare.

create(p: WithdrawParams) → Promise<WithdrawHandle<TransactionRequest>>

Prepares and executes the required L2 steps. Returns a handle with the L2 transaction hash.

Returns: WithdrawHandle

const handle = await sdk.withdrawals.create({ token, amount, to });
/*
{
  kind: "withdrawal",
  l2TxHash: Hex,
  stepHashes: Record<string, Hex>,
  plan: WithdrawPlan
}
*/

[!WARNING] If any L2 step reverts, create() throws a typed error. Use tryCreate() to avoid exceptions and return a result object.

tryCreate(p) → Promise<{ ok: true; value: WithdrawHandle } | { ok: false; error }>

Result-style create.

status(handleOrHash) → Promise<WithdrawalStatus>

Reports the current phase of a withdrawal. Accepts a WithdrawHandle or raw L2 tx hash.

PhaseMeaning
UNKNOWNNo L2 hash provided
L2_PENDINGL2 receipt not yet available
PENDINGIncluded on L2 but not yet finalizable
READY_TO_FINALIZECan be finalized on L1
FINALIZEDAlready finalized on L1
const s = await sdk.withdrawals.status(handle);
// { phase, l2TxHash, key? }

wait(handleOrHash, { for: 'l2' | 'ready' | 'finalized', pollMs?, timeoutMs? })

Wait until the withdrawal reaches a specific phase.

  • { for: 'l2' } → Resolves the L2 receipt (TransactionReceiptZKsyncOS) or null
  • { for: 'ready' } → Resolves null when finalizable
  • { for: 'finalized' } → Resolves the L1 receipt (if found) or null
const l2Rcpt = await sdk.withdrawals.wait(handle, { for: 'l2' });
await sdk.withdrawals.wait(handle, { for: 'ready', pollMs: 6000, timeoutMs: 15 * 60_000 });
const l1Rcpt = await sdk.withdrawals.wait(handle, { for: 'finalized', pollMs: 7000 });

[!TIP] Default polling is 5500 ms (minimum 1000 ms). Use timeoutMs for long polling windows.

tryWait(handleOrHash, opts) → Result<TransactionReceipt | null>

Result-style wait.

finalize(l2TxHash: Hex) → Promise<{ status: WithdrawalStatus; receipt?: TransactionReceipt }>

Send the L1 finalize transaction if ready. If already finalized, returns the status without sending.

const { status, receipt } = await sdk.withdrawals.finalize(handle.l2TxHash);
if (status.phase === 'FINALIZED') {
  console.log('L1 tx:', receipt?.transactionHash);
}

[!INFO] If not ready, finalize() throws a typed STATE error. Use status() or wait(..., { for: 'ready' }) before calling to avoid exceptions.

tryFinalize(l2TxHash) → Promise<{ ok: true; value: { status: WithdrawalStatus; receipt?: TransactionReceipt } } | { ok: false; error }>

Result-style finalize.

End-to-End Example

const handle = await sdk.withdrawals.create({ token, amount, to });

// L2 inclusion
await sdk.withdrawals.wait(handle, { for: 'l2' });

// Option A: finalize immediately (throws if not ready)
await sdk.withdrawals.finalize(handle.l2TxHash);

// Option B: wait for readiness, then finalize
await sdk.withdrawals.wait(handle, { for: 'ready' });
await sdk.withdrawals.finalize(handle.l2TxHash);

Types (Overview)

export interface WithdrawParams {
  token: Address; // L2 token (ETH sentinel supported)
  amount: bigint; // wei
  to?: Address; // L1 recipient
  l2GasLimit?: bigint;
  l2TxOverrides?: Eip1559GasOverrides;
}

export interface Eip1559GasOverrides {
  gasLimit?: bigint;
  maxFeePerGas?: bigint;
  maxPriorityFeePerGas?: bigint;
}

export interface WithdrawQuote {
  route: 'eth-base' | 'eth-nonbase' | 'erc20-nonbase';
  approvalsNeeded: Array<{ token: Address; spender: Address; amount: bigint }>;
  suggestedL2GasLimit?: bigint;
}

export interface WithdrawPlan<TTx = TransactionRequest> {
  route: WithdrawQuote['route'];
  summary: WithdrawQuote;
  steps: Array<{ key: string; kind: string; tx: TTx }>;
}

export interface WithdrawHandle<TTx = TransactionRequest> {
  kind: 'withdrawal';
  l2TxHash: Hex;
  stepHashes: Record<string, Hex>;
  plan: WithdrawPlan<TTx>;
}

export type WithdrawalStatus =
  | { phase: 'UNKNOWN'; l2TxHash: Hex }
  | { phase: 'L2_PENDING'; l2TxHash: Hex }
  | { phase: 'PENDING'; l2TxHash: Hex; key?: unknown }
  | { phase: 'READY_TO_FINALIZE'; l2TxHash: Hex; key: unknown }
  | { phase: 'FINALIZED'; l2TxHash: Hex; key: unknown };

// L2 receipt augmentation returned by wait({ for: 'l2' })
export type TransactionReceiptZKsyncOS = TransactionReceipt & {
  l2ToL1Logs?: Array<unknown>;
};

Notes & Pitfalls

  • Two chains, two receipts: Inclusion on L2 and finalization on L1 are separate phases.
  • Polling strategy: In production UIs, prefer wait({ for: 'ready' }) before finalize() to avoid premature attempts.
  • Approvals: If ERC-20 approvals are needed for withdrawal, create() automatically handles them.

Finalization Services

Helpers for building and executing L1 finalization of L2 withdrawals using the Viem adapter. These utilities fetch the required L2→L1 proof data, check readiness, and submit finalizeDeposit on the L1 Nullifier contract.

Use these services when you need fine-grained control (preflight simulations, custom gas, external orchestration). For the high-level path, see sdk.withdrawals.finalize(...).


At a Glance

  • Factory: createFinalizationServices(client) → FinalizationServices
  • Workflow: fetch paramsoptionally check statussimulate readinesssubmit finalize tx
  • Prereq: An initialized ViemClient with an L1 wallet (used to sign the L1 finalize tx).

Import & Setup

import { createPublicClient, createWalletClient, http, type Address } from 'viem';
import { createViemClient, createViemSdk, createFinalizationServices } from '@matterlabs/zksync-js/viem';

const l1 = createPublicClient({ transport: http(process.env.ETH_RPC!) });
const l2 = createPublicClient({ transport: http(process.env.ZKSYNC_RPC!) });

const l1Wallet = createWalletClient({
  account: /* your L1 Account */,
  transport: http(process.env.ETH_RPC!),
});

const client = createViemClient({ l1, l2, l1Wallet });
// optional: const sdk = createViemSdk(client);

const svc = createFinalizationServices(client);

Minimal Usage Example

import type { Hex } from 'viem';

const l2TxHash: Hex = '0x...';

// 1) Build finalize params + discover the L1 Nullifier to call
const { params, nullifier } = await svc.fetchFinalizeDepositParams(l2TxHash);

// 2) (Optional) Check if already finalized
const already = await svc.isWithdrawalFinalized({
  chainIdL2: params.chainId,
  l2BatchNumber: params.l2BatchNumber,
  l2MessageIndex: params.l2MessageIndex,
});
if (already) {
  console.log('Already finalized on L1');
} else {
  // 3) Dry-run on L1 to confirm readiness (no gas spent)
  const readiness = await svc.simulateFinalizeReadiness(params, nullifier);

  if (readiness.kind === 'READY') {
    // 4) Submit finalize tx (signed by your L1 wallet)
    const { hash, wait } = await svc.finalizeDeposit(params, nullifier);
    console.log('L1 finalize tx:', hash);
    const rcpt = await wait();
    console.log('Finalized in block:', rcpt.blockNumber);
  } else {
    console.warn('Not ready to finalize:', readiness);
  }
}

Tip: If you prefer the SDK to handle readiness checks automatically, call sdk.withdrawals.finalize(l2TxHash) instead.

API

fetchFinalizeDepositParams(l2TxHash) → Promise<{ params, nullifier }>

Builds the inputs required by Nullifier.finalizeDeposit for a given L2 withdrawal tx.

Parameters

NameTypeRequiredDescription
l2TxHashHexL2 withdrawal transaction hash.

Returns

FieldTypeDescription
paramsFinalizeDepositParamsCanonical finalize input (proof, indices, message).
nullifierAddressL1 Nullifier contract address to call.

isWithdrawalFinalized(key) → Promise<boolean>

Reads the Nullifier mapping to determine whether a withdrawal has already been finalized.

Parameters

NameTypeRequiredDescription
keyWithdrawalKeyUnique key for the withdrawal.

Returns: true if finalized; otherwise false.

simulateFinalizeReadiness(params, nullifier) → Promise<FinalizeReadiness>

Performs a static call on the L1 Nullifier to check whether finalizeDeposit would succeed now (no gas spent).

Parameters

NameTypeRequiredDescription
paramsFinalizeDepositParamsPrepared finalize input.
nullifierAddressL1 Nullifier address.

Returns: FinalizeReadiness (see Types).

finalizeDeposit(params, nullifier) → Promise<{ hash: string; wait: () => Promise<TransactionReceipt> }>

Sends the L1 finalize transaction to the Nullifier with the provided params.

Parameters

NameTypeRequiredDescription
paramsFinalizeDepositParamsPrepared finalize input.
nullifierAddressL1 Nullifier address.

Returns

FieldTypeDescription
hashstringSubmitted L1 transaction hash.
wait() => Promise<TransactionReceipt>Helper to await on-chain inclusion of the tx.

Warning: This call will revert if the withdrawal is not ready or invalid. Prefer simulateFinalizeReadiness or sdk.withdrawals.wait(..., { for: 'ready' }) first.

Status & Phases

If you are also using sdk.withdrawals.status(...), the phases align conceptually with readiness:

Withdrawal PhaseMeaningReadiness interpretation
L2_PENDINGL2 tx not in a block yetNot ready
L2_INCLUDEDL2 receipt is availableNot ready (proof not derivable yet)
PENDINGInclusion known; proof data not yet derivable/availableNOT_READY
READY_TO_FINALIZEProof posted; can be finalized on L1READY
FINALIZINGL1 finalize tx sent but not yet indexedBetween READY and FINALIZED
FINALIZEDWithdrawal finalized on L1FINALIZED
FINALIZE_FAILEDPrior L1 finalize revertedPossibly UNFINALIZABLE
UNKNOWNNo L2 hash or insufficient dataN/A

Types

// Finalize call input
export interface FinalizeDepositParams {
  chainId: bigint;
  l2BatchNumber: bigint;
  l2MessageIndex: bigint;
  l2Sender: Address;
  l2TxNumberInBatch: number;
  message: Hex;
  merkleProof: Hex[];
}

// Key that identifies a withdrawal in the Nullifier mapping
export type WithdrawalKey = {
  chainIdL2: bigint;
  l2BatchNumber: bigint;
  l2MessageIndex: bigint;
};

// Overall withdrawal state (used by higher-level status helpers)
type WithdrawalPhase =
  | 'L2_PENDING'
  | 'L2_INCLUDED'
  | 'PENDING'
  | 'READY_TO_FINALIZE'
  | 'FINALIZING'
  | 'FINALIZED'
  | 'FINALIZE_FAILED'
  | 'UNKNOWN';

export type WithdrawalStatus = {
  phase: WithdrawalPhase;
  l2TxHash: Hex;
  l1FinalizeTxHash?: Hex;
  key?: WithdrawalKey;
};

// Readiness result returned by simulateFinalizeReadiness(...)
export type FinalizeReadiness =
  | { kind: 'READY' }
  | { kind: 'FINALIZED' }
  | {
      kind: 'NOT_READY';
      // temporary, retry later
      reason: 'paused' | 'batch-not-executed' | 'root-missing' | 'unknown';
      detail?: string;
    }
  | {
      kind: 'UNFINALIZABLE';
      // permanent, won’t become ready
      reason: 'message-invalid' | 'invalid-chain' | 'settlement-layer' | 'unsupported';
      detail?: string;
    };

// Viem-bound service surface
export interface FinalizationServices {
  fetchFinalizeDepositParams(
    l2TxHash: Hex,
  ): Promise<{ params: FinalizeDepositParams; nullifier: Address }>;

  isWithdrawalFinalized(key: WithdrawalKey): Promise<boolean>;

  simulateFinalizeReadiness(
    params: FinalizeDepositParams,
    nullifier: Address,
  ): Promise<FinalizeReadiness>;

  finalizeDeposit(
    params: FinalizeDepositParams,
    nullifier: Address,
  ): Promise<{ hash: string; wait: () => Promise<TransactionReceipt> }>;
}

Notes & Pitfalls

  • Anyone can finalize: The withdrawer, a relayer, or your backend—finalization is permissionless.
  • Delay is expected: Proof generation/posting introduce lag between L2 inclusion and readiness.
  • Gas: Finalization is an L1 transaction; ensure the L1 wallet has ETH for gas.
  • Error surface: Underlying calls can throw typed errors (STATE, RPC, VERIFICATION). Check readiness to avoid avoidable failures.

Cross-References