Skip to content

Latest commit

 

History

History
212 lines (155 loc) · 9.57 KB

how_l2_messaging_works.md

File metadata and controls

212 lines (155 loc) · 9.57 KB

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.

overview image

Part 1 - User Generates a Message

Consider the following contract. Its main function is to forward any received string to L1:

contract MsgSender {
  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.

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.

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 needs to verify 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 correspond 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 in the Verification Timelock contract on Ethereum mainnet at 0x3db52ce065f728011ac6732222270b3f2360d919.

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 travelled 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.