Finalization Services

Helpers for building and executing L1 finalization of L2 withdrawals using the Viem adapter. These utilities fetch the required L2→L1 proof data, check readiness, and submit the finalization tx on L1. They are protocol-aware: on protocol v31 and below they call finalizeDeposit on the L1 Nullifier; from v32 on they call executeBundle on the L1 InteropHandler, which replaced it.

Use these services when you need fine-grained control (preflight simulations, custom gas, external orchestration). For the high-level path, see sdk.withdrawals.finalize(...).


At a Glance

  • Factory: createFinalizationServices(client) → FinalizationServices
  • Workflow: fetch finalization → optionally check status → simulate readiness → submit finalize tx
  • Prereq: An initialized ViemClient with an L1 wallet (used to sign the L1 finalize tx).

Import & Setup

import { privateKeyToAccount } from 'viem/accounts';
import { createPublicClient, createWalletClient, http, parseEther } from 'viem';
import { createViemClient, createViemSdk, createFinalizationServices } from '@matterlabs/zksync-js/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); // optional
const svc = createFinalizationServices(client);

Minimal Usage Example

// 1) Derive the finalization args + the L1 contract to call. Works on both protocols: pre-v32
//    chains resolve to `L1Nullifier.finalizeDeposit`, v32+ chains to
//    `L1InteropHandler.executeBundle`.
const { finalization, key } = await svc.fetchFinalization(handle.l2TxHash);

// 2) (Optional) check finalization
const already = await svc.isWithdrawalFinalized(finalization);
if (already) {
  console.log('Already finalized on L1', key);
} else {
  // 3) Dry-run on L1 to confirm readiness (no gas spent)
  const readiness = await svc.simulateFinalizeReadiness(finalization);

  if (readiness.kind === 'READY') {
    // 4) Submit finalize tx
    const { hash, wait } = await svc.finalize(finalization);
    console.log('L1 finalize tx:', hash);
    const rcpt = await wait();
    console.log('Finalized in block:', rcpt.blockNumber);
  } else {
    console.warn('Not ready to finalize:', readiness);
  }
}

Tip: If you prefer the SDK to handle readiness checks automatically, call sdk.withdrawals.finalize(l2TxHash) instead.

API

fetchFinalization(l2TxHash) → Promise<ResolvedWithdrawalFinalization>

Derives the finalization arguments for a given L2 withdrawal tx, tagged with the withdrawal protocol the chain speaks. This is the protocol-neutral entry point: it resolves to L1Nullifier.finalizeDeposit on protocol v31 and below, and to L1InteropHandler.executeBundle on v32 and above.

Parameters

NameTypeRequiredDescription
l2TxHashHex✅L2 withdrawal transaction hash.

Returns

FieldTypeDescription
targetAddressL1 contract to send the finalization to.
finalizationWithdrawalFinalizationProtocol-tagged finalize input (see Types).
keyWithdrawalKeyIdentifying key; carries bundleHash on v32+.

fetchFinalizeDepositParams(l2TxHash) → Promise<{ params, nullifier }>

[!WARNING] Deprecated. Only meaningful on protocol v31 chains. On v32+ this throws, because L1Nullifier.finalizeDeposit no longer exists. Use fetchFinalization instead.

Builds the inputs required by Nullifier.finalizeDeposit for a given L2 withdrawal tx.

Parameters

NameTypeRequiredDescription
l2TxHashHex✅L2 withdrawal transaction hash.

Returns

FieldTypeDescription
paramsFinalizeDepositParamsCanonical finalize input (proof, indices, message).
nullifierAddressL1 Nullifier contract address to call.

isWithdrawalFinalized(finalization) → Promise<boolean>

Checks whether the withdrawal has already been finalized on L1. Reads the Nullifier mapping on v31, and the interop handler's bundle status on v32+ (finalized means FullyExecuted or Unbundled).

Parameters

NameTypeRequiredDescription
finalizationWithdrawalFinalization✅As returned by fetchFinalization.

Returns: true if finalized; otherwise false.

simulateFinalizeReadiness(finalization) → Promise<FinalizeReadiness>

Performs a static call on the resolved L1 contract to check whether finalization would succeed now (no gas spent).

Parameters

NameTypeRequiredDescription
finalizationWithdrawalFinalization✅As returned by fetchFinalization.

Returns: FinalizeReadiness

Readiness states (see Types) include:

  • { kind: 'READY' }
  • { kind: 'FINALIZED' }
  • { kind: 'NOT_READY', reason, detail? } (temporary)
  • { kind: 'UNFINALIZABLE', reason, detail? } (permanent)

estimateFinalization(finalization) → Promise<FinalizationEstimate>

Estimates gas and per-gas fees for the L1 finalization transaction.

Parameters

NameTypeRequiredDescription
finalizationWithdrawalFinalization✅As returned by fetchFinalization.

finalize(finalization) → Promise<{ hash; wait: () => Promise<TransactionReceipt> }>

Sends the L1 finalize transaction — finalizeDeposit on the Nullifier (v31) or executeBundle on the interop handler (v32+).

Parameters

NameTypeRequiredDescription
finalizationWithdrawalFinalization✅As returned by fetchFinalization.

Returns

FieldTypeDescription
hashstringSubmitted L1 transaction hash.
wait() => Promise<TransactionReceipt>Helper to await on-chain inclusion of the tx.

[!WARNING] This method will revert if the withdrawal is not ready or invalid. Prefer calling simulateFinalizeReadiness or using sdk.withdrawals.wait(..., { for: 'ready' }) first.

