Interop

Cross-chain execution between ZKsync L2 chains: send native tokens, ERC-20 tokens, or arbitrary contract calls from a source L2 to a destination L2 using the viem adapter.


At a Glance

  • Resource: sdk.interop
  • Typical flow: create → wait → finalize
  • Inspection flow: quote → prepare → create → status → wait → finalize
  • Error style: Throwing methods (quote, prepare, create, status, wait, finalize, getInteropRoot, verifyBundle) + safe variants (tryQuote, tryPrepare, tryCreate, tryWait, tryFinalize)
  • SDK config: Requires interop: { gwChain } — see Import

Import

import { createPublicClient, createWalletClient, http, type Account, type Chain, type Transport } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { createViemClient, createViemSdk } 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 l2Src = createPublicClient({ transport: http(process.env.SRC_L2_RPC!) });
const l1Wallet = createWalletClient<Transport, Chain, Account>({
  account,
  transport: http(process.env.L1_RPC!),
});

const client = createViemClient({ l1, l2: l2Src, l1Wallet });
const sdk = createViemSdk(client, {
  interop: { gwChain: process.env.GW_RPC! }, // required for interop
});
// sdk.interop → InteropResource

[!INFO] The gwChain option is required for interop. It can be a RPC URL string or a live PublicClient. It is used to poll the gateway chain for interop root availability during wait().

Quick Start

Send 0.001 ETH from source L2 to destination L2:

const handle = await sdk.interop.create(l2Dst, {
  actions: [{ type: 'sendErc20', token: tokenSrcAddress, to: me, amount: 1_000_000n }],
});

const finalizationInfo = await sdk.interop.wait(l2Dst, handle);
const result = await sdk.interop.finalize(l2Dst, finalizationInfo);
// result.dstExecTxHash — tx hash on destination chain

[!TIP] For UX that never throws, use the try* variants and branch on ok.


Method Reference

quote(dstChain, params) → Promise<InteropQuote>

Estimate the operation (route, approvals, fee). Does not send transactions.

Parameters

NameTypeRequiredDescription
dstChainChainRefDestination chain — URL string or PublicClient.
params.actionsInteropAction[]Ordered list of actions to execute on the destination chain.
params.execution{ only: Address }Restrict who can execute the bundle on destination.
params.unbundling{ by: Address }Allow a specific address to unbundle actions individually.
params.fee{ useFixed: boolean }Use fixed ZK fee (true) instead of dynamic base-token fee.
params.txOverridesTxOverridesGas overrides for the source L2 transaction.

Returns: InteropQuote

const q = await sdk.interop.quote(l2Dst, {
  actions: [{ type: 'sendErc20', token: tokenSrcAddress, to: me, amount: 1_000_000n }],
});
/*
{
  route: 'direct' | 'indirect',
  approvalsNeeded: [],
  totalActionValue: bigint,
  bridgedTokenTotal: bigint,
  interopFee: { token, amount },
  l2Fee?: bigint
}
*/

[!TIP] If approvalsNeeded is non-empty (ERC-20 actions), create() will include approval steps automatically.

tryQuote(dstChain, params) → Promise<{ ok: true; value: InteropQuote } | { ok: false; error }>

Result-style quote.

prepare(dstChain, params) → Promise<InteropPlan<TransactionRequest>>

Build the plan (ordered steps + unsigned transactions) without sending.

Returns: InteropPlan

const plan = await sdk.interop.prepare(l2Dst, {
  actions: [{ type: 'sendErc20', token: tokenSrcAddress, to: me, amount: 1_000_000n }],
});
/*
{
  route: 'direct' | 'indirect',
  summary: InteropQuote,
  steps: [
    { key: 'sendBundle', kind: 'sendBundle', description: '...', tx: ... }
  ]
}
*/

tryPrepare(dstChain, params) → Promise<{ ok: true; value: InteropPlan } | { ok: false; error }>

Result-style prepare.

create(dstChain, params) → Promise<InteropHandle<TransactionRequest>>

Prepares and executes all required source-chain steps. Waits for each step receipt before returning.

Returns: InteropHandle

const handle = await sdk.interop.create(l2Dst, {
  actions: [{ type: 'sendErc20', token: tokenSrcAddress, to: me, amount: 1_000_000n }],
});
/*
{
  kind: 'interop',
  l2SrcTxHash: Hex,
  stepHashes: Record<string, Hex>,
  plan: InteropPlan
}
*/

[!WARNING] If any step reverts, create() throws a typed error. Prefer tryCreate() to avoid exceptions.

tryCreate(dstChain, params) → Promise<{ ok: true; value: InteropHandle } | { ok: false; error }>

Result-style create.

status(dstChain, waitable, opts?) → Promise<InteropStatus>

