Skip to main content

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) + result variants (tryQuote, tryPrepare, tryCreate, tryWait, tryFinalize)

Import

import { JsonRpcProvider, Wallet, parseEther } from 'ethers';
import { createEthersClient, createEthersSdk } from '@dutterbutter/zksync-sdk/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);
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)

  • eth-base — Base token is ETH on L2
  • eth-nonbase — Base token is not ETH on L2
  • erc20-nonbase — Withdrawing an ERC-20 that is not the base token
You do not pass a route; it’s derived from network metadata + token.

Method reference

quote(p: WithdrawParams) → Promise<WithdrawQuote>

Estimate the operation (route, approvals, gas hints). Does not send txs. 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 required L2 steps. Returns a handle with the L2 tx hash. Returns: WithdrawHandle
const handle = await sdk.withdrawals.create({ token, amount, to });
/*
{
  kind: "withdrawal",
  l2TxHash: Hex,
  stepHashes: Record<string, Hex>,
  plan: WithdrawPlan
}
*/
If any L2 step reverts, create throws a typed error. Prefer tryCreate for a result object.

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

Result-style create.

status(handleOrHash) → Promise<WithdrawalStatus>

Report current phase for a withdrawal. Accepts the WithdrawHandle from create or a raw L2 tx hash. Phases
  • UNKNOWN — no L2 hash provided
  • L2_PENDING — L2 receipt missing
  • PENDING — included on L2 but not yet finalizable
  • READY_TO_FINALIZE — can be finalized on L1 now
  • FINALIZED — already 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 is reached.
  • { for: 'l2' } → resolves L2 receipt (TransactionReceiptZKsyncOS) or null
  • { for: 'ready' } → resolves null when 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 });
Default polling is 5500 ms (min 1000 ms). Use timeoutMs for long windows.

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

Result-style wait.

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

Sends 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);
}
If not ready, finalize throws a typed STATE error. Use status(…) or wait(..., { for: 'ready' }) first to avoid throws.

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

Result-style finalize.

End-to-end examples

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)

type WithdrawParams = {
  token: Address; // L2 token (ETH sentinel supported)
  amount: bigint; // wei
  to: Address; // L1 recipient
};

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

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

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

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' })
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; it avoids premature finalize calls.
  • Approvals: if withdrawing ERC-20 requires approvals, create will include those steps automatically.