Interop Messages

A guide for sending and verifying interop messages with ZKsync Connect.

ZKsync Connect enables sending and verifying messages across ZKsync chains via ZKsync Gateway. It is the first phase of universal interoperability for the Elastic Network.

An interop message consists of arbitrary data and has two simple properties:

  1. Anyone can send a message.
  2. Anyone can verify that a given message was successfully sent on some chain.

The message itself has no destination chain or address. It’s simply a payload created by a user or contract that gets broadcast. There is no expiration for when messages can be verified.

Messages can only be sent and verified on ZKsync chains that use ZKsync Gateway. To set up a local multichain testing environment with Gateway, check out the Running ZKsync Gateway locally guide. To see which testnet and mainnet chains use ZKsync Gateway, check the Elastic Network Chains table.

Before verifying a message, the transaction that the message was sent in must be executed in a batch, and the interop root must be updated on the target chain. The full flow for sending and verifying a message looks like this:

  1. Send the message.
  2. Check that the message transaction is fully executed.
  3. Check that the gateway proof is ready.
  4. Check that the interop root is updated on the target chain.
  5. Verify the message.

Sending a message

You can use the InteropClient along with the sendMessage method to send any message. The method accepts either a string or a BytesLike value, and returns the transaction hash.

import { Provider, Wallet, InteropClient } from 'zksync-ethers';

// private key for local pre-configured rich wallet
const PRIVATE_KEY = '0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110';
const CHAIN1_RPC = 'http://localhost:3050';
const GW_RPC = 'http://localhost:3150';
const L1_RPC = 'http://localhost:8545';
const GW_CHAIN_ID = BigInt('506');

const providerl2 = new Provider(CHAIN1_RPC);
const providerl1 = new Provider(L1_RPC);
const wallet = new Wallet(PRIVATE_KEY, providerl2, providerl1);

const interop = new InteropClient({
  gateway: {
    // 'testnet' | 'mainnet' | 'local'
    env: 'local',
    gwRpcUrl: GW_RPC,
    gwChainId: GW_CHAIN_ID,
  },
});

export async function send() {
  const message = 'Some L2->L1 message';
  const sent = await interop.sendMessage(wallet, message);
  console.log('Sent on source chain:', sent);
  // -> { txHash, l1BatchNumber, l1BatchTxIndex, l2ToL1LogIndex, sender, messageHex }
}

Sending a message from a smart contract

To send a message from a smart contract, you can use the sendToL1 method on the L1Messenger contract.

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.28;
import { IL1Messenger } from '@matterlabs/zksync-contracts/contracts/system-contracts/interfaces/IL1Messenger.sol';

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) public {
    L1Messenger.sendToL1(_message);
  }
}

Checking the execution status

To check the execution status of a message, you can use the getMessageStatus method. In order to verify a message, the status must first be EXECUTED.

import { Provider, InteropClient } from 'zksync-ethers';

// Testnet RPC URLS & Gateway chain ID
const CHAIN1_RPC = 'https://sepolia.era.zksync.dev'; // Era
const GW_RPC = 'https://rpc.era-gateway-testnet.zksync.dev'; // Gateway testnet
const GW_CHAIN_ID = BigInt('32657'); // Gateway testnet ID

// Provider for message source chain
const providerChain1 = new Provider(CHAIN1_RPC);

const interop = new InteropClient({
  gateway: {
    // 'testnet' | 'mainnet' | 'local'
    env: 'testnet',
    gwRpcUrl: GW_RPC,
    gwChainId: GW_CHAIN_ID,
  },
});

export async function checkStatus() {
  const txHash = '0xd2ed8c2141996e123a2dbe153beb84404775300f654ba633994e8d48d2cbad2d';
  const status = await interop.getMessageStatus(providerChain1, txHash);
  console.log('status', status);
  // -> "QUEUED" | "SENDING" | "PROVING" | "EXECUTED" | "FAILED" | "REJECTED" | "UNKNOWN"
}

Checking if the interop root is updated

In order to verify a message, the interop root must be updated on the target chain. To check if the interop root is ready, you can use the getGwBlockForBatch waitForGatewayInteropRoot methods.

import { ethers } from 'ethers';
import { Provider, waitForGatewayInteropRoot, getGwBlockForBatch } from 'zksync-ethers';

// Testnet RPC URLS & Gateway chain ID
const CHAIN1_RPC = 'https://sepolia.era.zksync.dev'; // Era
const CHAIN2_RPC = 'https://api.testnet.abs.xyz'; // Abstract
const GW_RPC = 'https://rpc.era-gateway-testnet.zksync.dev'; // Gateway testnet
const GW_CHAIN_ID = BigInt('32657'); // Gateway testnet ID