Non-blocking lifecycle inspection. Returns the current phase. Accepts either an InteropHandle or a raw source L2 tx hash.

Phases

PhaseMeaning
SENTBundle sent on source chain
VERIFIEDBundle verified, ready for execution on destination
EXECUTEDAll actions executed on destination
UNBUNDLEDActions selectively executed or cancelled
FAILEDExecution reverted or invalid
UNKNOWNStatus cannot be determined
const st = await sdk.interop.status(l2Dst, handle);
// or: sdk.interop.status(l2Dst, handle.l2SrcTxHash)
// st.phase: 'SENT' | 'VERIFIED' | 'EXECUTED' | 'UNBUNDLED' | 'FAILED' | 'UNKNOWN'

wait(dstChain, waitable, opts?) → Promise<InteropFinalizationInfo>

Block until the bundle proof is available on the destination chain. Returns the InteropFinalizationInfo needed to call finalize().

  • opts.pollMs — polling interval in ms (default: 5000)
  • opts.timeoutMs — max wait time in ms (throws on timeout)
const finalizationInfo = await sdk.interop.wait(l2Dst, handle, {
  pollMs: 5_000,
  timeoutMs: 30 * 60_000,
});
// finalizationInfo.bundleHash — interop bundle hash
// finalizationInfo.proof     — Merkle proof for execution

tryWait(dstChain, waitable, opts?) → Promise<{ ok: true; value: InteropFinalizationInfo } | { ok: false; error }>

Result-style wait.

finalize(dstChain, h, opts?, txOverrides?) → Promise<InteropFinalizationResult>

Execute the bundle on the destination chain. Accepts either:

  • InteropFinalizationInfo (returned by wait()) — executes immediately
  • InteropHandle or raw tx hash — calls wait() internally first

Parameters

NameTypeRequiredDescription
dstChainChainRefDestination chain — URL string or PublicClient.
hInteropFinalizationInfo | InteropWaitableFinalization info or a waitable handle/hash.
optsLogsQueryOptionsOptions for log queries used to check bundle status.
txOverridesTxGasOverridesGas overrides for the executeBundle transaction on destination.
const result = await sdk.interop.finalize(l2Dst, finalizationInfo);
// { bundleHash: Hex, dstExecTxHash: Hex }

// Or pass a waitable — finalize() calls wait() internally:
// const result = await sdk.interop.finalize(l2Dst, handle);

To override gas on the destination executeBundle transaction:

await sdk.interop.finalize(l2Destination, finalizationInfo, undefined, {
  gasLimit: 5_000_000n,
  maxFeePerGas: 200_000_000n,
});

[!INFO] finalize() sends a transaction on the destination L2, not on L1. Use txOverrides if the destination chain requires a manual gas limit (e.g. when the interop handler calls a receiver contract that may consume significant gas).

tryFinalize(dstChain, h, opts?, txOverrides?) → Promise<{ ok: true; value: InteropFinalizationResult } | { ok: false; error }>

Result-style finalize. Accepts the same txOverrides parameter.

getInteropRoot(dstChain, rootChainId, batchNumber) → Promise<Hex>

Read the interop root stored on the destination chain for a given source chain and batch number. Useful for low-level inspection or building custom proof-verification flows.

Parameters

NameTypeDescription
dstChainChainRefDestination chain — URL string or PublicClient.
rootChainIdbigintChain ID of the source (root) chain.
batchNumberbigintBatch number on the source chain.

Returns: Promise<Hex> — the raw interop root hash, or zero bytes if not yet available.

// Fetch the interop root for a given source chain ID and batch number
const root = await sdk.interop.getInteropRoot(
  l2Dst,
  /* rootChainId */ 300n,   // source chain ID
  /* batchNumber */ 42n,    // batch number on the source chain
);
console.log('Interop root:', root); // 0x...

verifyBundle(dstChain, h) → Promise<InteropFinalizationResult>

Submit a verifyBundle transaction on the destination chain. Unlike finalize(), this calls the handler's verify path, which records the bundle as verified without executing actions.

Accepts either:

  • InteropFinalizationInfo (returned by wait()) — submits immediately
  • InteropHandle or raw tx hash — calls wait() internally first

Returns: InteropFinalizationResult

// Verify the bundle on the destination chain without executing actions.
// Accepts an InteropHandle, InteropFinalizationInfo, or raw tx hash.
const result = await sdk.interop.verifyBundle(l2Dst, handle);
// { bundleHash: Hex, dstExecTxHash: Hex }
console.log('Bundle verified on destination:', result.dstExecTxHash);

[!INFO] verifyBundle() is a power-user method. Most integrations should use finalize() instead. Use this when you need to separate the verification and execution steps.


End-to-End Examples

ERC-20 Transfer

