L2 -> L1 communication

L2 -> L1 communication

This section describes the interface to interact with Ethereum from L2. It assumes that you are already familiar with the basic concepts of working with L2 -> L1 communication. If you are new to this topic, you can read the conceptual introduction here.


Please note that with system update released in Feb 2023, the ergs concept is only used by the VM and the SDK (version 0.13.0 and above) whilest the API layer operates with gas.

Find more information in the changelog.


Unlike L1 -> L2 communication, it is impossible to directly initialize transactions from L2 to L1. However, you can send an arbitrary-length message from zkSync to Ethereum, and then handle the received message on an L1 smart contract. To send a message from the L2 side, you should call the sendToL1 method from the messenger system contract. It accepts only the bytes of the message that is sent to the zkSync smart contract on Ethereum.

From the L1 side, the zkSync smart contract provides the method proveL2MessageInclusion to prove that the message was sent to L1 and included in a zkSync block.

Sending a message from L2 to L1

Sending messages from the L2 side requires users to call the sendToL1 method from the Messenger system contract. This method accepts only the bytes of the message that is being sent to the zkSync smart contract on L1.

function sendToL1(bytes memory _message) external returns (bytes32 messageHash);
  • _message is a parameter that contains the raw bytes of the message


The message sender will be determined from context.

This function sends a message from L2 and returns the keccak256 hash of the message bytes. The message hash can be used later to get proof that the message was sent on L1. Its use is optional and is for convenience purposes only.

More information about Messenger can be found in the system contracts section.


Sending a message from L2 to L1 using zksync-web3

import { Wallet, Provider, Contract, utils } from "zksync-web3";
import { ethers } from "ethers";


