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.