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

Import

import { JsonRpcProvider, Wallet, parseEther } 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 = createEthersClient({ l1, l2, signer });
const sdk = createEthersSdk(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: await signer.getAddress() as `0x${string}`, // 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
baseWithdrawing the base token (ETH or otherwise)
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: "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>>

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?.hash);
}

[!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: 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 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.