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)
  • Token mapping: Use sdk.tokens if you need L1/L2 token addresses or assetIds ahead of time.

Import

import { createViemClient, createViemSdk } from '@matterlabs/zksync-js/viem';
import { privateKeyToAccount } from 'viem/accounts';
import { createPublicClient, createWalletClient, http, parseEther } from '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({ chain: l1Chain, account, transport: http(process.env.L1_RPC!) });
const l2Wallet = createWalletClient({ chain: l2Chain, account, transport: http(process.env.L2_RPC!) });

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

Quick Start

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

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

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
baseWithdrawing the base token (ETH or otherwise)
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: "base" | "erc20-nonbase",
  summary: {
    route,
    approvalsNeeded: [{ token, spender, amount }],
    amounts: {
      transfer: { token, amount }
    },
    fees: {
      token,
      maxTotal,
      mintValue,
      l2: { gasLimit, maxFeePerGas, maxPriorityFeePerGas, total }
    }
  }
}
*/

Fee estimation notes

  • If approvalsNeeded is non-empty, the withdraw gas estimate may be unavailable and fees.l2 can be zeros. Treat this as unknown, not free.
  • After the approval transaction is confirmed, call quote or prepare again to get a withdraw fee estimate.
  • quote only covers the withdraw transaction. Approval gas is not included in the fee breakdown.

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: wait for readiness, then finalize
await sdk.withdrawals.wait(handle, { for: 'ready' });
await sdk.withdrawals.finalize(handle.l2TxHash);

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

Types (Overview)

Withdraw Params

type TxOverrides = {
  gasLimit: bigint;
  maxFeePerGas: bigint;
  maxPriorityFeePerGas?: bigint | undefined;
}

interface WithdrawParams {
  token: Address;
  amount: bigint;
  to?: Address;
  refundRecipient?: Address;
  l2TxOverrides?: TxOverrides;
}

Withdraw Quote

/** Routes */
type WithdrawRoute = 'base' | 'erc20-nonbase';

interface ApprovalNeed {
  token: Address;
  spender: Address;
  amount: bigint;
}

type L2WithdrawalFeeParams = {
  gasLimit: bigint;
  maxFeePerGas: bigint;
  maxPriorityFeePerGas?: bigint;
  total: bigint;
};

type WithdrawalFeeBreakdown = {
  token: Address; // fee token address
  maxTotal: bigint; // max amount that can be charged
  mintValue?: bigint;
  l2?: L2WithdrawalFeeParams;
};

/** Quote */
interface WithdrawQuote {
  route: WithdrawRoute;
  approvalsNeeded: readonly ApprovalNeed[];
  amounts: {
    transfer: { token: Address; amount: bigint };
  };
  fees: WithdrawalFeeBreakdown;
}

Withdraw Plan

interface PlanStep<Tx, Preview = undefined> {
  key: string;
  kind: string;
  description: string;
  /** Adapter-specific request (ethers TransactionRequest, viem WriteContractParameters, etc.) */
  tx: Tx;
  /** Optional compact, human-friendly view for logging/UI */
  preview?: Preview;
}

interface Plan<Tx, Route, Quote> {
  route: Route;
  summary: Quote;
  steps: Array<PlanStep<Tx>>;
}

/** Plan (Tx generic) */
type WithdrawPlan<Tx> = Plan<Tx, WithdrawRoute, WithdrawQuote>;

Withdraw Waitable

interface Handle<TxHashMap extends Record<string, Hex>, Route, PlanT> {
  kind: 'deposit' | 'withdrawal';
  route?: Route;
  stepHashes: TxHashMap; // step key -> tx hash
  plan: PlanT;
}

/** Handle */
interface WithdrawHandle<Tx>
  extends Handle<Record<string, Hex>, WithdrawRoute, WithdrawPlan<Tx>> {
  kind: 'withdrawal';
  l2TxHash: Hex;
  l1TxHash?: Hex;
  l2BatchNumber?: number;
  l2MessageIndex?: number;
  l2TxNumberInBatch?: number;
}

/** Waitable */
type WithdrawalWaitable = Hex | { l2TxHash?: Hex; l1TxHash?: Hex } | WithdrawHandle<unknown>;

interface L2ToL1Log {
  l2ShardId?: number;
  isService?: boolean;
  txNumberInBlock?: number;
  sender?: Address;
  key?: Hex;
  value?: Hex;
}

// L2 receipt augmentation returned by wait({ for: 'l2' })
type TransactionReceiptZKsyncOS = TransactionReceipt & {
  l2ToL1Logs?: L2ToL1Log[];
};

Withdraw Status

type WithdrawalKey = {
  chainIdL2: bigint;
  l2BatchNumber: bigint;
  l2MessageIndex: bigint;
};

type WithdrawalPhase =
  | 'L2_PENDING' // tx not in an L2 block yet
  | 'L2_INCLUDED' // we have the L2 receipt
  | 'PENDING' // inclusion known; proof data not yet derivable/available
  | 'READY_TO_FINALIZE' // Ready to call finalize on L1
  | 'FINALIZING' // L1 tx sent but not picked up yet
  | 'FINALIZED' // L2-L1 tx finalized on L1
  | 'FINALIZE_FAILED' // prior L1 finalize reverted
  | 'UNKNOWN';

// Withdrawal Status
type WithdrawalStatus = {
  phase: WithdrawalPhase;
  l2TxHash: Hex;
  l1FinalizeTxHash?: Hex;
  key?: WithdrawalKey;
};

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.