Finalization (Withdrawals)

Withdrawals from ZKsync (L2) only complete on Ethereum (L1) after you explicitly call finalize.

When withdrawing from ZKsync (L2) back to Ethereum (L1), funds are not automatically released on L1 after your L2 transaction is included.

Withdrawals are a two-step process:

  1. Initiate on L2 — call withdraw() (via the SDK’s create) to start the withdrawal. This burns or locks funds on L2 and emits logs; funds are still unavailable on L1.
  2. Finalize on L1 — call finalize(l2TxHash) to release funds on L1. This submits an L1 transaction; only then does your ETH or token balance increase on Ethereum.

[!WARNING] If you never finalize, your funds remain locked — visible as “ready to withdraw,” but unavailable on L1. Anyone can finalize on your behalf, but typically you should do it.

Protocol Versions (v31 vs v32)

Protocol v32 replaced both ends of the withdrawal flow, and neither change is backwards compatible:

StepProtocol v31 and belowProtocol v32 and above
Initiate ETHL2BaseToken.withdraw(l1Receiver)InteropCenter.sendBundle(...)
Initiate ERC-20L2AssetRouter.withdraw(assetId, transferData)InteropCenter.sendBundle(...)
FinalizeL1Nullifier.finalizeDeposit(params)L1InteropHandler.executeBundle(bundle, proof)
Check statusL1Nullifier.isWithdrawalFinalized(...)L1InteropHandler.bundleStatus(bundleHash)

From v32 on, a withdrawal is an interop bundle: a single indirect call to the L2 asset router, destined for the L1 chain. Base-token and ERC-20 withdrawals share that one path.

The SDK handles this for you. It detects the chain's withdrawal protocol once per client and picks the matching path, so create, status, wait and finalize behave identically on both. Detection uses, in order:

  1. The chain's protocol version, read from its ChainTypeManager. Note this is the per-chain version, not the CTM's latest — during a rolling ecosystem upgrade the two differ.
  2. If that cannot be read, whether the v32-only InteropAttributeParser (0x…010015) has bytecode on L2. That contract is force-deployed on every v32 chain, EraVM and ZKsync OS alike.

If neither probe can run (for example the chain's Bridgehub is not reachable from your L1 provider), force it explicitly:

const sdk = createEthersSdk(client, { withdrawals: { protocol: 'interop-bundle' } });

[!WARNING] A withdrawal initiated before a chain's v32 upgrade cannot be finalized after it: v32 removed L1Nullifier.finalizeDeposit entirely, and the pre-upgrade L2→L1 message is not an interop bundle. Finalize in-flight withdrawals before the upgrade lands. The SDK reports this case with an explicit error rather than an opaque revert.

Why Finalization Matters

  • Funds remain locked until finalized.
  • Anyone can finalize — typically the withdrawer does.
  • Finalization costs L1 gas — budget for it.

Finalization Methods

MethodPurposeReturns
withdrawals.status(h | l2TxHash)Snapshot phase (UNKNOWNFINALIZED)WithdrawalStatus
withdrawals.wait(h | l2TxHash, { for })Block until a checkpoint ('l2' | 'ready' | 'finalized')Receipt or null
withdrawals.finalize(l2TxHash)Send the L1 finalize transaction{ status, receipt }

[!NOTE] All methods accept either a handle (from create) or a raw L2 transaction hash. If you only have the hash, you can still finalize.

Phases

PhaseMeaning
UNKNOWNHandle doesn’t contain an L2 hash yet.
L2_PENDINGL2 transaction not yet included.
PENDINGL2 included, but not yet ready to finalize on L1.
UNFINALIZABLEFinalization can never succeed; see status.reason.
READY_TO_FINALIZEFinalization on L1 would succeed now.
FINALIZEDFinalized on L1; funds released.

Examples

finalize-by-handle.ts
// 1) Create on L2
const withdrawal = await sdk.withdrawals.create({
  token: ETH_ADDRESS,
  amount: parseEther('0.01'),
  to: account.address,
});

// 2) Wait until finalizable (no side effects)
await sdk.withdrawals.wait(withdrawal, { for: 'ready', pollMs: 5500 });

// 3) Finalize on L1
const { status, receipt } = await sdk.withdrawals.finalize(withdrawal.l2TxHash);

console.log(status.phase); // "FINALIZED"
console.log(receipt?.transactionHash); // L1 finalize tx hash
finalize-by-hash.ts
// If you only have the L2 tx hash:
const l2TxHash = '0x...';

// Optionally confirm readiness first
const s = await sdk.withdrawals.status(l2TxHash);
if (s.phase !== 'READY_TO_FINALIZE') {
  await sdk.withdrawals.wait(l2TxHash, { for: 'ready', timeoutMs: 30 * 60_000 });
}

// Then finalize
const { status, receipt } = await sdk.withdrawals.finalize(l2TxHash);

Prefer "no-throw" variants in UI/services that need explicit flow control.

const r = await sdk.withdrawals.tryFinalize(l2TxHash);
if (!r.ok) {
  console.error('Finalize failed:', r.error);
} else {
  console.log('Status:', r.value.status);
  console.log('Finalized on L1 tx hash?:', r.value.receipt?.transactionHash);
}

Operational Tips

  • Gate UX with phases: Display a Finalize button only when status.phase === 'READY_TO_FINALIZE'.
  • Polling cadence: wait(..., { for: 'ready' }) defaults to ~5500 ms. Adjust with pollMs as needed.
  • Timeouts: Use timeoutMs for long windows and fall back to status(...) to keep UIs responsive.
  • Receipts may be null: wait(..., { for: 'finalized' }) can resolve to null if finalized but receipt is unavailable; show an L1 explorer link based on the submitted transaction hash.

Common Errors

TypeDescriptionAction
RPCRPC or network hiccup (ZKsyncError: RPC)Retry with backoff.
INTERNALDecode or internal issue (ZKsyncError: INTERNAL)Capture logs and report.

See Also