Interop Messages
A guide for sending and verifying interop messages with ZKsync Connect.
Use this guide to send a message on chain 6565,
wait for the gateway proof and interop root,
and verify that message on chain 6566.
These examples use the local zksync-os-server environment from the
local setup guide.
Choose between using Hardhat 3 + viem, Hardhat 3 + ethers, or Foundry.
- Create a new project folder
mkdir hardhat-example cd hardhat-example
- Initialize a new Hardhat 3 project with Node Test Runner and Viem.
npx hardhat --init - Install the
zksync-jsnpm package.npm install -D @matterlabs/zksync-js - Configure
hardhat.config.tswith the local chains from the local setup guide.ignition: { requiredConfirmations: 1, }, networks: { localZKsyncOSL1: { type: 'http', chainType: 'l1', url: 'http://localhost:8545', accounts: ['0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110'], }, localZKsyncOSChain1: { type: 'http', chainType: 'generic', url: 'http://localhost:3050', accounts: ['0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110'], }, localZKsyncOSChain2: { type: 'http', chainType: 'generic', url: 'http://localhost:3051', accounts: ['0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110'], }, },
Sending a message
- Create a sender contract in
contracts/InteropSendMessage.sol.touch contracts/InteropSendMessage.sol - Copy and paste the contract below into
contracts/InteropSendMessage.sol.//SPDX-License-Identifier: Unlicense pragma solidity ^0.8.28; interface IL1Messenger { function sendToL1(bytes calldata _message) external returns (bytes32); } contract InteropSendMessage { address constant L2_TO_L1_MESSENGER_SYSTEM_CONTRACT_ADDR = 0x0000000000000000000000000000000000008008; IL1Messenger public l1Messenger = IL1Messenger(L2_TO_L1_MESSENGER_SYSTEM_CONTRACT_ADDR); function sendMessage(bytes calldata _message) external { l1Messenger.sendToL1(_message); } } - Create a script in
scripts/interop-send-message.ts.touch scripts/interop-send-message.ts - Copy and paste the script below into
scripts/interop-send-message.ts.import { network } from 'hardhat'; import { stringToHex } from 'viem'; const chain1 = await network.create({ network: 'localZKsyncOSChain1', chainType: 'generic', }); const chain1PublicClient = await chain1.viem.getPublicClient(); const sender = await chain1.viem.deployContract('InteropSendMessage'); const txHash = await sender.write.sendMessage([stringToHex('Hello from chain 6565')]); await chain1PublicClient.waitForTransactionReceipt({ hash: txHash }); console.log('Message sender contract:', sender.address); console.log('Source chain tx hash:', txHash); - Run the script and save the transaction hash from the output.
npx hardhat run scripts/interop-send-message.ts - Export the transaction hash once so the rest of the scripts can read it from
process.env.MESSAGE_TX_HASH.export MESSAGE_TX_HASH="0x..."
Checking that the proof is ready
- Create a script in
scripts/interop-check-status.ts.touch scripts/interop-check-status.ts - Copy and paste the script below into
scripts/interop-check-status.ts.import { createClient } from '@matterlabs/zksync-js/viem'; import { findL1MessageSentLog, messengerLogIndex } from '@matterlabs/zksync-js/core'; import { network } from 'hardhat'; import { decodeAbiParameters } from 'viem'; const txHash = process.env.MESSAGE_TX_HASH || '0x...'; const chain1 = await network.create({ network: 'localZKsyncOSChain1', chainType: 'generic', }); const l1 = await network.create({ network: 'localZKsyncOSL1', chainType: 'l1', }); const l1PublicClient = await l1.viem.getPublicClient(); const chain1PublicClient = await chain1.viem.getPublicClient(); const [l1WalletClient] = await l1.viem.getWalletClients(); const client = createClient({ l1: l1PublicClient, l2: chain1PublicClient, l1Wallet: l1WalletClient, }); function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } const txReceipt = await chain1PublicClient.getTransactionReceipt({ hash: txHash as `0x${string}` }); const receiptWithLogs = await client.zks.getReceiptWithL2ToL1(txHash as `0x${string}`); if (!receiptWithLogs) { throw new Error(`L2 -> L1 receipt not found for ${txHash}.`); } const messageLog = findL1MessageSentLog(txReceipt, { prefer: 'messenger' }); const [messageData] = decodeAbiParameters([{ type: 'bytes' }], messageLog.data); const messageLogIndex = messengerLogIndex(receiptWithLogs); while (true) { const finalizedBlock = await chain1PublicClient.getBlock({ blockTag: 'finalized' }); if (finalizedBlock.number !== null && finalizedBlock.number >= txReceipt.blockNumber) { break; } await sleep(5_000); } let proof; while (true) { try { proof = await client.zks.getL2ToL1LogProof(txHash as `0x${string}`, messageLogIndex, 'messageRoot'); break; } catch { await sleep(5_000); } } console.log('Source transaction is finalized in batch:', proof.batchNumber); console.log('Message data:', messageData); console.log('Message proof is ready for gateway block:', proof.gatewayBlockNumber); console.log('Source chain batch number:', proof.batchNumber); console.log('L2 message index:', proof.id); - Run the script to wait for the proof.
npx hardhat run scripts/interop-check-status.ts
Checking if the interop root is updated
- Create a script in
scripts/interop-check-interop-root.ts.touch scripts/interop-check-interop-root.ts - Copy and paste the script below into
scripts/interop-check-interop-root.ts.import { createClient } from '@matterlabs/zksync-js/viem'; import { createViemSdk } from '@matterlabs/zksync-js/viem/sdk'; import { messengerLogIndex } from '@matterlabs/zksync-js/core'; import { createPublicClient, http, zeroHash } from 'viem'; import { network } from 'hardhat'; const txHash = process.env.MESSAGE_TX_HASH || '0x...'; const chain1 = await network.create({ network: 'localZKsyncOSChain1', chainType: 'generic', }); const chain2 = await network.create({ network: 'localZKsyncOSChain2', chainType: 'generic', }); const l1 = await network.create({ network: 'localZKsyncOSL1', chainType: 'l1', }); const gatewayRpc = 'http://localhost:3052'; const gatewayPublicClient = createPublicClient({ transport: http(gatewayRpc), }); const l1PublicClient = await l1.viem.getPublicClient(); const chain1PublicClient = await chain1.viem.getPublicClient(); const chain2PublicClient = await chain2.viem.getPublicClient(); const [l1WalletClient] = await l1.viem.getWalletClients(); const [chain2WalletClient] = await chain2.viem.getWalletClients(); const client = createClient({ l1: l1PublicClient, l2: chain1PublicClient, l1Wallet: l1WalletClient, }); const sdk = createViemSdk(client, { interop: { gwChain: gatewayRpc }, }); function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } const txReceipt = await chain1PublicClient.getTransactionReceipt({ hash: txHash as `0x${string}` }); const receiptWithLogs = await client.zks.getReceiptWithL2ToL1(txHash as `0x${string}`); if (!receiptWithLogs) { throw new Error(`L2 -> L1 receipt not found for ${txHash}.`); } const messageLogIndex = messengerLogIndex(receiptWithLogs); while (true) { const finalizedBlock = await chain1PublicClient.getBlock({ blockTag: 'finalized' }); if (finalizedBlock.number !== null && finalizedBlock.number >= txReceipt.blockNumber) { break; } await sleep(5_000); } let proof; while (true) { try { proof = await client.zks.getL2ToL1LogProof(txHash as `0x${string}`, messageLogIndex, 'messageRoot'); break; } catch { await sleep(5_000); } } if (proof.gatewayBlockNumber == null) { throw new Error(`Gateway block number is not available yet for ${txHash}.`); } const gatewayChainId = await gatewayPublicClient.getChainId(); while (true) { const root = await sdk.interop.getInteropRoot(chain2PublicClient, BigInt(gatewayChainId), proof.gatewayBlockNumber); if (root !== zeroHash) { console.log('Interop root is updated on chain 6566:', root); break; } const pokeTxHash = await chain2WalletClient.sendTransaction({ account: chain2WalletClient.account, to: chain2WalletClient.account.address, value: 1n, }); await chain2PublicClient.waitForTransactionReceipt({ hash: pokeTxHash }); } - Run the script to wait for the interop root on chain
6566.npx hardhat run scripts/interop-check-interop-root.ts
Verifying a message
- Create a script in
scripts/interop-verify-message.ts.touch scripts/interop-verify-message.ts - Copy and paste the script below into
scripts/interop-verify-message.ts.import { createClient } from '@matterlabs/zksync-js/viem'; import { createViemSdk } from '@matterlabs/zksync-js/viem/sdk'; import { findL1MessageSentLog, messengerLogIndex } from '@matterlabs/zksync-js/core'; import { createPublicClient, decodeAbiParameters, http, zeroHash, type Abi } from 'viem'; import { network } from 'hardhat'; const txHash = process.env.MESSAGE_TX_HASH || '0x...'; const chain1 = await network.create({ network: 'localZKsyncOSChain1', chainType: 'generic', }); const chain2 = await network.create({ network: 'localZKsyncOSChain2', chainType: 'generic', }); const l1 = await network.create({ network: 'localZKsyncOSL1', chainType: 'l1', }); const gatewayRpc = 'http://localhost:3052'; const gatewayPublicClient = createPublicClient({ transport: http(gatewayRpc), }); const l1PublicClient = await l1.viem.getPublicClient(); const chain1PublicClient = await chain1.viem.getPublicClient(); const chain2PublicClient = await chain2.viem.getPublicClient(); const [l1WalletClient] = await l1.viem.getWalletClients(); const [chain2WalletClient] = await chain2.viem.getWalletClients(); const chain1Client = createClient({ l1: l1PublicClient, l2: chain1PublicClient, l1Wallet: l1WalletClient, }); const chain2Client = createClient({ l1: l1PublicClient, l2: chain2PublicClient, l1Wallet: l1WalletClient, }); const sdk = createViemSdk(chain1Client, { interop: { gwChain: gatewayRpc }, }); const l2MessageVerificationAbi = [ { type: 'function', name: 'proveL2MessageInclusionShared', stateMutability: 'view', inputs: [ { name: '_chainId', type: 'uint256' }, { name: '_blockOrBatchNumber', type: 'uint256' }, { name: '_index', type: 'uint256' }, { name: '_message', type: 'tuple', components: [ { name: 'txNumberInBatch', type: 'uint16' }, { name: 'sender', type: 'address' }, { name: 'data', type: 'bytes' }, ], }, { name: '_proof', type: 'bytes32[]' }, ], outputs: [{ name: '', type: 'bool' }], }, ] as const satisfies Abi; function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } const txReceipt = await chain1PublicClient.getTransactionReceipt({ hash: txHash as `0x${string}` }); const receiptWithLogs = await chain1Client.zks.getReceiptWithL2ToL1(txHash as `0x${string}`); if (!receiptWithLogs) { throw new Error(`L2 -> L1 receipt not found for ${txHash}.`); } if (!txReceipt.to) { throw new Error(`Transaction target not found for ${txHash}.`); } const txNumberInBatch = Number(txReceipt.transactionIndex); if (!Number.isInteger(txNumberInBatch)) { throw new Error(`Transaction index is not available for ${txHash}.`); } const messageLog = findL1MessageSentLog(txReceipt, { prefer: 'messenger' }); const [messageData] = decodeAbiParameters([{ type: 'bytes' }], messageLog.data); const messageLogIndex = messengerLogIndex(receiptWithLogs); while (true) { const finalizedBlock = await chain1PublicClient.getBlock({ blockTag: 'finalized' }); if (finalizedBlock.number !== null && finalizedBlock.number >= txReceipt.blockNumber) { break; } await sleep(5_000); } let proof; while (true) { try { proof = await chain1Client.zks.getL2ToL1LogProof(txHash as `0x${string}`, messageLogIndex, 'messageRoot'); break; } catch { await sleep(5_000); } } if (proof.gatewayBlockNumber == null) { throw new Error(`Gateway block number is not available yet for ${txHash}.`); } const gatewayChainId = await gatewayPublicClient.getChainId(); while (true) { const root = await sdk.interop.getInteropRoot(chain2PublicClient, BigInt(gatewayChainId), proof.gatewayBlockNumber); if (root !== zeroHash) { break; } const pokeTxHash = await chain2WalletClient.sendTransaction({ account: chain2WalletClient.account, to: chain2WalletClient.account.address, value: 1n, }); await chain2PublicClient.waitForTransactionReceipt({ hash: pokeTxHash }); } const sourceChainId = await chain1PublicClient.getChainId(); const verified = await chain2PublicClient.readContract({ address: (await chain2Client.ensureAddresses()).l2MessageVerification, abi: l2MessageVerificationAbi, functionName: 'proveL2MessageInclusionShared', args: [ BigInt(sourceChainId), proof.batchNumber, proof.id, { txNumberInBatch, sender: txReceipt.to, data: messageData, }, proof.proof, ], }); console.log('Message is verified:', verified); - Run the script to verify the message directly on chain
6566.npx hardhat run scripts/interop-verify-message.ts
Verifying onchain
- Create a wrapper contract in
contracts/InteropVerification.sol.touch contracts/InteropVerification.sol - Copy and paste the contract below into
contracts/InteropVerification.sol.//SPDX-License-Identifier: Unlicense pragma solidity ^0.8.28; interface IMessageVerification { struct L2Message { uint16 txNumberInBatch; address sender; bytes data; } function proveL2MessageInclusionShared( uint256 _sourceChainId, uint256 _l1BatchNumber, uint256 _l2MessageIndex, L2Message calldata _l2MessageData, bytes32[] calldata _proof ) external view returns (bool); } contract InteropVerification { address constant L2_MESSAGE_VERIFICATION_ADDRESS = 0x0000000000000000000000000000000000010009; IMessageVerification public l2MessageVerifier = IMessageVerification(L2_MESSAGE_VERIFICATION_ADDRESS); function checkVerification( uint256 _sourceChainId, uint256 _l1BatchNumber, uint256 _l2MessageIndex, IMessageVerification.L2Message calldata _l2MessageData, bytes32[] calldata _proof ) external view returns (bool) { return l2MessageVerifier.proveL2MessageInclusionShared( _sourceChainId, _l1BatchNumber, _l2MessageIndex, _l2MessageData, _proof ); } } - Create a script in
scripts/interop-get-verification-args-local.ts.touch scripts/interop-get-verification-args-local.ts - Copy and paste the script below into
scripts/interop-get-verification-args-local.ts.import { createClient } from '@matterlabs/zksync-js/viem'; import { createViemSdk } from '@matterlabs/zksync-js/viem/sdk'; import { findL1MessageSentLog, messengerLogIndex } from '@matterlabs/zksync-js/core'; import { createPublicClient, decodeAbiParameters, http, zeroHash } from 'viem'; import { network } from 'hardhat'; const txHash = process.env.MESSAGE_TX_HASH || '0x...'; const chain1 = await network.create({ network: 'localZKsyncOSChain1', chainType: 'generic', }); const chain2 = await network.create({ network: 'localZKsyncOSChain2', chainType: 'generic', }); const l1 = await network.create({ network: 'localZKsyncOSL1', chainType: 'l1', }); const gatewayRpc = 'http://localhost:3052'; const gatewayPublicClient = createPublicClient({ transport: http(gatewayRpc), }); const l1PublicClient = await l1.viem.getPublicClient(); const chain1PublicClient = await chain1.viem.getPublicClient(); const chain2PublicClient = await chain2.viem.getPublicClient(); const [l1WalletClient] = await l1.viem.getWalletClients(); const [chain2WalletClient] = await chain2.viem.getWalletClients(); const client = createClient({ l1: l1PublicClient, l2: chain1PublicClient, l1Wallet: l1WalletClient, }); const sdk = createViemSdk(client, { interop: { gwChain: gatewayRpc }, }); function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } const txReceipt = await chain1PublicClient.getTransactionReceipt({ hash: txHash as `0x${string}` }); const receiptWithLogs = await client.zks.getReceiptWithL2ToL1(txHash as `0x${string}`); if (!receiptWithLogs) { throw new Error(`L2 -> L1 receipt not found for ${txHash}.`); } if (!txReceipt.to) { throw new Error(`Transaction target not found for ${txHash}.`); } const txNumberInBatch = Number(txReceipt.transactionIndex); if (!Number.isInteger(txNumberInBatch)) { throw new Error(`Transaction index is not available for ${txHash}.`); } const messageLog = findL1MessageSentLog(txReceipt, { prefer: 'messenger' }); const [messageData] = decodeAbiParameters([{ type: 'bytes' }], messageLog.data); const messageLogIndex = messengerLogIndex(receiptWithLogs); while (true) { const finalizedBlock = await chain1PublicClient.getBlock({ blockTag: 'finalized' }); if (finalizedBlock.number !== null && finalizedBlock.number >= txReceipt.blockNumber) { break; } await sleep(5_000); } let proof; while (true) { try { proof = await client.zks.getL2ToL1LogProof(txHash as `0x${string}`, messageLogIndex, 'messageRoot'); break; } catch { await sleep(5_000); } } if (proof.gatewayBlockNumber == null) { throw new Error(`Gateway block number is not available yet for ${txHash}.`); } const gatewayChainId = await gatewayPublicClient.getChainId(); let interopRoot = zeroHash; while (interopRoot === zeroHash) { interopRoot = await sdk.interop.getInteropRoot(chain2PublicClient, BigInt(gatewayChainId), proof.gatewayBlockNumber); if (interopRoot !== zeroHash) { break; } const pokeTxHash = await chain2WalletClient.sendTransaction({ account: chain2WalletClient.account, to: chain2WalletClient.account.address, value: 1n, }); await chain2PublicClient.waitForTransactionReceipt({ hash: pokeTxHash }); } const sourceChainId = await chain1PublicClient.getChainId(); const args = { interopRoot, srcChainId: BigInt(sourceChainId), l1BatchNumber: proof.batchNumber, l2MessageIndex: proof.id, msgData: { txNumberInBatch, sender: txReceipt.to, data: messageData, }, gatewayProof: proof.proof, }; console.log('Interop root:', args.interopRoot); console.log('Verification args:', args); - Run the script to inspect the verification arguments.
npx hardhat run scripts/interop-get-verification-args-local.ts - Create a script in
scripts/interop-test-onchain-verification.ts.touch scripts/interop-test-onchain-verification.ts - Copy and paste the script below into
scripts/interop-test-onchain-verification.ts.import { createClient } from '@matterlabs/zksync-js/viem'; import { createViemSdk } from '@matterlabs/zksync-js/viem/sdk'; import { findL1MessageSentLog, messengerLogIndex } from '@matterlabs/zksync-js/core'; import { createPublicClient, decodeAbiParameters, http, zeroHash } from 'viem'; import { network } from 'hardhat'; const txHash = process.env.MESSAGE_TX_HASH || '0x...'; const chain1 = await network.create({ network: 'localZKsyncOSChain1', chainType: 'generic', }); const chain2 = await network.create({ network: 'localZKsyncOSChain2', chainType: 'generic', }); const l1 = await network.create({ network: 'localZKsyncOSL1', chainType: 'l1', }); const gatewayRpc = 'http://localhost:3052'; const gatewayPublicClient = createPublicClient({ transport: http(gatewayRpc), }); const l1PublicClient = await l1.viem.getPublicClient(); const chain1PublicClient = await chain1.viem.getPublicClient(); const chain2PublicClient = await chain2.viem.getPublicClient(); const [l1WalletClient] = await l1.viem.getWalletClients(); const [chain2WalletClient] = await chain2.viem.getWalletClients(); const client = createClient({ l1: l1PublicClient, l2: chain1PublicClient, l1Wallet: l1WalletClient, }); const sdk = createViemSdk(client, { interop: { gwChain: gatewayRpc }, }); function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } const txReceipt = await chain1PublicClient.getTransactionReceipt({ hash: txHash as `0x${string}` }); const receiptWithLogs = await client.zks.getReceiptWithL2ToL1(txHash as `0x${string}`); if (!receiptWithLogs) { throw new Error(`L2 -> L1 receipt not found for ${txHash}.`); } if (!txReceipt.to) { throw new Error(`Transaction target not found for ${txHash}.`); } const txNumberInBatch = Number(txReceipt.transactionIndex); if (!Number.isInteger(txNumberInBatch)) { throw new Error(`Transaction index is not available for ${txHash}.`); } const messageLog = findL1MessageSentLog(txReceipt, { prefer: 'messenger' }); const [messageData] = decodeAbiParameters([{ type: 'bytes' }], messageLog.data); const messageLogIndex = messengerLogIndex(receiptWithLogs); while (true) { const finalizedBlock = await chain1PublicClient.getBlock({ blockTag: 'finalized' }); if (finalizedBlock.number !== null && finalizedBlock.number >= txReceipt.blockNumber) { break; } await sleep(5_000); } let proof; while (true) { try { proof = await client.zks.getL2ToL1LogProof(txHash as `0x${string}`, messageLogIndex, 'messageRoot'); break; } catch { await sleep(5_000); } } if (proof.gatewayBlockNumber == null) { throw new Error(`Gateway block number is not available yet for ${txHash}.`); } const gatewayChainId = await gatewayPublicClient.getChainId(); while (true) { const interopRoot = await sdk.interop.getInteropRoot( chain2PublicClient, BigInt(gatewayChainId), proof.gatewayBlockNumber ); if (interopRoot !== zeroHash) { break; } const pokeTxHash = await chain2WalletClient.sendTransaction({ account: chain2WalletClient.account, to: chain2WalletClient.account.address, value: 1n, }); await chain2PublicClient.waitForTransactionReceipt({ hash: pokeTxHash }); } const sourceChainId = await chain1PublicClient.getChainId(); const verifier = await chain2.viem.deployContract('InteropVerification'); const result = await verifier.read.checkVerification([ BigInt(sourceChainId), proof.batchNumber, proof.id, { txNumberInBatch, sender: txReceipt.to, data: messageData, }, proof.proof, ]); console.log('Verifier contract address:', verifier.address); console.log('Onchain verification result:', result); - Run the script to verify the message through your wrapper contract.
npx hardhat run scripts/interop-test-onchain-verification.ts