const handle = await sdk.interop.create(l2Dst, {
  actions: [
    {
      type: 'sendErc20',
      token: tokenSrcAddress,
      to: me,
      amount: 1_000_000n,
    },
  ],
  unbundling: { by: me },
});

const finalizationInfo = await sdk.interop.wait(l2Dst, handle, {
  pollMs: 5_000,
  timeoutMs: 30 * 60_000,
});

const result = await sdk.interop.finalize(l2Dst, finalizationInfo);
console.log('ERC-20 transferred to destination:', result.dstExecTxHash);

Remote Contract Call

const handle = await sdk.interop.create(l2Dst, {
  actions: [
    {
      type: 'call',
      to: greeterAddress,
      data: calldata,
    },
  ],
});

const finalizationInfo = await sdk.interop.wait(l2Dst, handle, {
  pollMs: 5_000,
  timeoutMs: 30 * 60_000,
});

const result = await sdk.interop.finalize(l2Dst, finalizationInfo);
console.log('Remote call executed on destination:', result.dstExecTxHash);

Types (Overview)

Interop Params

type InteropRoute = 'direct' | 'indirect';

type InteropAction =
  | { type: 'sendErc20'; token: Address; to: Address; amount: bigint }
  | { type: 'call'; to: Address; data: Hex; value?: bigint };

interface InteropParams {
  actions: InteropAction[];
  execution?: { only: Address };
  unbundling?: { by: Address };
  fee?: { useFixed: boolean };
  txOverrides?: {
    nonce?: number;
    gasLimit?: bigint;
    maxFeePerGas?: bigint;
    maxPriorityFeePerGas?: bigint;
  };
}

Interop Quote

interface InteropFee {
  token: Address;
  amount: bigint;
}

interface ApprovalNeed {
  token: Address;
  spender: Address;
  amount: bigint;
}

interface InteropQuote {
  route: InteropRoute;
  approvalsNeeded: readonly ApprovalNeed[];
  totalActionValue: bigint;
  bridgedTokenTotal: bigint;
  interopFee: InteropFee;
  l2Fee?: bigint;
}

Interop Plan

interface PlanStep<Tx> {
  key: string;
  kind: string;
  description: string;
  tx: Tx;
}

interface InteropPlan<Tx> {
  route: InteropRoute;
  summary: InteropQuote;
  steps: Array<PlanStep<Tx>>;
}

Interop Handle

interface InteropHandle<Tx> {
  kind: 'interop';
  l2SrcTxHash: Hex;
  l1MsgHash?: Hex;
  bundleHash?: Hex;
  dstExecTxHash?: Hex;
  stepHashes: Record<string, Hex>;
  plan: InteropPlan<Tx>;
}

Interop Status

type InteropPhase =
  | 'SENT'       // bundle sent on source chain
  | 'VERIFIED'   // verified, ready for execution on destination
  | 'EXECUTED'   // all actions executed on destination
  | 'UNBUNDLED'  // actions selectively executed or cancelled
  | 'FAILED'     // execution reverted or invalid
  | 'UNKNOWN';   // status cannot be determined

interface InteropStatus {
  phase: InteropPhase;
  l2SrcTxHash?: Hex;
  l1MsgHash?: Hex;
  bundleHash?: Hex;
  dstExecTxHash?: Hex;
}

Interop Finalization

interface InteropMessageProof {
  chainId: bigint;
  l1BatchNumber: bigint;
  l2MessageIndex: bigint;
  message: {
    txNumberInBatch: number;
    sender: Address;
    data: Hex;
  };
  proof: Hex[];
}

interface InteropFinalizationInfo {
  l2SrcTxHash: Hex;
  bundleHash: Hex;
  dstChainId: bigint;
  proof: InteropMessageProof;
  encodedData: Hex;
}

interface InteropFinalizationResult {
  bundleHash: Hex;
  dstExecTxHash: Hex;
}

[!TIP] Prefer the try* variants to avoid exceptions and work with structured result objects.


Notes & Pitfalls

  • gwChain is required: Forgetting it causes a STATE error on the first interop call.
  • dstChain first: All interop methods take the destination chain as the first argument — unlike deposits/withdrawals.
  • Finalization is on destination: finalize() sends a transaction on the destination L2, not on L1. Use txOverrides to set a custom gas limit when the receiver contract consumes significant gas.
  • wait() can take minutes: It polls until the L2→L1 proof is generated and the interop root is available on destination. Use timeoutMs to bound long waits.
  • ERC-20 approvals: If approvalsNeeded is non-empty, create() automatically sends approval transactions first.
  • ERC-20 tokens must be migrated to Gateway: The SDK does not migrate tokens automatically. If the ERC-20 token has not been migrated to the Gateway chain, create() will throw an error. Migrate the token first before using it in an interop transfer.
  • Multiple actions: Actions are atomic — all succeed or the bundle fails. Use unbundling to allow partial execution.