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.
Note
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.
Structure
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
Tips
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.
Examples
zksync-web3
Sending a message from L2 to L1 using import { Wallet, Provider, Contract, utils } from "zksync-web3";
import { ethers } from "ethers";
const TEST_PRIVATE_KEY = "<YOUR_PRIVATE_KEY>";
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 {
main();
} catch (error) {
console.error(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 contract, 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 thegetBlock
method._index
is the index of the L2 log in the block. It's returned asid
by thegetMessageProof
method of thezksync-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 astransactionIndex
usinggetTransaction
_proof
is a parameter that contains the Merkle proof of the message inclusion. It can be retrieved either from observing Ethereum or received from thegetMessageProof
method of thezksync-web3
API.
Important
Note that the L2 block of your transaction must be verified (and hence the transaction finalized) before proving the inclusion in L1.
Example
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
require(!isL2ToL1MessageProcessed[_l2BlockNumber][_index]);
IZkSync zksync = IZkSync(_zkSyncAddress);
address someSender = 0x19A5bFCBE15f98Aa073B9F81b58466521479DF8D;
L2Message memory message = L2Message({sender: someSender, data: _message, txNumberInBlock:_l2TxNumberInBlock});
bool success = zksync.proveL2MessageInclusion(
_l2BlockNumber,
_index,
message,
_proof
);
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 TEST_PRIVATE_KEY = "<YOUR_PRIVATE_KEY>";
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(
l1BatchNumber,
proof,
// @ts-ignore
l1BatchTxIndex
);
console.log("Result is :>> ", result);
process.exit();
}
try {
main();
} catch (error) {
console.error(error);
}