// Provider for message source chain
const providerChain1 = new Provider(CHAIN1_RPC);
// Provider for chain to verify on
const providerChain2 = new Provider(CHAIN2_RPC);

export async function checkInteropRoot() {
  const txHash = '0xd2ed8c2141996e123a2dbe153beb84404775300f654ba633994e8d48d2cbad2d';
  const receipt = await (await providerChain1.getTransaction(txHash)).waitFinalize();
  const gw = new ethers.JsonRpcProvider(GW_RPC);
  const gwBlock = await getGwBlockForBatch(BigInt(receipt.l1BatchNumber!), providerChain1, gw);
  const root = await waitForGatewayInteropRoot(GW_CHAIN_ID, providerChain2, gwBlock);
  console.log('interop root is updated', root);
}

Verifying a Message

You can either verify a message using the SDK or onchain inside a smart contract.

Offchain Verification

You can verify a message directly using the SDK by calling the verifyMessage method on the InteropClient.

import { Provider, InteropClient } from 'zksync-ethers';

// Testnet RPC URLS & Gateway chain ID
const CHAIN1_RPC = 'https://sepolia.era.zksync.dev'; // Era
const CHAIN2_RPC = 'https://api.testnet.abs.xyz'; // Abstract
const GW_RPC = 'https://rpc.era-gateway-testnet.zksync.dev'; // Gateway testnet
const GW_CHAIN_ID = BigInt('32657'); // Gateway testnet ID

// Provider for message source chain
const providerChain1 = new Provider(CHAIN1_RPC);
// Provider for chain to verify on
const providerChain2 = new Provider(CHAIN2_RPC);

const interop = new InteropClient({
  gateway: {
    // 'testnet' | 'mainnet' | 'local'
    env: 'testnet',
    gwRpcUrl: GW_RPC,
    gwChainId: GW_CHAIN_ID,
  },
});

export async function verify() {
  const txHash = '0xd2ed8c2141996e123a2dbe153beb84404775300f654ba633994e8d48d2cbad2d';

  const verifyRes = await interop.verifyMessage({
    txHash,
    srcProvider: providerChain1, // source chain provider (to fetch proof + batch details)
    targetChain: providerChain2, // target chain provider (to read interop root + verify)
    // includeProofInputs: true, // optional debug info
  });
  console.log('Message is verified:', verifyRes.verified);
}

To verify a message on a local chain, you must also send some transactions on the target chain in order to create a new batch so that the interop root gets updated. You can see an example of this below under "Local chains example".

Onchain Verification

To verify a message inside a smart contract, you can fetch the input args for proveL2MessageInclusionShared using the getVerificationArgs method.

Testnet example

import { Provider, InteropClient } from 'zksync-ethers';

// Testnet RPC URLS & Gateway chain ID
const CHAIN1_RPC = 'https://sepolia.era.zksync.dev'; // Era
const CHAIN2_RPC = 'https://api.testnet.abs.xyz'; // Abstract
const GW_RPC = 'https://rpc.era-gateway-testnet.zksync.dev'; // Gateway testnet
const GW_CHAIN_ID = BigInt('32657'); // Gateway testnet ID

// Provider for message source chain
const providerChain1 = new Provider(CHAIN1_RPC);
// Provider for chain to verify on
const providerChain2 = new Provider(CHAIN2_RPC);

const interop = new InteropClient({
  gateway: {
    // 'testnet' | 'mainnet' | 'local'
    env: 'testnet',
    gwRpcUrl: GW_RPC,
    gwChainId: GW_CHAIN_ID,
  },
});

// get args to pass into a contract for onchain verification
export async function getVerificationArgs() {
  const txHash = '0xd2ed8c2141996e123a2dbe153beb84404775300f654ba633994e8d48d2cbad2d';

  const args = await interop.getVerificationArgs({
    txHash,
    srcProvider: providerChain1, // source chain provider (to fetch proof + batch details)
    targetChain: providerChain2, // target chain provider (to read interop root + verify)
  });
  console.log('Verification Args:', args);
  // --> { srcChainId, l1BatchNumber, l2MessageIndex, msgData: { txNumberInBatch, sender, data }, gatewayProof }
}

Local chains example

To verify a message on a local chain, you must also send some transactions on the target chain in order to create a new batch so that the interop root gets updated. You can see an example of this below:

import { ethers } from 'ethers';
import { Provider, Wallet, InteropClient, getGwBlockForBatch, Contract, utils } from 'zksync-ethers';

// private key for local pre-configured rich wallet
const PRIVATE_KEY = '0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110';