Status & Phases

If you are also using sdk.withdrawals.status(...), the phases align conceptually with readiness:

Withdrawal PhaseMeaningReadiness interpretation
L2_PENDINGL2 tx not in a block yetNot ready
L2_INCLUDEDL2 receipt is availableNot ready (proof not derivable yet)
PENDINGInclusion known; proof data not yet derivable/availableNOT_READY
READY_TO_FINALIZEProof posted; can be finalized on L1READY
FINALIZINGL1 finalize tx sent but not yet indexedBetween READY and FINALIZED
FINALIZEDWithdrawal finalized on L1FINALIZED
FINALIZE_FAILEDPrior L1 finalize revertedPossibly UNFINALIZABLE
UNKNOWNNo L2 hash or insufficient dataN/A

Types

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
  | 'UNFINALIZABLE' // finalization can never succeed for this withdrawal
  | 'UNKNOWN';

// Withdrawal Status
type WithdrawalStatus = {
  phase: WithdrawalPhase;
  l2TxHash: Hex;
  l1FinalizeTxHash?: Hex;
  key?: WithdrawalKey;
  // Why the withdrawal is PENDING or UNFINALIZABLE, when known.
  reason?: string;
};

interface FinalizeDepositParams {
  chainId: bigint;
  l2BatchNumber: bigint;
  l2MessageIndex: bigint;
  l2Sender: Address;
  l2TxNumberInBatch: number;
  message: Hex;
  merkleProof: Hex[];
}

// Protocol v32+ finalization inputs: the withdrawal's interop bundle and its inclusion proof.
interface WithdrawalBundleFinalization {
  bundle: Hex;
  bundleHash: Hex;
  proof: {
    chainId: bigint;
    l1BatchNumber: bigint;
    l2MessageIndex: bigint;
    message: { txNumberInBatch: number; sender: Address; data: Hex };
    proof: Hex[];
  };
}

// Outcome of a withdrawal bundle on the destination.
//  - `finalized` — the call ran; funds released on L1
//  - `failed`    — terminally unwound with the call cancelled; funds NOT released
//  - `pending`   — not resolved yet
type WithdrawalOutcome = 'finalized' | 'failed' | 'pending';

// Which contract finalizes the withdrawal, and with which arguments.
//  - `legacy-withdrawal` → L1Nullifier.finalizeDeposit        (protocol v31 and below)
//  - `interop-bundle`     → L1InteropHandler.executeBundle     (protocol v32 and above)
type WithdrawalFinalization =
  | { protocol: 'legacy-withdrawal'; params: FinalizeDepositParams }
  | { protocol: 'interop-bundle'; params: WithdrawalBundleFinalization };

interface ResolvedWithdrawalFinalization {
  target: Address;
  finalization: WithdrawalFinalization;
  key: WithdrawalKey;
}

// Finalization readiness states
// Used for `status()`
type FinalizeReadiness =
  | { kind: 'READY' }
  | { kind: 'FINALIZED' }
  | {
      kind: 'NOT_READY';
      // temporary, retry later
      reason: 'paused' | 'batch-not-executed' | 'root-missing' | 'unknown';
      detail?: string;
    }
  | {
      kind: 'UNFINALIZABLE';
      // permanent, won’t become ready
      reason: 'message-invalid' | 'invalid-chain' | 'settlement-layer' | 'unsupported';
      detail?: string;
    };

interface FinalizationEstimate {
  gasLimit: bigint;
  maxFeePerGas: bigint;
  maxPriorityFeePerGas: bigint;
}

interface FinalizationServices {
  /**
   * Derive the finalization arguments for a withdrawal, tagged with the protocol they belong to.
   */
  fetchFinalization(l2TxHash: Hex): Promise<ResolvedWithdrawalFinalization>;

  /**
   * Build `finalizeDeposit` params.
   *
   * @deprecated Only meaningful on protocol v31 chains. Throws on v32+, where withdrawals are
   * finalized through the interop handler — use {@link fetchFinalization} instead.
   */
  fetchFinalizeDepositParams(
    l2TxHash: Hex,
  ): Promise<{ params: FinalizeDepositParams; nullifier: Address }>;

  /** Check whether the withdrawal has already been finalized on L1. */
  isWithdrawalFinalized(finalization: WithdrawalFinalization): Promise<boolean>;

  /**
   * Classify the withdrawal's on-chain outcome. Distinguishes a terminally-failed bundle (unwound
   * with its call cancelled) from one that is merely not finalized yet.
   */
  withdrawalOutcome(finalization: WithdrawalFinalization): Promise<WithdrawalOutcome>;

  /** Simulate finalization on L1 to check readiness. */
  simulateFinalizeReadiness(finalization: WithdrawalFinalization): Promise<FinalizeReadiness>;

  /** Estimate gas & fees for finalization on L1. */
  estimateFinalization(finalization: WithdrawalFinalization): Promise<FinalizationEstimate>;

  /** Send the finalization transaction on L1. */
  finalize(
    finalization: WithdrawalFinalization,
  ): Promise<{ hash: string; wait: () => Promise<TransactionReceipt> }>;
}

Notes & Pitfalls

  • Anyone can finalize: The withdrawer, a relayer, or your backend—finalization is permissionless.
  • Delay is expected: Proof generation/posting introduce lag between L2 inclusion and readiness.
  • Gas: Finalization is an L1 transaction; ensure the L1 wallet has ETH for gas.
  • Error surface: Underlying calls can throw typed errors (STATE, RPC, VERIFICATION). Check readiness to avoid avoidable failures.

Cross-References