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.

  1. Create a new project folder
    mkdir hardhat-example
    cd hardhat-example
    
  1. Initialize a new Hardhat 3 project with Node Test Runner and Viem.
    npx hardhat --init
    
  2. Install the zksync-js npm package.
    npm install -D @matterlabs/zksync-js
    
  3. Configure hardhat.config.ts with 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

  1. Create a sender contract in contracts/InteropSendMessage.sol.
    touch contracts/InteropSendMessage.sol
    
  2. 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);
      }
    }
    
  3. Create a script in scripts/interop-send-message.ts.
    touch scripts/interop-send-message.ts
    
  4. 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);
    
  5. Run the script and save the transaction hash from the output.
    npx hardhat run scripts/interop-send-message.ts
    
  6. 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

  1. Create a script in scripts/interop-check-status.ts.
    touch scripts/interop-check-status.ts
    
  2. 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);
    
  3. Run the script to wait for the proof.
    npx hardhat run scripts/interop-check-status.ts
    

Checking if the interop root is updated

  1. Create a script in scripts/interop-check-interop-root.ts.
    touch scripts/interop-check-interop-root.ts
    
  2. 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 });
    }
    
  3. Run the script to wait for the interop root on chain 6566.
    npx hardhat run scripts/interop-check-interop-root.ts
    

Verifying a message

  1. Create a script in scripts/interop-verify-message.ts.
    touch scripts/interop-verify-message.ts
    
  2. 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);
    
  3. Run the script to verify the message directly on chain 6566.
    npx hardhat run scripts/interop-verify-message.ts
    

