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
gwChainoption is required for interop. It can be a RPC URL string or a livePublicClient. It is used to poll the gateway chain for interop root availability duringwait().
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 onok.
Method Reference
quote(dstChain, params) → Promise<InteropQuote>
Estimate the operation (route, approvals, fee). Does not send transactions.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
dstChain | ChainRef | ✅ | Destination chain — URL string or PublicClient. |
params.actions | InteropAction[] | ✅ | 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.txOverrides | TxOverrides | ❌ | Gas 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
approvalsNeededis 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. PrefertryCreate()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
| Phase | Meaning |
|---|---|
SENT | Bundle sent on source chain |
VERIFIED | Bundle 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 |
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 bywait()) — executes immediatelyInteropHandleor raw tx hash — callswait()internally first
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
dstChain | ChainRef | ✅ | Destination chain — URL string or PublicClient. |
h | InteropFinalizationInfo | InteropWaitable | ✅ | Finalization info or a waitable handle/hash. |
opts | LogsQueryOptions | ❌ | Options for log queries used to check bundle status. |
txOverrides | TxGasOverrides | ❌ | Gas 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. UsetxOverridesif 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
| Name | Type | Description |
|---|---|---|
dstChain | ChainRef | Destination chain — URL string or PublicClient. |
rootChainId | bigint | Chain ID of the source (root) chain. |
batchNumber | bigint | Batch 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 bywait()) — submits immediatelyInteropHandleor raw tx hash — callswait()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 usefinalize()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
gwChainis required: Forgetting it causes aSTATEerror on the first interop call.dstChainfirst: 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. UsetxOverridesto 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. UsetimeoutMsto bound long waits.- ERC-20 approvals: If
approvalsNeededis 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
unbundlingto allow partial execution.