This blog post presents the entire workflow of a transaction executed on zkSync Era. zkSync Era is a Zk Rollup Layer 2 blockchain that executes transactions and proves its execution on the Ethereum blockchain using Zero-Knowledge proofs.
Special thanks to @vladbochok1 from zkSync team for his blog post review before release.
zkSync Era is a Layer 2 protocol aiming to scale Ethereum throughput using zero-knowledge cryptography. It is known as a Zk Rollup. This blockchain hopes to accelerate the adoption of blockchain technology by making decentralized applications more accessible and affordable at any scale. As of today, more than $400M have been bridged to zkSync Era.
In this blog post, we will walk through the entire workflow of a Layer 2 transaction. The different states of a transaction will be detailed, from its generation to its finalization. To illustrate the process, we will focus on a single example transaction.
Global workflow of a zkSync transaction
For readability, the following abbreviations will be used throughout this blog post:
Abbreviation | Terms | Short definition |
---|---|---|
EOA | Externally-Owned Account | An Ethereum account generated from a key pair, with no associated smart contract code. |
ETH, Ether | Ethereum's native coin | Currency used for fees and value transfers on Ethereum and its L2. |
EVM | Ethereum Virtual Machine | Abstract machine, formally defined in the Ethereum's yellow paper, executing all Ethereum transactions. |
L1 | Layer 1 blockchain | Blockchain used as the underlying main blockchain architecture. In this post, the L1 is Ethereum. |
L2 | Layer 2 blockchain | Blockchain used as the overlaying network, secured by an L1. In this post, the L2 is zkSync Era. |
Pubdata | Public data | Data stored on L1 from which the full L2 state can be reconstructed. |
ZKP | Zero-Knowledge Proof | Type of cryptographic proof used to prove a statement's correctness without revealing other informations. In the context of Zk Rollups, those ZKP simply enable a verifier to check whether a prover performed a computation correctly. Transactions remain public and their effects are stored on-chain, so they are easy to replay. |
Since its official launch in 2015, Ethereum established itself as the leading blockchain for smart contracts, notably due to its versatility, security, and high level of decentralization. However, these strong qualities came at the cost of a limited capacity.
This limit is so significant that a special concept was coined for it: the "blockchain trilemma", stating that it is very hard (if not impossible) to design blockchains that are simultaneously decentralized, secure, and scalable. In Ethereum's case, scalability was sacrificed, limiting the network to about 12 transactions per seconds. The blockchain has been at capacity for years now, leading to competition among its users for inclusion, which implies higher fees (as high as $100 per transaction during the most congested periods).
One of the proposed solutions are Layer 2 blockchains, taking advantage of Ethereum's security and decentralization to propose highly scalable blockchains.
Zk Rollups are a Layer 2 solution leveraging ZK technology to scale Ethereum throughput without compromising Ethereum security.
Creation of the transaction
Here is the scenario of the example transaction: the well-known Alice wants to transfer 1 ETH to the famous Bob. The transaction fees on the Ethereum blockchain are way too high for Alice, she would like to reduce those fees. She decides to use a Zk Rollup to transfer value while saving fees, so she uses zkSync Era.
First, as with every blockchain, users are responsible for creating their transactions. Alice generates hers according to the target blockchain format, and uses her key pair to sign it.
zkSync Era transactions are based on the Ethereum format. As such, Alice will create a transaction with the following fields:
Field name | Description | Alice's example transaction |
---|---|---|
from |
the address of the sender who will sign the transaction | Alice's address |
recipient |
the receiving address | Bob's address |
signature |
the proof that the transaction is authorized by the sender | Signature generated by Alice, using her key pair |
nonce |
a sequentially incrementing counter | Alice account nonce value |
value |
the amount of ETH to transfer from the sender to the recipient, in wei | \(10^{18}\), representing 1 ETH |
input data |
arbitrary data, mainly used for call to smart contracts | Empty field, as Bob's account is an EOA |
gasLimit |
maximum amount of gas units that the transaction should consume | Depends on the network state |
maxPriorityFeePerGas |
maximum price of the consumed gas used as a tip to the validator | Depends on the network state |
maxFeePerGas |
maximum fee per unit of gas for the transaction | Depends on the network state |
Note: The last 3 values are related to gas and depend on the network's state. If those values are set too low, the transaction will either fail or not be included by validators until the network fees get more affordable.
Once the transaction has been generated by Alice, she sends it to an L2 node.
In the context of zkSync Era, the L2 node is called an "operator".
The transaction is sent to the L2 operator by calling the eth_sendRawTransaction
endpoint.
zkSync Era supports the standard Ethereum JSON-RPC API,
and also provides its own L2-specific features.
zkSync Era then uses its own specific fields for transactions. The following fields are provided when querying for L2 transaction details to a zkSync node's JSON-RPC API:
Field name | Description | Alice's example transaction |
---|---|---|
is_l1_originated |
a boolean that indicates if the transaction comes from L1 or not | False : the transaction is from Alice's L2 account to Bob's L2 account |
status |
the current status of the transaction | Depends on the step of the transaction workflow |
fee |
the amount of fee charged for transaction execution and validation | Depends on the network's state |
initiator_address |
the initiator of the transaction (i.e. the account that will pay fees) | Alice's address |
received_at |
the timestamp when which the operator reveived the transaction | Depends on when Alice sent her transaction |
eth_commit_tx_hash |
the Ethereum transaction in which the transaction's data was sent to L1 | Depends on the transaction's batch state |
eth_prove_tx_hash |
the Ethereum transaction in which the transaction's data was validated on L1 | Depends on the transaction's batch state |
eth_execute_tx_hash |
the Ethereum transaction in which the transaction was executed on L1 state | Depends on the transaction's batch state |
Depending on the transaction type, other fields can be added to a transaction,
but these won't be considered here.
We will use the status
field throughout this blog post to follow the evolution of Alice's transaction.
In classic EVM, an account is represented as an address (a 160-bit identifier, conventionally represented in hexadecimal form).
0x29DF43F75149D0552475A6f9B2aC96E28796ed0b
is an example of a real address used on Ethereum.
There exist two types of accounts: Externally-Owned Accounts (EOA) and Smart Contracts.
On Ethereum, an EOA address is derived from a public key.
It corresponds to the last 20 bytes of the Keccak-256 hash of the secp256k1 public key.
It can be represented by the following formula: address = keccak256(secp256k1_pubkey)[12:]
.
zkSync Era uses the same EOA addresses.
This way, most wallets such as MetaMask directly support zkSync Era out of the box.
On Ethereum, a smart contract is an address at which a bytecode is deployed, meaning that the account's code size is non-zero. All non-zero code size accounts are considered smart contracts, and all zero code size accounts are considered EOA. A smart contract address is defined deterministically from the hash of the bytecode, the deployer address and other data such as a nonce (random or not) and a static identifier.
In the zkSync Era ecosystem, all accounts are defined as smart contracts. A default account code is attached to every address greater than \(2^{16}-1=\mathtt{0xFFFF}\) for which no bytecode is defined. This default account code is mainly used to validate and execute EOA's transactions. Addresses lower than \(2^{16}\) are used for system contracts. They are part of the kernel space and enable some advanced features such as sending messages to the L1 and managing L2 ETH balances. Some system contracts will be detailed in the next sections as they are used by Alice's transaction.
Users can deploy their own smart contracts on zkSync Era. The solution aims to provide compatibility with existing Solidity codebases. A zkEVM bytecode can be deployed on zkSync Era the same way EVM bytecode can be deployed on Ethereum, with some little differences. The way smart contract addresses are derived is also different, notably to avoid cross-chain exploits. The inner workings of zkSync Era's zkEVM is very different from the EVM, but the abstraction layer used and the dedicated toolchain allow most Solidity codebases to work out of the box. Moreover, the most used Solidity development tools are supported on zkSync Era (Hardhat, Foundry).
Once an operator receives Alice's transaction, it adds it to a mempool of transactions. The status of the transaction is set as pending. This status is set almost instantly after the operator receives the transaction.
Pending transaction
The operator slices the transaction mempool into blocks called "L2 blocks". An L2 block is a list of transactions with specific metadata. In the case of zkSync Era, these L2 blocks are only used on the L2 blockchain, they are not included in this form on the L1 Ethereum blockchain. The rationale behind L2 blocks is to provide a fast software validation for user experience, as wallets show validation to the user as soon as the transaction has been included into an L2 block. The current L2 block production average time is less than a second.
Once multiple L2 blocks are available, they are merged into an "L1 batch". An L1 batch contains all the transactions, from the first L2 block to the last one in the batch. An L1 batch is later submitted and proved on Ethereum, which allows spreading the submission gas cost across all batched transactions. This is the main mechanism reducing gas cost, and scaling Ethereum's throughput.
L2 blocks and L1 batch in zkSync Era
Once the operator has included the transaction into an L2 block, the status of the transaction is set to included. This is currently achieved less than a second after the operator received the transaction. This state is used by most wallets to confirm the execution of the L2 transaction to the user.
Included transaction
All the transactions in an L1 batch are executed in zkSync Era's specific virtual machine: a zkEVM called EraVM. The inner workings of this zkEVM are complex, so we will only focus on the components used by Alice's transaction.
The L1 batch is provided to the bootloader L2 contract.
This smart contract is a special one: it is the EraVM's entry point, and it is responsible for the execution of all the transactions in the provided L1 batch.
Its memory mapping is initialized before being executed. According to zkSync, this memory initialization is done for convenience and efficiency.
It is the only point of non-determinism: "the bootloader starts with its memory pre-filled with any data the operator wants".
The bootloader starts with a call to the SystemContext
system contract,
setting multiple context variables such as the L1 batch timestamp,
its index and the hash of the previous L1 batch.
Once variables are set, it executes the transactions.
For each transaction, the workflow is the following:
validateTransaction
function.NonceHolder
system contract is called to increment the account nonce.payForTransaction
function.executeTransaction
function.In the case of Alice's transaction, the execution of the transaction will transfer funds from Alice to Bob.
On Ethereum, this would have been done by calling Bob's address directly with value = 1_000_000_000_000_000_000
(i.e. 1 ETH) and input data = ""
.
But EraVM works differently.
To call an address with some value, the MsgValueSimulator
system contract is used.
This system contract will modify the Ether balance of Alice and Bob in the L2EthToken
system contract.
Once balances are up-to-date, it calls Bob's account while setting Alice account as the sender.
This is done using an EraVM-specific opcode called mimic_call
that allows impersonating other accounts, by changing the msg.sender
value of any transaction.
Fortunately, this dangerous opcode can only be used by system contracts located in kernel space.
It is not available to user smart contracts as it would be a critical vulnerability.
Execution of Alice's transaction
Execution of transactions in EraVM produce public data. This data is stored on the L1 (Ethereum) and permits to reconstruct the full state of zkSync Era. The zkSync team has developed its own new solution for managing public data which is part of the proof system introduced in the Boojum upgrade. This solution allows the compression of public data on L2 before storing them on the L1 blockchain. Public data compression is mainly done to optimize gas fees when storing data on Ethereum.
zkSync's pubdata is divided in 4 categories:
Back to Alice's transaction, the interesting category of pubdata is Storage writes, which are also called storage diffs.
More precisely, we know that the modified storage slots are Alice's balance and Bob's balance in the L2EthToken
system contract as Alice sends 1 ETH to Bob.
zkSync Era is designed as a "statediff-based" rollup.
As such, it publishes the state changes on the L1 in a way that ensures data availability.
Ethereum is known to have a 2-layer tree
that attaches storage slot data to an account address (first layer of the tree) and then to the storage slot number (second layer).
Unlike Ethereum, zkSync's tree is qualified as "flat". It is a 1-layer tree that stores the data to a slot's derived key.
The derived key is calculated by hashing the storage slot number and the account address of this slot: H(Slot number, Account)
.
Differences between zkSync Era and Ethereum in the storage slot data trees
In zkSync's pubdata solution, initial writes and repeated writes are handled differently.
The initial write will create the derived key and a sequential ID will be attached to this derived key.
The pair \(\left(\mathrm{derived key}, \mathrm{value}\right)\) will be published on the L1.
Once a sequential ID has been attached to the derived key,
it will be used for future writes named repeated writes.
So, for a repeated write which is a modification of an existing storage slot value, the sequential ID attached to the slot will be used.
This ID is called enumeration_index
and each ID is permanently assigned to one storage slot.
Like on Ethereum, reading empty storage slots on zkSync Era returns a zero value.
Once all the L1 batch pubdata has been compressed, it is committed to the L1Messenger
system contract.
This contract verifies that the pubdata is consistent and was compressed properly.
If data is valid, the compressed data is stored on zkSync's L1 smart contract using the EraVM-specific opcode to_l1
.
This step is called "L1 batch commitment" and it is performed by calling the commitBatches
function on the L1.
The hash of this L1 transaction will be reported by zkSync's nodes as the value of the eth_commit_tx_hash
field associated with the L2 transaction details (seen previously).
Once the L1 batch data is stored on L1, all the transactions of the batch keep the status included and the L1 batch is set to the status committed.
Boojum's pubdata of L1 batch are committed to Ethereum
The next step of Alice's transaction workflow is the verification of the transaction. A transaction is said to be "verified" when the L1 batch in which it is included has been verified on the L1 smart contract.
Verified transaction
zkSync Era is a Zk Rollup. As such, it uses a Zero-Knowledge Proof system to succinctly prove the execution of L2 transactions. This succinctness is the main point of the use of ZKP in Zk Rollups. The verification process of the ZKP allow to verify the correct computation of thousands of transaction. There exist several ZKP systems. zkSync Era has developed its own new ZKP system, which is part of the Boojum upgrade.
The Boojum proving system can be seen as a toolbox to implement ZK circuits. Those ZK circuits are used to perform arbitrary computations. They are essential for proof systems such as Boojum to generate computation witnesses. These witnesses can then be verified non-interactively, proving that the ZK circuits were executed correctly.
EraVM is a zkEVM. Specific ZK circuits were developed for the EraVM to fit the EVM behavior. They are used to prove the correct execution of the VM. The EraVM will produce a ZK witness each time an L1 batch is processed.
This witness allows the operator (i.e. the prover) to prove the correct computation of the L1 batch. Once an L1 batch has been committed to zkSync's L1 smart contract, the witness can be provided to this smart contract to prove that the EraVM computed the L1 batch correctly. The L1 smart contract then delegates verification to a specialized smart contract (deployed on the L1) acting as the verifier in the ZKP system.
Generation of a ZK proof for L1 batch execution in EraVM
This verification step is done by calling the proveBatches
function on the L1 smart contract.
The hash of this L1 transaction will be reported by zkSync's nodes as the value of the eth_prove_tx_hash
field associated with the L2 transaction details (seen previously).
After the L1 batch data is verified on the L1, all the transactions of the batch are set to the verified status.
The L1 batch is also set to the verified status.
zkSync's L1 smart contract is a Diamond Proxy. This system is used to allow incremental smart contract updates, and to get rid of the 24 KiB bytecode size limitation attached to smart contracts.
The core idea is to segregate functionality from storage on one hand, and into several related smart contracts on the other hand. In zkSync Era's current state, these are:
Addresse | Facet Name | Number of registered selectors |
---|---|---|
0xdC7c3D...a9FaC4 | Admin (DiamondCut) | 8 |
0x2E6492...F97CC0 | Admin (Governance) | 5 |
0x7Ed066...F3330b | Executor | 4 |
0x7444DE...B391Ce | Getters | 35 |
0x0x62aA...E968EF | Mailbox | 6 |
Note that Governance and DiamondCut functionality were split at the time of deployment.
To understand how Alice's transaction gets published and verified onto the L1,
we will focus on the Executor
facet.
At this level, zkSync only works with entire batches of L2 transactions. This enables transaction compression on the L2 and lowers the transaction fees as they get shared among the batch's transactions. Alice's transaction is thus compressed and bundled with other transactions in a batch. Rather than a full list of L2 transactions, the batch only contains the list of changes required to reconstruct the state of the L2 blockchain, ensuring data availability (see the "Public data outputs" section).
This batch first gets committed to the L1 smart contract using the commitBatches
function,
which is only accessible to the set of validators.
The function ensures batches are valid, submitted in order and based on a valid known state,
but it makes no guarantee on the validity of the set of changes.
At this point, Alice's transaction is reduced to its effects and simply published to the L1.
This corresponds to the "included" state.
The proof comes next, when the proveBatches
function gets called (only by a validator).
The validator provides a zero-knowledge proof of the batch, proving the good execution
of the EraVM on the set of public inputs, including Alice's transaction.
A specialized smart contract called the
Verifier
is responsible for verifying the proof.
This special contract shall be updated alongside EraVM's updates using protocol upgrades.
Once a batch has been proven, cross-chain operations
(stored in a priority queue accessible using the Mailbox facet) need to be executed.
Since the funds transferred by Alice remain on L2, no operation is needed for this transaction.
After all operations are executed, the batch can transition to the "executed" state when
executeBatches
is called.
Alice's transaction is now considered finalized.
Finalized transaction
Once a batch is verified, all the transactions in it have the status verified. Before the transactions become finalized, there is one last step to perform.
Multiple L1 batches have been committed and verified on the L1 smart contract. The final step of the workflow is the execution of the L1 batches. This means that the state obtained after the L1 batches must become the official state of the L2 in the L1 contract.
To optimize the gas cost on Ethereum, a single L1 transaction will be used to execute multiple L1 batches. These transactions are sent by a zkSync operator. As of today, the L2 is highly centralized, and the zkSync operator has a lot of power in the L2.
Execution of L1 batches on the L1 smart contract
This final step is done by calling the executeBatches
function in the L1 diamond.
The hash of this L1 transaction will be reported by zkSync's nodes as the value of the eth_execute_tx_hash
field associated with the L2 transaction details (seen previously).
In practice, the state finalization is done by importing the L2 logs Merkle tree for each L1 batch.
Once this step is completed, the L1 batches are marked as finalized. At this point, there is no way to cancel L2 transactions. L2 transactions can be qualified as finalized.
An L2 transaction on zkSync Era goes through multiple steps. The transaction is quickly confirmed at the Layer 2 level, and users are informed that their transactions are valid at this stage. But these confirmations given to user wallets only ensure the transactions' execution on the L2. More low-level checks are required to ensure that the protocol can be trusted. For example, the execution of the L2 Virtual Machine needs to be proven to ensure that the executed transactions are valid.
By using Zero-Knowledge Proofs, zkSync Era is able to prove the correct execution of its EraVM and that the outputs of this execution are valid. Producing and verifying a proof of correct execution on-chain lets zkSync Era bring "Ethereum's security" to its L2.
ZKP are a powerful tool to fix Ethereum's scalability issues. Scalability is improved by batching many transactions together and only submitting a single proof. Verifiers do not need to compute the whole set of transactions to verify they were correctly executed, and can focus solely on the transactions' effects. In the case of zkSync Era, hundreads of transactions are validated as correct using a single Ethereum transaction that verifies the proof. According to EIP-4844, Zk Rollups fees are ~40-100x lower than Ethereum fees. This reduction is possible thanks to ZKP.
But the implementation of the EraVM may be vulnerable. For example, it could execute invalid transactions as valid ones and vice-versa. This is why the zkSync team organized a Code4rena contest with a $1.1M prize pool. This audit contest aims at finding bugs in zkSync Era, so they can be fixed before release, and to avoid production bugs which often have a high impact in the blockchain ecosystem.
Now that we illustrated zkSync throughout Alice's transaction's lifetime, let's discuss a few points of interest we glossed over earlier.
As a remainder, Diamond smart contracts are specially designed for granular upgradability and limitless functionality.
To achieve this, externally-accessible functions are registered using their Solidity-style
selector1
Each selector is mapped to a Facet containing the associated logic.
When called, the Diamond contract's fallback function retrieves the appropriate Facet,
performs a DELEGATECALL
with its calldata and relay the return status of the Facet.
Using a DELEGATECALL
means that the Facet has full power over the Diamond Smart Contract storage,
so registered Facets and functions need to be controlled carefully.
To update the Diamond's registered functions, one must use the diamondCut
internal
function.2
In zkSync's current design, this is only possible at deployment time (in the constructor
)
and using the Admin Facet's executeUpgrade
function, which is protected by onlyGovernor
.
A Facet has no associated storage.
Instead, it accesses the Diamond's storage space directly thanks to the DELEGATECALL
.
To achieve interoperability among Facets and to prevent storage space collision,
the standard suggests two patterns:
Diamond Storage
and AppStorage.
zkSync uses both patterns:
DiamondStorage
structure, responsible for Diamond-related functionality and
located in keccak256("diamond.standard.diamond.storage") - 1
,AppStorage
structure, holding the app's state and
located in the first slot of the Base
Smart Contract, inherited by all Facets as the first parent.In summary, zkSync's implementation of the Diamond standard uses a set of Facets containing related
functionality, and aggregates them in a single Smart Contract.
Storage is contained in only two structures: DiamondStorage
and AppStorage
.
Functionality is distributed among 4 Facets.
Although the EIP-2535 allows Diamond Smart Contracts to
have native externally-accessible functions,
zkSync's implementation has none and delegates everything to Facets in its fallback function,
including Diamond-related functionality.
This explains why it is called a Diamond Proxy.
The actual Diamond-related functionality is located in the
Diamond library
used only by the Proxy, the Admin
Facet and the Getters
Facet.
The core object of the Diamond is the selectorToFacet
mapping.
It is used to know which facet to call for any particular function selector.
Although a mapping(bytes4 => address)
is sufficient and minimal in theory
(hence its use in the standard's illustrative examples),
zkSync chose to use more complex data structures to allow more efficient introspection and updates,
as well as freezing capabilities.
In exchange, the library needs to pay special attention to ensure internal data consistency and
accurate bookkeeping.
The Diamond implements a bidirectional, one-to-many mapping between Facet addresses and selectors:
facetToSelectors
, andselectorToFacet
.In addition, it also keeps track of a list of used Facet addresses in address[] facets
and
of a global frozen status in bool isFrozen
.
Conceptual Entity Relation Diagram of zkSync's Diamond implementation
The main mapping uses specialized objects to hold additional information about its elements:
SelectorToFacet
has these fields
(note that all these values are packed in a single slot by Solidity,
improving general gas consumption)3:address facetAddress
(data),bool isFreezable
(metadata) anduint16 selectorPosition
(bookkeeping).FacetToSelectors
has these fields:bytes4[] selectors
(data), anduint16 facetPosition
(bookkeeping).The one-to-many relationship in the bidirectional mapping is represented
in FacetToSelectors
by the array of selectors
and in SelectorToFacet
by the selector's position in the corresponding array.
This means these values need to be kept in sync by the library,
and that at most \(2^{16}\) selectors per Facet can be registered
(which should be more than enough).
The advantage of this bookkeeping is that the library does not need to perform
(potentially expensive) searches to find how to update its internal data structures,
at the cost of more upfront computations and a slightly higher storage footprint.
If you would like to learn more about our security audits and explore how we can help you, get in touch with us!