Interop (viem)

A fast path to execute cross-chain actions between ZKsync L2 chains using the viem adapter.

Interop is a three-step process:

  1. Create the bundle on the source L2.
  2. Wait until the bundle proof is available on the destination.
  3. Finalize to execute the actions on the destination L2.

Prerequisites

  • A funded source L2 account (gas + action value + interop fee).
  • A funded destination L2 account for the finalization transaction.
  • RPC URLs: L1_RPC_URL, GW_RPC_URL, SRC_L2_RPC_URL, DST_L2_RPC_URL.
  • Installed: @matterlabs/zksync-js + viem.
  • SDK initialized with interop: { gwChain } (see Setup).

Setup

Interop requires the SDK to know the gateway chain RPC, used to poll for interop root availability.

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 L1_RPC = 'http://localhost:8545';   // e.g. https://sepolia.infura.io/v3/XXX
const GW_RPC = 'http://localhost:3052';   // gateway chain RPC
const SRC_L2_RPC = 'http://localhost:3050'; // source L2 RPC
const DST_L2_RPC = 'http://localhost:3051'; // destination L2 RPC
const PRIVATE_KEY = process.env.PRIVATE_KEY || '';
const TOKEN_SRC_ADDRESS = process.env.TOKEN_SRC_ADDRESS || ''; // ERC-20 token on source L2

Parameters (quick reference)

ParamRequiredMeaning
actionsYesOrdered list of actions to execute on destination
executionNoRestrict execution to a specific address
unbundlingNoSpecify who can unbundle actions individually
feeNo{ useFixed: true } to use fixed ZK fee instead of dynamic base-token fee
txOverridesNoGas overrides for the source L2 transaction

Action types

TypeFieldsEffect on destination
sendErc20token, to, amountTransfer ERC-20 tokens to to
callto, data, value?Execute arbitrary contract call

ERC-20 actions may require an L2 approve() on the source chain. quote() surfaces required approvals.

[!WARNING] The ERC-20 token must already be migrated to the Gateway chain before it can be used in an interop transfer. The SDK does not perform this migration automatically — if the token is not migrated, create() will throw an error.


Fast path (one-shot)

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 L1_RPC = 'http://localhost:8545';   // e.g. https://sepolia.infura.io/v3/XXX
const GW_RPC = 'http://localhost:3052';   // gateway chain RPC
const SRC_L2_RPC = 'http://localhost:3050'; // source L2 RPC
const DST_L2_RPC = 'http://localhost:3051'; // destination L2 RPC
const PRIVATE_KEY = process.env.PRIVATE_KEY || '';
const TOKEN_SRC_ADDRESS = process.env.TOKEN_SRC_ADDRESS || ''; // ERC-20 token on source L2

async function main() {
  if (!PRIVATE_KEY) throw new Error('Set PRIVATE_KEY in env');

  const account = privateKeyToAccount(PRIVATE_KEY as `0x${string}`);
  const me = account.address as `0x${string}`;

  const l1 = createPublicClient({ transport: http(L1_RPC) });
  const l2Src = createPublicClient({ transport: http(SRC_L2_RPC) });
  const l2Dst = createPublicClient({ transport: http(DST_L2_RPC) });

  const l1Wallet = createWalletClient<Transport, Chain, Account>({
    account,
    transport: http(L1_RPC),
  });

  const client = createViemClient({ l1, l2: l2Src, l1Wallet });
  const sdk = createViemSdk(client, {
    interop: { gwChain: GW_RPC },
  });

  const params = {
    actions: [
      {
        type: 'sendErc20' as const,
        token: TOKEN_SRC_ADDRESS as `0x${string}`,
        to: me,
        amount: 1_000_000n,
      },
    ],
  };

  // Send the interop bundle on source L2
  const handle = await sdk.interop.create(l2Dst, params);
  console.log('Source L2 tx:', handle.l2SrcTxHash);

  // Wait until the bundle proof is available on destination
  const finalizationInfo = await sdk.interop.wait(l2Dst, handle, {
    pollMs: 5_000,
    timeoutMs: 30 * 60_000,
  });

  // Execute the bundle on destination L2
  const result = await sdk.interop.finalize(l2Dst, finalizationInfo);
  console.log('Executed on destination:', result.dstExecTxHash);
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});
  • create() sends the interop bundle on source L2.
  • wait() blocks until the bundle proof is available on destination.
  • finalize() executes the bundle on destination L2.

Inspect & customize (quote → prepare → create)

1. Quote (no side-effects) Preview fees, approvals, and route before sending anything.

const quote = await sdk.interop.quote(l2Dst, params);
// {
//   route: 'direct' | 'indirect',
//   approvalsNeeded: [{ token, spender, amount }], // ERC-20 approval needed
//   totalActionValue: 0n,
//   bridgedTokenTotal: 1_000_000n,
//   interopFee: { token: '0x000...', amount: bigint },
//   l2Fee?: bigint
// }

2. Prepare (build txs, don't send) Get the transaction request objects for signing or custom gas management.

const plan = await sdk.interop.prepare(l2Dst, params);
// {
//   route: 'direct' | 'indirect',
//   summary: InteropQuote,
//   steps: [{ key, kind, description, tx }]
// }

3. Create (send) Executes all required source-chain steps and waits for receipts.

const handle = await sdk.interop.create(l2Dst, params);
// {
//   kind: 'interop',
//   l2SrcTxHash: Hex,
//   stepHashes: Record<string, Hex>,
//   plan: InteropPlan
// }

Track progress (status vs wait)

Non-blocking snapshot

const st = await sdk.interop.status(l2Dst, handle);
// st.phase: 'SENT' | 'VERIFIED' | 'EXECUTED' | 'UNBUNDLED' | 'FAILED' | 'UNKNOWN'

Block until ready for finalization

const finalizationInfo = await sdk.interop.wait(l2Dst, handle, {
  pollMs: 5_000,
  timeoutMs: 30 * 60_000,
});
// Returns InteropFinalizationInfo once bundle proof is available on destination

Finalization (required step)

const result = await sdk.interop.finalize(l2Dst, finalizationInfo);
// { bundleHash: Hex, dstExecTxHash: Hex }
console.log('Executed on destination:', result.dstExecTxHash);

[!INFO] You can also pass the handle (or raw l2SrcTxHash) directly to finalize(). It will call wait() internally before executing on destination.

Error handling patterns

Exceptions

try {
  const handle = await sdk.interop.create(l2Dst, params);
  const finalizationInfo = await sdk.interop.wait(l2Dst, handle);
  const result = await sdk.interop.finalize(l2Dst, finalizationInfo);
  console.log('dstExecTxHash:', result.dstExecTxHash);
} catch (e) {
  console.error('Interop failed:', e);
}

No-throw style

Every method has a try* variant (e.g. tryQuote, tryCreate, tryWait, tryFinalize). These never throw—so you don't need a try/catch. Instead they return:

  • { ok: true, value: ... } on success
  • { ok: false, error: ... } on failure

This is useful for UI flows or services where you want explicit control over errors.

const createResult = await sdk.interop.tryCreate(l2Dst, params);
if (!createResult.ok) {
  console.error('Create failed:', createResult.error);
  return;
}
const handle = createResult.value;

Troubleshooting

  • Interop is not configured: Pass interop: { gwChain: GW_RPC } when creating the SDK.
  • Stuck at SENT: The L2→L1 proof may not be generated yet; wait() polls automatically.
  • FAILED phase: Inspect status.dstExecTxHash for the destination revert; check the action calldata and value.
  • Finalize reverts: Ensure the destination L2 account has enough gas. The bundle may have already been executed — check status() first.

See also