Send an L2 to L1 message

Send an L2 to L1 message

It is impossible to send transactions directly from L2 to L1.

Instead, you can send arbitrary-length messages from zkSync Era to Ethereum, and then handle the received message on Ethereum with an L1 smart contract.

What is a message?

  • A message is like an event on Ethereum.

  • The difference is that a message publishes data on L1.

  • Solidity representationopen in new window: solidity struct L2Message { address sender; bytes data; uint256 txNumberInblock; }


  • Verification and confirmation is possible using Ethereum data.
  • However, zkSync Era has an efficient request proof function which does the same.

Common use cases

Along with zkSync Era's built-in censorship resistance that requires multi-layer interoperability, there are some common use cases that need L2 to L1 transaction functionality, such as:

  • Bridging funds from L2 to L1.
  • Layer 2 governance.


  1. Create a project folder and cd into it
mkdir message-l2
cd message-l2
  1. Run
yarn init add -y
  1. Run
yarn add -D @matterlabs/zksync-contracts
  1. Import the zkSync Era library or contract containing the required functionality.
yarn add zksync-ethers@5 ethers@5 typescript @types/node ts-node
  1. In the root folder add .env file with private key of wallet to use
  1. Create a file.ts file in the root directory with the next script:
// 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-ethers";

import dotenv from "dotenv";

const TEST_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY || "";

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

const l2Provider = new Provider("");
const l1Provider = ethers.getDefaultProvider("sepolia");

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 ${}`);

  const res = await mailboxL1Contract.proveL2MessageInclusion(l1BatchNumber,, 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) {
  1. Add the following lines to your package.json in the root folder:
"scripts": {
   "run-file": "ts-node file.ts"
  1. To run the script, execute:
yarn run-file

Example output

Sending message to L1 with text Some L2->L1 message
L2 trx hash is  0xb6816e16906788ea5867bf868693aa4e7a46b68ccd2091be345e286a984cb39b
Waiting for transaction to finalize...
Getting L2 message proof for block 5382192
Proof is:  {
  id: 14,
  proof: [
  root: '0xbc872eb80a7d5d35dd16283c1b1a768b1e1c36404000edaaa04868c7d6a5907c'
L1 Index for Tx in block :>>  32
L1 Batch for block :>>  77512
Retrieving proof for batch 77512, transaction index 32 and proof id 14
Result is :>>  true

Send a message

Two transactions are required:

  • An L2 transaction which sends a message of arbitrary length.
  • An L1 read; implemented by a getter function on an L1 smart contract.
  1. Get a Contract object that represents the L1Messenger contract.

  2. Transform the request into a raw bytes array.

  3. Use the sendToL1open in new window function from the IL1Messenger.solopen in new window interface, passing the message as a raw bytes array.

Each sent message emits an L1MessageSentopen in new window event.

event L1MessageSent(address indexed _sender, bytes32 indexed _hash, bytes _message);

function sendToL1(bytes memory _message) external returns (bytes32);

3.1 The return value from sendToL1 is the keccak256 hash of the message bytes.

Prove the result

The proveL2MessageInclusionopen in new window function returns a boolean parameter indicating whether the message was sent successfully to L1.

function proveL2MessageInclusion(
    uint256 _blockNumber,
    uint256 _index,
    L2Message memory _message,
    bytes32[] calldata _proof
) public view returns (bool) {
    return _proveL2LogInclusion(_blockNumber, _index, _L2MessageToLog(_message), _proof);

Parameter details

  • _blockNumber: L1 batch number in which the L2 block was included; retrievable using the getBlock method.
  • _index: Index of the L2 log in the block; returned as id by the zks_getL2ToL1LogProof method.
  • _message: Parameter holding the message data. It should be an object containing:
    • sender: Address that sent the message from L2.
    • data: Message sent in bytes.
    • txNumberInBlock: Index of the transaction in the L2 block; returned as transactionIndex with getTransactionReceiptopen in new window on an Ethers Provider object.
  • _proof: Merkle proof of the message inclusion; retrieved by observing Ethereum or using the zks_getL2ToL1LogProof method of the zksync web3 API.


// The Example contract below 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);