Interop (viem)
A fast path to execute cross-chain actions between ZKsync L2 chains using the viem adapter.
Interop is a three-step process:
- Create the bundle on the source L2.
- Wait until the bundle proof is available on the destination.
- 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)
| Param | Required | Meaning |
|---|---|---|
actions | Yes | Ordered list of actions to execute on destination |
execution | No | Restrict execution to a specific address |
unbundling | No | Specify who can unbundle actions individually |
fee | No | { useFixed: true } to use fixed ZK fee instead of dynamic base-token fee |
txOverrides | No | Gas overrides for the source L2 transaction |
Action types
| Type | Fields | Effect on destination |
|---|---|---|
sendErc20 | token, to, amount | Transfer ERC-20 tokens to to |
call | to, 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 rawl2SrcTxHash) directly tofinalize(). It will callwait()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: Passinterop: { gwChain: GW_RPC }when creating the SDK.- Stuck at
SENT: The L2→L1 proof may not be generated yet;wait()polls automatically. FAILEDphase: Inspectstatus.dstExecTxHashfor 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.