async function main() {
  const zkSyncProvider = new Provider("https://zksync2-testnet.zksync.dev");

  const wallet = new Wallet(TEST_PRIVATE_KEY, zkSyncProvider);

  const messengerContract = new ethers.Contract(utils.L1_MESSENGER_ADDRESS, utils.L1_MESSENGER, wallet);

  console.log(`Messenger contract address is ${messengerContract.address}`);

  const someString = ethers.utils.toUtf8Bytes("Some L2->L1 message");
  console.log(`Sending message from L2 to L1`);
  const tx = await messengerContract.sendToL1(someString);

  console.log("L2 trx hash is ", tx.hash);
  const receipt = await tx.waitFinalize();

  console.log(`Transaction included in block ${receipt.blockNumber}`);

  // Get proof that the message was sent to L1
  const msgProof = await zkSyncProvider.getMessageProof(receipt.blockNumber, wallet.address, ethers.utils.keccak256(someString));

  console.log("Proof that message was sent to L1 :>> ", msgProof);

try {
} catch (error) {

Smart contract in L2 that sends a message to L1

The following contract sends its address to L1 via the Messenger system contract:

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

// Importing interfaces and addresses of the system contracts
import "@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol";

contract Example {
    function sendMessageToL1() external returns(bytes32 messageHash) {
        // Construct the message directly on the contract
        bytes memory message = abi.encode(address(this));

        messageHash = L1_MESSENGER_CONTRACT.sendToL1(message);

Proving the inclusion of the message into the L2 block

From the L1 side, the zkSync smart contract provides an interface to prove that the message was sent to L1 and included in a zkSync block.

The proveL2MessageInclusion function from the Mailbox L1 contractopen in new window, returns a boolean value that indicates that a message with such parameters, was sent to L1.

    struct L2Message {
        address sender;
        bytes data;
        uint256 txNumberInblock;

    function proveL2MessageInclusion(
        uint256 _blockNumber,
        uint256 _index,
        L2Message calldata _message,
        bytes32[] calldata _proof
    ) external view returns (bool);

Here is a detailed description of the required parameters:

  • _blockNumber is the L1 batch number in which the L2 block was included. It can be retrieved using the getBlock method.
  • _index is the index of the L2 log in the block. It's returned as id by the getMessageProof method of the zksync-web3 API.
  • _message is a parameter that contains the full information of the message sent. It should be an object containing:
    • sender: the address that sent the message from L2.
    • data: the message sent in bytes.
    • txNumberInBlock: the index of the transaction in the L2 block, which is returned as transactionIndex using getTransaction
  • _proof is a parameter that contains the Merkle proof of the message inclusion. It can be retrieved either from observing Ethereum or received from the getMessageProof method of the zksync-web3 API.


Note that the L2 block of your transaction must be verified (and hence the transaction finalized) before proving the inclusion in L1.


L1 message processing contract

The following contract receives the information of the transaction sent to the L2 messenger contract and proves that it was included in an L2 block.

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

// Importing zkSync contract interface
import "@matterlabs/zksync-contracts/l1/contracts/zksync/interfaces/IZkSync.sol";

contract Example {
  // NOTE: The zkSync contract implements only the functionality for proving that a message belongs to a block
  // but does not guarantee that such a proof was used only once. That's why a contract that uses L2 -> L1
  // communication must take care of the double handling of the message.
  /// @dev mapping L2 block number => message number => flag
  /// @dev Used to indicated that zkSync L2 -> L1 message was already processed
  mapping(uint256 => mapping(uint256 => bool)) isL2ToL1MessageProcessed;

  function consumeMessageFromL2(
    // The address of the zkSync smart contract.
    // It is not recommended to hardcode it during the alpha testnet as regenesis may happen.
    address _zkSyncAddress,
    // zkSync block number in which the message was sent
    uint256 _l2BlockNumber,
    // Message index, that can be received via API
    uint256 _index,
    // The tx number in block
    uint16 _l2TxNumberInBlock,
    // The message that was sent from l2
    bytes calldata _message,
    // Merkle proof for the message
    bytes32[] calldata _proof
  ) external {
    // check that the message has not been processed yet

    IZkSync zksync = IZkSync(_zkSyncAddress);
    address someSender = 0x19A5bFCBE15f98Aa073B9F81b58466521479DF8D;
    L2Message memory message = L2Message({sender: someSender, data: _message, txNumberInBlock:_l2TxNumberInBlock});

    bool success = zksync.proveL2MessageInclusion(
    require(success, "Failed to prove message inclusion");

    // Mark message as processed
    isL2ToL1MessageProcessed[_l2BlockNumber][_index] = true;

End to end

The following script sends a message from L2 to L1, retrieves the message proof, and validates that the message received in L1 came from an L2 block.

import * as ethers from "ethers";
import { Provider, utils, Wallet } from "zksync-web3";

const MESSAGE = "Some L2->L1 message";

const l2Provider = new Provider("https://zksync2-testnet.zksync.dev");
const l1Provider = ethers.getDefaultProvider("goerli");

const wallet = new Wallet(TEST_PRIVATE_KEY, l2Provider, l1Provider);

async function sendMessageToL1(text: string) {
  console.log(`Sending message to L1 with text ${text}`);
  const textBytes = ethers.utils.toUtf8Bytes(MESSAGE);

  const messengerContract = new ethers.Contract(utils.L1_MESSENGER_ADDRESS, utils.L1_MESSENGER, wallet);
  const tx = await messengerContract.sendToL1(textBytes);
  await tx.wait();
  console.log("L2 trx hash is ", tx.hash);
  return tx;

async function getL2MessageProof(blockNumber: ethers.BigNumberish) {
  console.log(`Getting L2 message proof for block ${blockNumber}`);
  return await l2Provider.getMessageProof(blockNumber, wallet.address, ethers.utils.keccak256(ethers.utils.toUtf8Bytes(MESSAGE)));

async function proveL2MessageInclusion(l1BatchNumber: ethers.BigNumberish, proof: any, trxIndex: number) {
  const zkAddress = await l2Provider.getMainContractAddress();

  const mailboxL1Contract = new ethers.Contract(zkAddress, utils.ZKSYNC_MAIN_ABI, l1Provider);
  // all the information of the message sent from L2
  const messageInfo = {
    txNumberInBlock: trxIndex,
    sender: wallet.address,
    data: ethers.utils.toUtf8Bytes(MESSAGE),

  console.log(`Retrieving proof for batch ${l1BatchNumber}, transaction index ${trxIndex} and proof id ${proof.id}`);

  const res = await mailboxL1Contract.proveL2MessageInclusion(l1BatchNumber, proof.id, messageInfo, proof.proof);

  return res;

 * Full end-to-end of an L2-L1 messaging with proof validation.
 * Recommended to run in 3 steps:
 * 1. Send message.
 * 2. Wait for transaction to finalize and block verified
 * 3. Wait for block to be verified and validate proof
async function main() {
  // Step 1: send message
  const l2Trx = await sendMessageToL1(MESSAGE);

  console.log("Waiting for transaction to finalize...");

  // Step 2: waiting to finalize can take a few minutes.
  const l2Receipt = await l2Trx.waitFinalize();

  // Step 3: get and validate proof (block must be verified)
  const proof = await getL2MessageProof(l2Receipt.blockNumber);

  console.log(`Proof is: `, proof);

  const { l1BatchNumber, l1BatchTxIndex } = await l2Provider.getTransactionReceipt(l2Receipt.transactionHash);

  console.log("L1 Index for Tx in block :>> ", l1BatchTxIndex);

  console.log("L1 Batch for block :>> ", l1BatchNumber);

  // IMPORTANT: This method requires that the block is verified
  // and sent to L1!
  const result = await proveL2MessageInclusion(
    // @ts-ignore

  console.log("Result is :>> ", result);

try {
} catch (error) {