const CHAIN1_RPC = 'http://localhost:3050';
const CHAIN2_RPC = 'http://localhost:3250';
const GW_RPC = 'http://localhost:3150'; // gateway
const L1_RPC = 'http://localhost:8545';
const GW_CHAIN_ID = BigInt('506');

// Chain 1
const providerChain1 = new Provider(CHAIN1_RPC);
const providerl1 = new Provider(L1_RPC);
const walletChain1 = new Wallet(PRIVATE_KEY, providerChain1, providerl1);

// Chain 2
const providerChain2 = new Provider(CHAIN2_RPC);
const walletChain2 = new Wallet(PRIVATE_KEY, providerChain2, providerl1);

const interop = new InteropClient({
  gateway: {
    // 'testnet' | 'mainnet' | 'local'
    env: 'local',
    gwRpcUrl: GW_RPC,
    gwChainId: GW_CHAIN_ID,
  },
});

// get args to pass into a contract for onchain verification
export async function getVerificationArgs() {
  const txHash = '0x...';

  // for local testing only
  // needed to force interop root to update on local chain 2
  const root = await updateLocalChainInteropRoot(txHash);
  console.log('interop root is updated', root);

  const args = await interop.getVerificationArgs({
    txHash,
    srcProvider: providerChain1, // source chain provider (to fetch proof + batch details)
    targetChain: providerChain2, // target chain provider (to read interop root + verify)
  });
  console.log('Verification Args:', args);
  // --> { srcChainId, l1BatchNumber, l2MessageIndex, msgData: { txNumberInBatch, sender, data }, gatewayProof }
  return args;
}

// force interop root to update on local chain 2
async function updateLocalChainInteropRoot(txHash: `0x${string}`, timeoutMs = 120_000): Promise<string> {
  const receipt = await (await walletChain1.provider.getTransaction(txHash)).waitFinalize();
  const gw = new ethers.JsonRpcProvider(GW_RPC);
  const gwBlock = await getGwBlockForBatch(BigInt(receipt.l1BatchNumber!), providerChain1, gw);

  // fetch the interop root from target chain
  const InteropRootStorage = new Contract(
    utils.L2_INTEROP_ROOT_STORAGE_ADDRESS,
    utils.L2_INTEROP_ROOT_STORAGE_ABI,
    walletChain2
  );

  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const root: string = await InteropRootStorage.interopRoots(GW_CHAIN_ID, gwBlock);
    if (root && root !== '0x' + '0'.repeat(64)) return root;
    // send tx just to get chain2 to seal batch
    const t = await walletChain2.sendTransaction({
      to: walletChain2.address,
      value: BigInt(1),
    });
    await (await walletChain2.provider.getTransaction(t.hash)).waitFinalize();
  }
  throw new Error(`Chain2 did not import interop root for (${GW_CHAIN_ID}, ${gwBlock}) in time`);
}

Verifying onchain

Once you have the input args, you can pass them into a contract function as shown in the example below:

Onchain verification script

import { getVerificationArgs } from './get-verification-args-local';
import { Contract, Provider, Wallet } from 'zksync-ethers';
import ABI_JSON from '../../artifacts-zk/contracts/InteropVerification.sol/InteropVerification.json';

// private key for local pre-configured rich wallet
const PRIVATE_KEY = '0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110';
const CHAIN_RPC = 'http://localhost:3050';
const L1_RPC = 'http://localhost:8545';

const providerl2 = new Provider(CHAIN_RPC);
const providerl1 = new Provider(L1_RPC);
const wallet = new Wallet(PRIVATE_KEY, providerl2, providerl1);

const CONTRACT_ADDRESS = '0x...';

export async function testOnchainVerification() {
  const args = await getVerificationArgs();
  const contract = new Contract(CONTRACT_ADDRESS, ABI_JSON.abi, wallet);
  const response = await contract.checkVerification(
    args.srcChainId,
    args.l1BatchNumber,
    args.l2MessageIndex,
    args.msgData,
    args.gatewayProof
  );
  console.log('message is verified:', response);
}

Smart contract verification

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.28;

import { IMessageVerification } from '@matterlabs/zksync-contracts/contracts/l1-contracts/state-transition/chain-interfaces/IMessageVerification.sol';
import { L2Message } from '@matterlabs/zksync-contracts/contracts/l1-contracts/common/Messaging.sol';

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,
    L2Message calldata _l2MessageData,
    bytes32[] calldata _proof
  ) public view returns (bool) {
    bool result = l2MessageVerifier.proveL2MessageInclusionShared(
      _sourceChainId,
      _l1BatchNumber,
      _l2MessageIndex,
      _l2MessageData,
      _proof
    );
    return result;
  }
}

Made with ❤️ by the ZKsync Community