zkSync Era is a permissionless general-purpose ZK rollup. Similar to many L1 blockchains and sidechains it enables deployment and interaction with Turing-complete smart contracts.
- L2 smart contracts are executed on a zkEVM.
- zkEVM bytecode is different from the L1 EVM.
- There is a Solidity and Vyper compilers for L2 smart contracts.
- There is a standard way to pass messages between L1 and L2. That is a part of the protocol.
- There is no escape hatch mechanism yet, but there will be one.
All data that is needed to restore the L2 state are also pushed on-chain. There are two approaches, publishing inputs of L2 transactions on-chain and publishing the state transition diff. zkSync follows the second option.
See the documentation to read more!
- Governor - a privileged address that controls the upgradability of the network and sets other privileged addresses.
- Security council - an address of the Gnosis multisig with the trusted owners that can decrease upgrade timelock.
- Validator/Operator - a privileged address that can commit/verify/execute L2 batches.
- L2 batch (or just batch) - An aggregation of multiple L2 blocks. Note, that while the API operates on L2 blocks, the prove system operates on batches, which represent a single proved VM execution, which typically contains multiple L2 blocks.
- Facet - implementation contract. The word comes from the EIP-2535.
- Gas - a unit that measures the amount of computational effort required to execute specific operations on the zkSync Era network.
Technically, this L1 smart contract acts as a connector between Ethereum (L1) and zkSync (L2). This contract checks the validity proof and data availability, handles L2 <-> L1 communication, finalizes L2 state transition, and more.
There are also important contracts deployed on the L2 that can also execute logic called system contracts. Using L2 <-> L1 communication can affect both the L1 and the L2.
The main contract uses EIP-2535 diamond proxy pattern. It is an in-house implementation that is inspired by the mudgen reference implementation. It has no external functions, only the fallback that delegates a call to one of the facets (target/implementation contract). So even an upgrade system is a separate facet that can be replaced.
One of the differences from the reference implementation is access freezability. Each of the facets has an associated
parameter that indicates if it is possible to freeze access to the facet. Privileged actors can freeze the diamond
(not a specific facet!) and all facets with the marker isFreezable
should be inaccessible until the governor or its owner
unfreezes the diamond. Note that it is a very dangerous thing since the diamond proxy can freeze the upgrade system and then
the diamond will be frozen forever.
It is a one-function contract that implements the logic of initializing a diamond proxy. It is called only once on the diamond constructor and is not saved in the diamond as a facet.
Implementation detail - function returns a magic value just like it is designed in EIP-1271, but the magic value is 32 bytes in size.
Separate facet, whose only function is providing view
and pure
methods. It also implements
diamond loupe which makes managing facets easier.
This contract must never be frozen.
Controls changing the privileged addresses such as governor and validators or one of the system parameters (L2 bootloader bytecode hash, verifier address, verifier parameters, etc), and it also manages the freezing/unfreezing and execution of upgrades in the diamond proxy.
This contract manages operations (calls with preconditions) for governance tasks. The contract allows for operations to be scheduled, executed, and canceled with appropriate permissions and delays. It is used for managing and coordinating upgrades and changes in all zkSync Era governed contracts.
Each upgrade consists of two steps:
- Upgrade Proposal - The governor can schedule upgrades in two different manners:
- Fully transparent data. All implementation contracts and migration contracts are known to the community. The governor must wait for the timelock to execute the upgrade.
- Shadow upgrade. The governor only shows the commitment for the upgrade. The upgrade can be executed only with security council approval without timelock.
- Upgrade execution - perform the upgrade that was proposed.
The facet that handles L2 <-> L1 communication, an overview for which can be found in docs.
The Mailbox performs three functions:
- L1 <-> L2 communication.
- Bridging native Ether to the L2.
- Censorship resistance mechanism (not yet implemented).
L1 -> L2 communication is implemented as requesting an L2 transaction on L1 and executing it on L2. This means a user can call the function on the L1 contract to save the data about the transaction in some queue. Later on, a validator can process it on L2 and mark them as processed on the L1 priority queue. Currently, it is used for sending information from L1 to L2 or implementing multi-layer protocols.
NOTE: While user requests the transaction from L1, the initiated transaction on L2 will have such a msg.sender
:
address sender = msg.sender;
if (sender != tx.origin) {
sender = AddressAliasHelper.applyL1ToL2Alias(msg.sender);
}
where
uint160 constant offset = uint160(0x1111000000000000000000000000000000001111);
function applyL1ToL2Alias(address l1Address) internal pure returns (address l2Address) {
unchecked {
l2Address = address(uint160(l1Address) + offset);
}
}
For most of the rollups the address aliasing needs to prevent cross-chain exploits that would otherwise be possible if we simply reused the same L1 addresses as the L2 sender. In zkSync Era address derivation rule is different from the Ethereum, so cross-chain exploits are already impossible. However, zkSync Era may add full EVM support in the future, so applying address aliasing leave room for future EVM compatibility.
The L1 -> L2 communication is also used for bridging ether. The user should include a msg.value
when initiating a
transaction request on the L1 contract. Before executing a transaction on L2, the specified address will be credited
with the funds. To withdraw funds user should call withdraw
function on the L2EtherToken
system contracts. This will
burn the funds on L2, allowing the user to reclaim them through the finalizeEthWithdrawal
function on the
MailboxFacet
.
L2 -> L1 communication, in contrast to L1 -> L2 communication, is based only on transferring the information, and not on the transaction execution on L1.
From the L2 side, there is a special zkEVM opcode that saves l2ToL1Log
in the L2 batch. A validator will send all
l2ToL1Logs
when sending an L2 batch to the L1 (see ExecutorFacet
). Later on, users will be able to both read their
l2ToL1logs
on L1 and prove that they sent it.
From the L1 side, for each L2 batch, a Merkle root with such logs in leaves is calculated. Thus, a user can provide
Merkle proof for each l2ToL1Logs
.
NOTE: For each executed L1 -> L2 transaction, the system program necessarily sends an L2 -> L1 log. To verify the
execution status user may use the proveL1ToL2TransactionStatus
.
NOTE: The l2ToL1Log
structure consists of fixed-size fields! Because of this, it is inconvenient to send a lot of
data from L2 and to prove that they were sent on L1 using only l2ToL1log
. To send a variable-length message we use
this trick:
- One of the system contracts accepts an arbitrary length message and sends a fixed length message with parameters
senderAddress == this
,marker == true
,key == msg.sender
,value == keccak256(message)
. - The contract on L1 accepts all sent messages and if the message came from this system contract it requires that the
preimage of
value
be provided.
A contract that accepts L2 batches, enforces data availability and checks the validity of zk-proofs.
The state transition is divided into three stages:
commitBatches
- check L2 batch timestamp, process the L2 logs, save data for a batch, and prepare data for zk-proof.proveBatches
- validate zk-proof.executeBatches
- finalize the state, marking L1 -> L2 communication processing, and saving Merkle tree with L2 logs.
Each L2 -> L1 system log will have a key that is part of the following:
enum SystemLogKey {
L2_TO_L1_LOGS_TREE_ROOT_KEY,
TOTAL_L2_TO_L1_PUBDATA_KEY,
STATE_DIFF_HASH_KEY,
PACKED_BATCH_AND_L2_BLOCK_TIMESTAMP_KEY,
PREV_BATCH_HASH_KEY,
CHAINED_PRIORITY_TXN_HASH_KEY,
NUMBER_OF_LAYER_1_TXS_KEY,
EXPECTED_SYSTEM_CONTRACT_UPGRADE_TX_HASH_KEY
}
When a batch is committed, we process L2 -> L1 system logs. Here are the invariants that are expected there:
- In a given batch there will be either 7 or 8 system logs. The 8th log is only required for a protocol upgrade.
- There will be a single log for each key that is containted within
SystemLogKey
- Three logs from the
L2_TO_L1_MESSENGER
with keys: L2_TO_L1_LOGS_TREE_ROOT_KEY
TOTAL_L2_TO_L1_PUBDATA_KEY
STATE_DIFF_HASH_KEY
- Two logs from
L2_SYSTEM_CONTEXT_SYSTEM_CONTRACT_ADDR
with keys:PACKED_BATCH_AND_L2_BLOCK_TIMESTAMP_KEY
PREV_BATCH_HASH_KEY
- Two or three logs from
L2_BOOTLOADER_ADDRESS
with keys:CHAINED_PRIORITY_TXN_HASH_KEY
NUMBER_OF_LAYER_1_TXS_KEY
EXPECTED_SYSTEM_CONTRACT_UPGRADE_TX_HASH_KEY
- None logs from other addresses (may be changed in the future).
Bridges are completely separate contracts from the Diamond. They are a wrapper for L1 <-> L2 communication on contracts on both L1 and L2. Upon locking assets on one layer, a request is sent to mint these bridged assets on the other layer. Upon burning assets on one layer, a request is sent to unlock them on the other.
Unlike the native Ether bridging, all other assets can be bridged by the custom implementation relying on the trustless L1 <-> L2 communication.
The "standard" implementation of the ERC20 token bridge. Works only with regular ERC20 tokens, i.e. not with fee-on-transfer tokens or other custom logic for handling user balances.
deposit
- lock funds inside the contract and send a request to mint bridged assets on L2.claimFailedDeposit
- unlock funds if the deposit was initiated but then failed on L2.finalizeWithdrawal
- unlock funds for the valid withdrawal request from L2.
The L2 counterpart of the L1 ERC20 bridge.
withdraw
- initiate a withdrawal by burning funds on the contract and sending a corresponding message to L1.finalizeDeposit
- finalize the deposit and mint funds on L2.
The custom bridge exclusively handles transfers of WETH tokens between the two domains. It is designed to streamline and enhance the user experience for bridging WETH tokens by minimizing the number of transactions required and reducing liquidity fragmentation thus improving efficiency and user experience.
This contract accepts WETH deposits on L1, unwraps them to ETH, and sends the ETH to the L2 WETH bridge contract, where it is wrapped back into WETH and delivered to the L2 recipient.
Thus, the deposit is made in one transaction, and the user receives L2 WETH that can be unwrapped to ETH.
The L2 counterpart of the L1 WETH bridge.
For withdrawals, the contract receives ETH from the L2 WETH bridge contract, wraps it into WETH, and sends the WETH to the L1 recipient.
An intermediate smart contract between the validator EOA account and the zkSync smart contract. Its primary purpose is to provide a trustless means of delaying batch execution without modifying the main zkSync contract. zkSync actively monitors the chain activity and reacts to any suspicious activity by freezing the chain. This allows time for investigation and mitigation before resuming normal operations.
It is a temporary solution to prevent any significant impact of the validator hot key leakage, while the network is in the Alpha stage.
This contract consists of four main functions commitBatches
, proveBatches
, executeBatches
, and revertBatches
, that
can be called only by the validator.
When the validator calls commitBatches
, the same calldata will be propogated to the zkSync contract (DiamondProxy
through
call
where it invokes the ExecutorFacet
through delegatecall
), and also a timestamp is assigned to these batches to track
the time these batches are commited by the validator to enforce a delay between committing and execution of batches. Then, the
validator can prove the already commited batches regardless of the mentioned timestamp, and again the same calldata (related
to the proveBatches
function) will be propogated to the zkSync contract. After, the delay
is elapsed, the validator
is allowed to call executeBatches
to propogate the same calldata to zkSync contract.
The auxiliary contract controls the permission access list. It is used in bridges and diamond proxies to control which addresses can interact with them in the Alpha release.
The L2 deployment process is different from Ethereum.
In L1, the deployment always goes through two opcodes create
and create2
, each of which provides its address
derivation. The parameter of these opcodes is the so-called "init bytecode" - a bytecode that returns the bytecode to be
deployed. This works well in L1 but is suboptimal for L2.
In the case of L2, there are also two ways to deploy contracts - create
and create2
. However, the expected input
parameters for create
and create2
are different. It accepts the hash of the bytecode, rather than the full bytecode.
Therefore, users pay less for contract creation and don't need to send the full contract code by the network upon
deploys.
A good question could be, how does the validator know the preimage of the bytecode hashes to execute the code? Here comes the concept of factory dependencies! Factory dependencies are a list of bytecode hashes whose preimages were shown on L1 (data is always available). Such bytecode hashes can be deployed, others - no. Note that they can be added to the system by either L2 transaction or L1 -> L2 communication, where you can specify the full bytecode and the system will mark it as known and allow you to deploy it.
Besides that, due to the bytecode differences for L1 and L2 contracts, address derivation is different. This applies to
both create
and create2
and means that contracts deployed on the L1 cannot have a collision with contracts deployed
on the L2. Please note that EOA address derivation is the same as on Ethereum.
Thus:
- L2 contracts are deployed by bytecode hash, not by full bytecode
- Factory dependencies - list of bytecode hashes that can be deployed on L2
- Address derivation for
create
/create2
on L1 and L2 is different
The amount of deposit can be limited. This limitation is applied on an account level and is not time-based. In other words, each account cannot deposit more than the cap defined. The tokens and the cap can be set through governance transactions. Moreover, there is an allow listing mechanism as well (only some allow listed accounts can call some specific functions). So, the combination of deposit limitation and allow listing leads to limiting the deposit of the allow listed account to be less than the defined cap.
struct Deposit {
bool depositLimitation;
uint256 depositCap;
}
Currently, the limit is used only for blocking deposits of the specific token (turning on the limitation and setting the limit to zero). And on the near future, this functionality will be completely removed.
See the documentation to read more!