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 finalizationoptionally check statussimulate readinesssubmit 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
l2TxHashHexL2 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
l2TxHashHexL2 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
finalizationWithdrawalFinalizationAs 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
finalizationWithdrawalFinalizationAs 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
finalizationWithdrawalFinalizationAs 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
finalizationWithdrawalFinalizationAs 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