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)

import {
  createPublicClient,
  createWalletClient,
  defineChain,
  http,
  parseEther,
  type Account,
  type Chain,
  type 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 || '';

const l1Chain = defineChain({
  id: 31337,
  name: "Local L1 Chain",
  nativeCurrency: {
    name: "Ether",
    symbol: "ETH",
    decimals: 18,
  },
  rpcUrls: {
    default: {
      http: [L1_RPC],
    },
  },
});

const l2Chain = defineChain({
  id: 6565,
  name: "local L2",
  nativeCurrency: {
    name: "Ether",
    symbol: "ETH",
    decimals: 18,
  },
  rpcUrls: {
    default: {
      http: [L2_RPC],
    },
  },
});

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>({
    chain: l1Chain,
    account,
    transport: http(L1_RPC),
  });
  const l2Wallet = createWalletClient<Transport, Chain, Account>({
    chain: l2Chain,
    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 handle = await sdk.withdrawals.create(params);
  console.log('CREATE:', handle);

  // Quick status
  const status = await sdk.withdrawals.status(handle.l2TxHash); // input can be handle or l2TxHash
  // status.phase: 'UNKNOWN' | 'L2_PENDING' | 'PENDING' | 'READY_TO_FINALIZE' | 'FINALIZED'
  console.log('STATUS (initial):', status);

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

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

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

  const l1Receipt = await sdk.withdrawals.wait(handle.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 status = await sdk.withdrawals.status(handle.l2TxHash); // input can be handle or l2TxHash
  // status.phase: 'UNKNOWN' | 'L2_PENDING' | 'PENDING' | 'READY_TO_FINALIZE' | 'FINALIZED'

Block until checkpoint

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

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