How L2 to L1 messaging works
In this article, we will explore the workings of Layer 2 (L2) to Layer 1 (L1) messaging in ZKsync Era.
If you’re uncertain about why messaging is necessary in the first place, please refer to our user documentation.
For ease of understanding, here’s a quick visual guide. We will unpack each part in detail as we progress.
Part 1 - User Generates a Message
Consider the following contract. Its main function is to forward any received string to L1:
contract Messenger {
function sendMessage(string memory message) public returns (bytes32 messageHash) {
messageHash = L1_MESSENGER_CONTRACT.sendToL1(bytes(message));
}
}
From a developer’s standpoint, you only need to invoke the sendToL1
method, and your task is complete.
It’s worth noting, however, that transferring data to L1 typically incurs high costs. These costs are associated with the ‘pubdata cost’ that is charged for each byte in the message. As a workaround, many individuals choose to send the hash of the message instead of the full message, as this helps to conserve resources.
Part 2 - System Contract Execution
The previously mentioned sendToL1
method executes a call to the L1Messenger.sol
system contract
here. This system contract performs tasks such as computing the appropriate gas cost and hashes, and
then it broadcasts an Event carrying the complete message.
function sendToL1(bytes calldata _message) external override returns (bytes32 hash) {
// ...
SystemContractHelper.toL1(true, bytes32(uint256(uint160(msg.sender))), hash);
emit L1MessageSent(msg.sender, hash, _message);
}
As depicted in the leading image, this stage is where the message data splits. The full body of the message is emitted for retrieval in Part 5 by the StateKeeper, while the hash of the message proceeds to be added to the Virtual Machine (VM) - as it has to be included in the proof.
The method then sends the message’s hash to the SystemContractHelper
, which makes an internal call:
function toL1(
bool _isService,
bytes32 _key,
bytes32 _value
) internal {
// ...
address callAddr = TO_L1_CALL_ADDRESS;
assembly {
call(_isService, callAddr, _key, _value, 0xFFFF, 0, 0)
}
}
Following the TO_L1_CALL_ADDRESS
, we discover that it’s set to a placeholder value. So what exactly is occurring here?
Part 3 - Compiler Tricks and the EraVM
Our VM features special opcodes designed to manage operations that aren’t possible in the Ethereum Virtual Machine (EVM), such as publishing data to L1. But how can we make these features accessible to Solidity?
We could expand the language by introducing new Solidity opcodes, but that would require modifying the solc compiler, among other things. Hence, we’ve adopted a different strategy.
To access these unique eraVM opcodes, the Solidity code simply executes a call to a specific address (the full list can be seen here). This call is compiled by the solc frontend, and then on the compiler backend, we intercept it and replace it with the correct eraVM opcode call here.
#![allow(unused)] fn main() { match simulation_address { Some(compiler_common::ADDRESS_TO_L1) => { return crate::zkevm::general::to_l1(context, is_first, in_0, in_1); } } }
This method allows your message to reach the VM.
Part 4 - Inside the Virtual Machine
The zkEVM assembly translates these opcodes into LogOpcodes.
#![allow(unused)] fn main() { pub const ALL_CANONICAL_MODIFIERS: [&'static str; 5] = ["sread", "swrite", "event", "to_l1", "precompile"]; let variant = match idx { 0 => LogOpcode::StorageRead, 1 => LogOpcode::StorageWrite, 2 => LogOpcode::Event, 3 => LogOpcode::ToL1Message, 4 => LogOpcode::PrecompileCall, } }
Each opcode is then converted into the corresponding LogOpcode and written into the Log here, which is handled by the EventSink oracle.
Part 5 - The Role of the State Keeper
At this stage, the state keeper needs to collect all the messages generated by the VM execution and append them to the calldata it transmits to Ethereum.
This process is divided into two steps:
- Retrieval of the ‘full’ messages
- Extraction of all the message hashes.
Why are these steps kept separate?
To avoid overwhelming our circuits with the content of entire messages, we relay them through Events, sending only their hash to the VM. In this manner, the VM only adds to the proof the information that a message with a specific hash was sent.
Retrieving Full Message Contents
We go through all the Events generated during the run here and identify those coming from the
L1_MESSENGER_ADDRESS
that corresponds to the L1MessageSent
topic. These Events represent the ‘emit’ calls executed
in Part 2.
Retrieving Message Hashes
Message hashes are transmitted alongside the other l2_to_l1_logs
within the VmExecutionResult.
The StateKeeper collects them from the LogQueries that the VM creates (these log queries also contain information about storage writes, so we use the AUX_BYTE filter to determine which ones contain L1 messages. The entire list can be found here). The StateKeeper employs the VM’s EventSink to filter them out here.
Part 6 - Interaction with Ethereum (L1)
After the StateKeeper has collected all the required data, it invokes the CommitBlocks
method from the
Executor.sol contract.
Inside the processL2Blocks
method, we iterate through the list of L2 message hashes, ensuring that the appropriate
full text is present for each:
// show preimage for hashed message stored in log
if (logSender == L2_TO_L1_MESSENGER_SYSTEM_CONTRACT_ADDR) {
(bytes32 hashedMessage, ) = UnsafeBytes.readBytes32(emittedL2Logs, i + 56);
// check that the full message body matches the hash.
require(keccak256(l2Messages[currentMessage]) == hashedMessage, "k2");
Currently, the executor is deployed on Ethereum mainnet at [0x389a081BCf20e5803288183b929F08458F1d863D][mainnet_executor].
You can view an example of our contract execution from Part 1, carrying the message “My sample message”, in this Sepolia transaction: 0x18c2a113d18c53237a4056403047ff9fafbf772cb83ccd44bb5b607f8108a64c.
Part 7 - Verifying Message Inclusion
We’ve now arrived at the final stage — how L1 users and contracts can confirm a message’s presence in L1.
This is accomplished through the ProveL2MessageInclusion
function call in Mailbox.sol.
Users supply the proof (merkle path) and the message, and the contract verifies that the merkle path is accurate and matches the root hash.
bytes32 calculatedRootHash = Merkle.calculateRoot(_proof, _index, hashedLog);
bytes32 actualRootHash = s.l2LogsRootHashes[_blockNumber];
return actualRootHash == calculatedRootHash;
Summary
In this article, we’ve traveled through a vast array of topics: from a user contract dispatching a message to L1 by invoking a system contract, to this message’s hash making its way all the way to the VM via special opcodes. We’ve also explored how it’s ultimately included in the execution results (as part of QueryLogs), gathered by the State Keeper, and transmitted to L1 for final verification.