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
| Name | Type | Required | Description |
|---|---|---|---|
l2TxHash | Hex | ✅ | L2 withdrawal transaction hash. |
Returns
| Field | Type | Description |
|---|---|---|
target | Address | L1 contract to send the finalization to. |
finalization | WithdrawalFinalization | Protocol-tagged finalize input (see Types). |
key | WithdrawalKey | Identifying key; carries bundleHash on v32+. |
fetchFinalizeDepositParams(l2TxHash) → Promise<{ params, nullifier }>
[!WARNING] Deprecated. Only meaningful on protocol v31 chains. On v32+ this throws, because
L1Nullifier.finalizeDepositno longer exists. UsefetchFinalizationinstead.
Builds the inputs required by Nullifier.finalizeDeposit for a given L2 withdrawal tx.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
l2TxHash | Hex | ✅ | L2 withdrawal transaction hash. |
Returns
| Field | Type | Description |
|---|---|---|
params | FinalizeDepositParams | Canonical finalize input (proof, indices, message). |
nullifier | Address | L1 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
| Name | Type | Required | Description |
|---|---|---|---|
finalization | WithdrawalFinalization | ✅ | 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
| Name | Type | Required | Description |
|---|---|---|---|
finalization | WithdrawalFinalization | ✅ | 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
| Name | Type | Required | Description |
|---|---|---|---|
finalization | WithdrawalFinalization | ✅ | 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
| Name | Type | Required | Description |
|---|---|---|---|
finalization | WithdrawalFinalization | ✅ | As returned by fetchFinalization. |
Returns
| Field | Type | Description |
|---|---|---|
hash | string | Submitted 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
simulateFinalizeReadinessor usingsdk.withdrawals.wait(..., { for: 'ready' })first.
Status & Phases
If you are also using sdk.withdrawals.status(...), the phases align conceptually with readiness:
| Withdrawal Phase | Meaning | Readiness interpretation |
|---|---|---|
L2_PENDING | L2 tx not in a block yet | Not ready |
L2_INCLUDED | L2 receipt is available | Not ready (proof not derivable yet) |
PENDING | Inclusion known; proof data not yet derivable/available | NOT_READY |
READY_TO_FINALIZE | Proof posted; can be finalized on L1 | READY |
FINALIZING | L1 finalize tx sent but not yet indexed | Between READY and FINALIZED |
FINALIZED | Withdrawal finalized on L1 | FINALIZED |
FINALIZE_FAILED | Prior L1 finalize reverted | Possibly UNFINALIZABLE |
UNKNOWN | No L2 hash or insufficient data | N/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.