Verifying onchain

  1. Create a wrapper contract in contracts/InteropVerification.sol.
    touch contracts/InteropVerification.sol
    
  2. 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
        );
      }
    }
    
  3. Create a script in scripts/interop-get-verification-args-local.ts.
    touch scripts/interop-get-verification-args-local.ts
    
  4. 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);
    
  5. Run the script to inspect the verification arguments.
    npx hardhat run scripts/interop-get-verification-args-local.ts
    
  6. Create a script in scripts/interop-test-onchain-verification.ts.
    touch scripts/interop-test-onchain-verification.ts
    
  7. 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);
    
  8. Run the script to verify the message through your wrapper contract.
    npx hardhat run scripts/interop-test-onchain-verification.ts
    
  1. Create a new project folder
    mkdir hardhat-example
    cd hardhat-example
    
  1. Initialize a new Hardhat 3 project with Mocha and Ethers.js.
    npx hardhat --init
    
  2. Install the zksync-js npm package.
    npm install -D @matterlabs/zksync-js
    
  3. Configure hardhat.config.ts with 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

  1. Create a sender contract in contracts/InteropSendMessage.sol.
    touch contracts/InteropSendMessage.sol
    
  2. 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);
      }
    }
    
  3. Create a script in scripts/interop-send-message.ts.
    touch scripts/interop-send-message.ts
    
  4. Copy and paste the script below into scripts/interop-send-message.ts.
    import { toUtf8Bytes } from 'ethers';
    import { network } from 'hardhat';
    
    const chain1 = await network.create({
      network: 'localZKsyncOSChain1',
      chainType: 'generic',
    });
    const [chain1Signer] = await chain1.ethers.getSigners();
    
    const factory = await chain1.ethers.getContractFactory('InteropSendMessage', chain1Signer);
    const sender = await factory.deploy();
    await sender.waitForDeployment();
    
    const tx = await sender.sendMessage(toUtf8Bytes('Hello from chain 6565'));
    await tx.wait();
    
    console.log('Message sender contract:', await sender.getAddress());
    console.log('Source chain tx hash:', tx.hash);
    
  5. Run the script and save the transaction hash from the output.
    npx hardhat run scripts/interop-send-message.ts
    
  6. 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

  1. Create a script in scripts/interop-check-status.ts.
    touch scripts/interop-check-status.ts
    
  2. Copy and paste the script below into scripts/interop-check-status.ts.
    import { createClient } from '@matterlabs/zksync-js/ethers';
    import { findL1MessageSentLog, messengerLogIndex } from '@matterlabs/zksync-js/core';
    import { AbiCoder } from 'ethers';
    import { network } from 'hardhat';
    
    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 [l1Signer] = await l1.ethers.getSigners();
    
    const client = createClient({
      l1: l1Signer.provider,
      l2: chain1.ethers.provider,
      signer: l1Signer,
    });
    
    const abiCoder = AbiCoder.defaultAbiCoder();
    
    function sleep(ms: number) {
      return new Promise((resolve) => setTimeout(resolve, ms));
    }
    
    const txReceipt = await chain1.ethers.provider.getTransactionReceipt(txHash);
    if (!txReceipt) {
      throw new Error(`Transaction receipt not found for ${txHash}.`);
    }
    
    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] = abiCoder.decode(['bytes'], messageLog.data) as [`0x${string}`];
    const messageLogIndex = messengerLogIndex(receiptWithLogs);
    
    while (true) {
      const finalizedBlock = await chain1.ethers.provider.getBlock('finalized');
      if (finalizedBlock && 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);
    
  3. Run the script to wait for the proof.
    npx hardhat run scripts/interop-check-status.ts
    

Checking if the interop root is updated

  1. Create a script in scripts/interop-check-interop-root.ts.
    touch scripts/interop-check-interop-root.ts
    
  2. Copy and paste the script below into scripts/interop-check-interop-root.ts.
    import { createClient } from '@matterlabs/zksync-js/ethers';
    import { createEthersSdk } from '@matterlabs/zksync-js/ethers/sdk';
    import { messengerLogIndex } from '@matterlabs/zksync-js/core';
    import { JsonRpcProvider, ZeroHash } from 'ethers';
    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 gatewayProvider = new JsonRpcProvider(gatewayRpc);
    
    const [l1Signer] = await l1.ethers.getSigners();
    const [chain2Signer] = await chain2.ethers.getSigners();
    
    const client = createClient({
      l1: l1Signer.provider,
      l2: chain1.ethers.provider,
      signer: l1Signer,
    });
    const sdk = createEthersSdk(client, {
      interop: { gwChain: gatewayRpc },
    });
    
    function sleep(ms: number) {
      return new Promise((resolve) => setTimeout(resolve, ms));
    }
    
    const txReceipt = await chain1.ethers.provider.getTransactionReceipt(txHash);
    if (!txReceipt) {
      throw new Error(`Transaction receipt not found for ${txHash}.`);
    }
    
    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 chain1.ethers.provider.getBlock('finalized');
      if (finalizedBlock && 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 { chainId: gatewayChainId } = await gatewayProvider.getNetwork();
    
    while (true) {
      const root = await sdk.interop.getInteropRoot(chain2.ethers.provider, gatewayChainId, proof.gatewayBlockNumber);
      if (root !== ZeroHash) {
        console.log('Interop root is updated on chain 6566:', root);
        break;
      }
    
      const pokeTx = await chain2Signer.sendTransaction({
        to: await chain2Signer.getAddress(),
        value: 1n,
      });
      await pokeTx.wait();
    }
    
  3. Run the script to wait for the interop root on chain 6566.
    npx hardhat run scripts/interop-check-interop-root.ts
    

Verifying a message

  1. Create a script in scripts/interop-verify-message.ts.
    touch scripts/interop-verify-message.ts
    
  2. Copy and paste the script below into scripts/interop-verify-message.ts.
    import { createClient } from '@matterlabs/zksync-js/ethers';
    import { createEthersSdk } from '@matterlabs/zksync-js/ethers/sdk';
    import { findL1MessageSentLog, messengerLogIndex } from '@matterlabs/zksync-js/core';
    import { AbiCoder, JsonRpcProvider, ZeroHash } from 'ethers';
    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 gatewayProvider = new JsonRpcProvider(gatewayRpc);
    
    const [l1Signer] = await l1.ethers.getSigners();
    const [chain2Signer] = await chain2.ethers.getSigners();
    
    const chain1Client = createClient({
      l1: l1Signer.provider,
      l2: chain1.ethers.provider,
      signer: l1Signer,
    });
    const chain2Client = createClient({
      l1: l1Signer.provider,
      l2: chain2.ethers.provider,
      signer: l1Signer,
    });
    const sdk = createEthersSdk(chain1Client, {
      interop: { gwChain: gatewayRpc },
    });
    
    const abiCoder = AbiCoder.defaultAbiCoder();
    
    function sleep(ms: number) {
      return new Promise((resolve) => setTimeout(resolve, ms));
    }
    
    const txReceipt = await chain1.ethers.provider.getTransactionReceipt(txHash);
    if (!txReceipt) {
      throw new Error(`Transaction receipt not found for ${txHash}.`);
    }
    
    const receiptWithLogs = await chain1Client.zks.getReceiptWithL2ToL1(txHash as `0x${string}`);
    if (!receiptWithLogs) {
      throw new Error(`L2 -> L1 receipt not found for ${txHash}.`);
    }
    
    const messageLog = findL1MessageSentLog(txReceipt, { prefer: 'messenger' });
    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 [messageData] = abiCoder.decode(['bytes'], messageLog.data) as [`0x${string}`];
    const messageLogIndex = messengerLogIndex(receiptWithLogs);
    
    while (true) {
      const finalizedBlock = await chain1.ethers.provider.getBlock('finalized');
      if (finalizedBlock && 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 { chainId: gatewayChainId } = await gatewayProvider.getNetwork();
    while (true) {
      const root = await sdk.interop.getInteropRoot(chain2.ethers.provider, gatewayChainId, proof.gatewayBlockNumber);
      if (root !== ZeroHash) {
        break;
      }
    
      const pokeTx = await chain2Signer.sendTransaction({
        to: await chain2Signer.getAddress(),
        value: 1n,
      });
      await pokeTx.wait();
    }
    
    const sourceChain = await chain1.ethers.provider.getNetwork();
    const args = {
      srcChainId: sourceChain.chainId,
      l1BatchNumber: proof.batchNumber,
      l2MessageIndex: proof.id,
      msgData: {
        txNumberInBatch,
        sender: txReceipt.to,
        data: messageData,
      },
      gatewayProof: proof.proof,
    };
    const { l2MessageVerification } = await chain2Client.contracts();
    
    const verified = await l2MessageVerification.proveL2MessageInclusionShared(
      args.srcChainId,
      args.l1BatchNumber,
      args.l2MessageIndex,
      args.msgData,
      args.gatewayProof
    );
    
    console.log('Message is verified:', verified);
    
  3. Run the script to verify the message directly on chain 6566.
    npx hardhat run scripts/interop-verify-message.ts
    

Verifying onchain

  1. Create a wrapper contract in contracts/InteropVerification.sol.
    touch contracts/InteropVerification.sol
    
  2. 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
        );
      }
    }
    
  3. Create a script in scripts/interop-get-verification-args-local.ts.
    touch scripts/interop-get-verification-args-local.ts
    
  4. Copy and paste the script below into scripts/interop-get-verification-args-local.ts.
    import { createClient } from '@matterlabs/zksync-js/ethers';
    import { createEthersSdk } from '@matterlabs/zksync-js/ethers/sdk';
    import { findL1MessageSentLog, messengerLogIndex } from '@matterlabs/zksync-js/core';
    import { AbiCoder, JsonRpcProvider, ZeroHash } from 'ethers';
    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 gatewayProvider = new JsonRpcProvider(gatewayRpc);
    
    const [l1Signer] = await l1.ethers.getSigners();
    const [chain2Signer] = await chain2.ethers.getSigners();
    
    const client = createClient({
      l1: l1Signer.provider,
      l2: chain1.ethers.provider,
      signer: l1Signer,
    });
    const sdk = createEthersSdk(client, {
      interop: { gwChain: gatewayRpc },
    });
    
    const abiCoder = AbiCoder.defaultAbiCoder();
    
    function sleep(ms: number) {
      return new Promise((resolve) => setTimeout(resolve, ms));
    }
    
    const txReceipt = await chain1.ethers.provider.getTransactionReceipt(txHash);
    if (!txReceipt) {
      throw new Error(`Transaction receipt not found for ${txHash}.`);
    }
    
    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' });
    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 [messageData] = abiCoder.decode(['bytes'], messageLog.data) as [`0x${string}`];
    const messageLogIndex = messengerLogIndex(receiptWithLogs);
    
    while (true) {
      const finalizedBlock = await chain1.ethers.provider.getBlock('finalized');
      if (finalizedBlock && 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 { chainId: gatewayChainId } = await gatewayProvider.getNetwork();
    let interopRoot = ZeroHash;
    while (interopRoot === ZeroHash) {
      interopRoot = await sdk.interop.getInteropRoot(chain2.ethers.provider, gatewayChainId, proof.gatewayBlockNumber);
      if (interopRoot !== ZeroHash) {
        break;
      }
    
      const pokeTx = await chain2Signer.sendTransaction({
        to: await chain2Signer.getAddress(),
        value: 1n,
      });
      await pokeTx.wait();
    }
    
    const sourceChain = await chain1.ethers.provider.getNetwork();
    const args = {
      interopRoot,
      srcChainId: sourceChain.chainId,
      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);
    
  5. Run the script to inspect the verification arguments.
    npx hardhat run scripts/interop-get-verification-args-local.ts
    
  6. Create a script in scripts/interop-test-onchain-verification.ts.
    touch scripts/interop-test-onchain-verification.ts
    
  7. Copy and paste the script below into scripts/interop-test-onchain-verification.ts.
    import { createClient } from '@matterlabs/zksync-js/ethers';
    import { createEthersSdk } from '@matterlabs/zksync-js/ethers/sdk';
    import { findL1MessageSentLog, messengerLogIndex } from '@matterlabs/zksync-js/core';
    import { AbiCoder, JsonRpcProvider, ZeroHash } from 'ethers';
    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 gatewayProvider = new JsonRpcProvider(gatewayRpc);
    
    const [l1Signer] = await l1.ethers.getSigners();
    const [chain2Signer] = await chain2.ethers.getSigners();
    
    const client = createClient({
      l1: l1Signer.provider,
      l2: chain1.ethers.provider,
      signer: l1Signer,
    });
    const sdk = createEthersSdk(client, {
      interop: { gwChain: gatewayRpc },
    });
    
    const abiCoder = AbiCoder.defaultAbiCoder();
    
    function sleep(ms: number) {
      return new Promise((resolve) => setTimeout(resolve, ms));
    }
    
    const txReceipt = await chain1.ethers.provider.getTransactionReceipt(txHash);
    if (!txReceipt) {
      throw new Error(`Transaction receipt not found for ${txHash}.`);
    }
    
    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' });
    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 [messageData] = abiCoder.decode(['bytes'], messageLog.data) as [`0x${string}`];
    const messageLogIndex = messengerLogIndex(receiptWithLogs);
    
    while (true) {
      const finalizedBlock = await chain1.ethers.provider.getBlock('finalized');
      if (finalizedBlock && 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 { chainId: gatewayChainId } = await gatewayProvider.getNetwork();
    while (true) {
      const interopRoot = await sdk.interop.getInteropRoot(
        chain2.ethers.provider,
        gatewayChainId,
        proof.gatewayBlockNumber
      );
      if (interopRoot !== ZeroHash) {
        break;
      }
    
      const pokeTx = await chain2Signer.sendTransaction({
        to: await chain2Signer.getAddress(),
        value: 1n,
      });
      await pokeTx.wait();
    }
    
    const sourceChain = await chain1.ethers.provider.getNetwork();
    const args = {
      srcChainId: sourceChain.chainId,
      l1BatchNumber: proof.batchNumber,
      l2MessageIndex: proof.id,
      msgData: {
        txNumberInBatch,
        sender: txReceipt.to,
        data: messageData,
      },
      gatewayProof: proof.proof,
    };
    
    const factory = await chain2.ethers.getContractFactory('InteropVerification', chain2Signer);
    const verifier = await factory.deploy();
    await verifier.waitForDeployment();
    
    const result = await verifier.checkVerification(
      args.srcChainId,
      args.l1BatchNumber,
      args.l2MessageIndex,
      args.msgData,
      args.gatewayProof
    );
    
    console.log('Verifier contract address:', await verifier.getAddress());
    console.log('Onchain verification result:', result);
    
  8. Run the script to verify the message through your wrapper contract.
    npx hardhat run scripts/interop-test-onchain-verification.ts
    
  1. Create a new Foundry project.
    forge init interop-messages
    cd interop-messages
    

Sending a message

  1. Create src/InteropSendMessage.sol.
    touch src/InteropSendMessage.sol
    
  2. Copy and paste the contract below into src/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);
        }
    }
    
  3. Build the project.
    forge build
    
  4. Set a funded local private key.
    export LOCAL_PRIVATE_KEY="0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110"
    
  5. Deploy the sender contract on chain 6565. Save the deployed contract address from the command output.
    forge create src/InteropSendMessage.sol:InteropSendMessage \
      --rpc-url http://localhost:3050 \
      --private-key $LOCAL_PRIVATE_KEY \
      --broadcast
    
  6. Send a message through the deployed sender contract. Save the transaction hash from the output.
    cast send <SENDER_CONTRACT_ADDRESS> \
      "sendMessage(bytes)" \
      "$(cast --from-utf8 'Hello from chain 6565')" \
      --rpc-url http://localhost:3050 \
      --private-key $LOCAL_PRIVATE_KEY
    

Checking that the proof is ready

  1. Create a helper script to fetch the message proof and verification arguments.
    touch script/get-message-verification-args.sh
    
  2. Copy and paste the script below into script/get-message-verification-args.sh.
    #!/usr/bin/env bash
    set -euo pipefail
    
    if ! command -v jq >/dev/null 2>&1; then
      echo "error: jq is required" >&2
      exit 1
    fi
    
    if ! command -v cast >/dev/null 2>&1; then
      echo "error: cast is required" >&2
      exit 1
    fi
    
    if [[ $# -lt 1 || $# -gt 3 ]]; then
      echo "usage: $0 <message_tx_hash> [rpc_url] [source_chain_id]" >&2
      echo "example: $0 0xabc... http://localhost:3050 6565" >&2
      exit 1
    fi
    
    MESSAGE_TX_HASH="$1"
    RPC="${2:-http://localhost:3050}"
    SOURCE_CHAIN_ID="${3:-6565}"
    
    L1_MESSENGER="0x0000000000000000000000000000000000008008"
    TOPIC_L1_MESSAGE_SENT_NEW="0xd0c9bf6f81b25545624e7ad46931632d5ad2f3313355ab364d096f4967797c90"
    TOPIC_L1_MESSAGE_SENT_LEG="0x3a36e47291f4201faf137fab081d92295bce2d53be2c6ca68ba82c7faa9ce241"
    
    RECEIPT_JSON="$(mktemp)"
    PROOF_JSON="$(mktemp)"
    trap 'rm -f "$RECEIPT_JSON" "$PROOF_JSON"' EXIT
    
    curl -s -X POST "$RPC" -H 'content-type: application/json' \
      --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getTransactionReceipt\",\"params\":[\"$MESSAGE_TX_HASH\"]}" \
      > "$RECEIPT_JSON"
    
    echo "Fetched receipt for tx $MESSAGE_TX_HASH from $RPC" >&2
    
    MESSAGE_SENDER=$(jq -r '.result.to' "$RECEIPT_JSON")
    TX_INDEX_HEX=$(jq -r '.result.transactionIndex' "$RECEIPT_JSON")
    TX_NUMBER_IN_BATCH=$(cast --to-dec "$TX_INDEX_HEX")
    
    MESSENGER_LOG_INDEXES=()
    while IFS= read -r idx; do
      MESSENGER_LOG_INDEXES+=("$idx")
    done < <(
      jq -r \
        --arg a "$L1_MESSENGER" \
        --arg t1 "$TOPIC_L1_MESSAGE_SENT_NEW" \
        --arg t2 "$TOPIC_L1_MESSAGE_SENT_LEG" \
        '.result.logs|to_entries[]|select((.value.address|ascii_downcase)==($a|ascii_downcase) and ((.value.topics[0]|ascii_downcase)==($t1|ascii_downcase) or (.value.topics[0]|ascii_downcase)==($t2|ascii_downcase)))|.key' \
        "$RECEIPT_JSON"
    )
    
    if [[ "${#MESSENGER_LOG_INDEXES[@]}" -eq 0 ]]; then
      echo "error: no L1MessageSent log found in receipt" >&2
      exit 1
    fi
    
    CHOSEN_LOG_INDEX="${MESSENGER_LOG_INDEXES[0]}"
    L2_TO_L1_LOG_INDEX=0
    
    L1_MESSAGE_DATA_ENCODED=$(jq -r --argjson i "$CHOSEN_LOG_INDEX" '.result.logs[$i].data' "$RECEIPT_JSON")
    MESSAGE_DATA=$(cast decode-abi "x()(bytes)" "$L1_MESSAGE_DATA_ENCODED" | tr -d '()')
    
    curl -s -X POST "$RPC" -H 'content-type: application/json' \
      --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"zks_getL2ToL1LogProof\",\"params\":[\"$MESSAGE_TX_HASH\",$L2_TO_L1_LOG_INDEX,\"messageRoot\"]}" \
      > "$PROOF_JSON"
    
    L1_BATCH_NUMBER=$(jq -r '.result.batchNumber' "$PROOF_JSON")
    L2_MESSAGE_INDEX=$(jq -r '.result.id' "$PROOF_JSON")
    GATEWAY_BLOCK_NUMBER=$(jq -r '.result.gatewayBlockNumber' "$PROOF_JSON")
    MESSAGE_PROOF_ARRAY=$(jq -r '"[" + (.result.proof | join(",")) + "]"' "$PROOF_JSON")
    
    PROOF_ENCODED_HEX=$(cast abi-encode \
      "x((uint256,uint256,uint256,(uint16,address,bytes),bytes32[]))" \
      "(\
    $SOURCE_CHAIN_ID,\
    $L1_BATCH_NUMBER,\
    $L2_MESSAGE_INDEX,\
    ($TX_NUMBER_IN_BATCH,$MESSAGE_SENDER,$MESSAGE_DATA),\
    $MESSAGE_PROOF_ARRAY\
    )")
    
    echo "Verification args ready: batch=$L1_BATCH_NUMBER, msgIndex=$L2_MESSAGE_INDEX, gatewayBlock=$GATEWAY_BLOCK_NUMBER" >&2
    
    cat <<EOF
    export SRC_CHAIN_ID=$SOURCE_CHAIN_ID
    export L1_BATCH_NUMBER=$L1_BATCH_NUMBER
    export L2_MESSAGE_INDEX=$L2_MESSAGE_INDEX
    export TX_NUMBER_IN_BATCH=$TX_NUMBER_IN_BATCH
    export MESSAGE_SENDER=$MESSAGE_SENDER
    export MESSAGE_DATA=$MESSAGE_DATA
    export MESSAGE_PROOF_ARRAY='$MESSAGE_PROOF_ARRAY'
    export GATEWAY_BLOCK_NUMBER=$GATEWAY_BLOCK_NUMBER
    export PROOF_ENCODED_HEX=$PROOF_ENCODED_HEX
    EOF
    
  3. Make the helper script executable.
    chmod +x script/get-message-verification-args.sh
    
  4. Load the verification arguments into your shell.
    eval "$(./script/get-message-verification-args.sh <MESSAGE_TX_HASH>)"
    

Checking if the interop root is updated

  1. Create a helper script that waits for the destination chain to import the interop root.
    touch script/wait-for-message-root.sh
    
  2. Copy and paste the script below into script/wait-for-message-root.sh.
    #!/usr/bin/env bash
    set -euo pipefail
    
    if ! command -v cast >/dev/null 2>&1; then
      echo "error: cast is required" >&2
      exit 1
    fi
    
    if [[ $# -lt 1 || $# -gt 4 ]]; then
      echo "usage: $0 <gateway_block_number> [chain2_rpc_url] [gateway_chain_id] [private_key]" >&2
      echo "example: $0 42 http://localhost:3051 506 \$LOCAL_PRIVATE_KEY" >&2
      exit 1
    fi
    
    GATEWAY_BLOCK_NUMBER="$1"
    CHAIN2_RPC="${2:-http://localhost:3051}"
    GATEWAY_CHAIN_ID="${3:-506}"
    LOCAL_PRIVATE_KEY="${4:-0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110}"
    INTEROP_ROOT_STORAGE="0x0000000000000000000000000000000000010008"
    ZERO_ROOT="0x0000000000000000000000000000000000000000000000000000000000000000"
    
    ME=$(cast wallet address --private-key "$LOCAL_PRIVATE_KEY")
    echo "Waiting for interop root on chain2 rpc=$CHAIN2_RPC gatewayChainId=$GATEWAY_CHAIN_ID gatewayBlock=$GATEWAY_BLOCK_NUMBER" >&2
    
    while true; do
      INTEROP_ROOT=$(cast call "$INTEROP_ROOT_STORAGE" \
        "interopRoots(uint256,uint256)(bytes32)" \
        "$GATEWAY_CHAIN_ID" \
        "$GATEWAY_BLOCK_NUMBER" \
        --rpc-url "$CHAIN2_RPC")
    
      if [[ "$INTEROP_ROOT" != "$ZERO_ROOT" ]]; then
        echo "Interop root found: $INTEROP_ROOT" >&2
        echo "export INTEROP_ROOT=$INTEROP_ROOT"
        exit 0
      fi
    
      echo "Interop root not ready yet; sending a 1 wei tx to advance blocks and retrying in 5s..." >&2
      cast send "$ME" \
        --value 1 \
        --rpc-url "$CHAIN2_RPC" \
        --private-key "$LOCAL_PRIVATE_KEY" \
        >/dev/null
    
      sleep 5
    done
    
  3. Make the helper script executable.
    chmod +x script/wait-for-message-root.sh
    
  4. Wait for the root on chain 6566.
    eval "$(./script/wait-for-message-root.sh $GATEWAY_BLOCK_NUMBER)"
    

Verifying a message

  1. Verify the message directly against the system contract on chain 6566.
    cast call 0x0000000000000000000000000000000000010009 \
      "proveL2MessageInclusionShared(uint256,uint256,uint256,(uint16,address,bytes),bytes32[])(bool)" \
      $SRC_CHAIN_ID \
      $L1_BATCH_NUMBER \
      $L2_MESSAGE_INDEX \
      "($TX_NUMBER_IN_BATCH,$MESSAGE_SENDER,$MESSAGE_DATA)" \
      "$MESSAGE_PROOF_ARRAY" \
      --rpc-url http://localhost:3051
    

Verifying onchain

  1. Create src/InteropVerification.sol.
    touch src/InteropVerification.sol
    
  2. Copy and paste the contract below into src/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
            );
        }
    }
    
  3. Deploy the wrapper contract on chain 6566. Save the deployed contract address from the command output.
    forge create src/InteropVerification.sol:InteropVerification \
      --rpc-url http://localhost:3051 \
      --private-key $LOCAL_PRIVATE_KEY \
      --broadcast
    
  4. Call the wrapper contract with the same verification arguments.
    cast call <VERIFIER_CONTRACT_ADDRESS> \
      "checkVerification(uint256,uint256,uint256,(uint16,address,bytes),bytes32[])(bool)" \
      $SRC_CHAIN_ID \
      $L1_BATCH_NUMBER \
      $L2_MESSAGE_INDEX \
      "($TX_NUMBER_IN_BATCH,$MESSAGE_SENDER,$MESSAGE_DATA)" \
      "$MESSAGE_PROOF_ARRAY" \
      --rpc-url http://localhost:3051
    

Made with ❤️ by the ZKsync Community