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 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 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.
These smart contracts manage the freezing/unfreezing and upgrades of the diamond proxy. That being said, the contract must never be frozen.
Currently, freezing and unfreezing are implemented as access control functions. It is fully controlled by the governor
but can be changed later. The governor can call freezeDiamond
to freeze the diamond and unfreezeDiamond
to restore
it.
Another purpose of DiamondCutFacet
is to upgrade the facets. The upgrading is split into 2-3 phases:
proposeTransparentUpgrade
/proposeShadowUpgrade
- propose an upgrade with visible/hidden parameters.cancelUpgradeProposal
- cancel the upgrade proposal.securityCouncilUpgradeApprove
- approve the upgrade by the security council.executeUpgrade
- finalize the upgrade.
The upgrade itself characterizes by three variables:
facetCuts
- a set of changes to the facets (adding new facets, removing facets, and replacing them).- pair
(address _initAddress, bytes _calldata)
for initializing the upgrade by making a delegate call to_initAddress
with_calldata
inputs.
Separate facet, whose only function is providing view
and pure
methods. It also implements
diamond loupe which makes managing facets easier.
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).
At the current stage, the governor has permission to instantly change the key system parameters with GovernanceFacet
.
Later such functionality will be removed and changing system parameters will be possible only via Diamond upgrade (see
DiamondCutFacet).
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 block. A validator will send all
l2ToL1Logs
when sending an L2 block 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 block, 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 blocks, enforces data availability and checks the validity of zk-proofs.
The state transition is divided into three stages:
commitBlocks
- check L2 block timestamp, process the L2 logs, save data for a block, and prepare data for zk-proof.proveBlocks
- validate zk-proof.executeBlocks
- finalize the state, marking L1 -> L2 communication processing, and saving Merkle tree with L2 logs.
When a block is committed, we process L2 -> L1 logs. Here are the invariants that are expected there:
- The only L2 -> L1 log from the
L2_SYSTEM_CONTEXT_ADDRESS
, with thekey == l2BlockTimestamp
andvalue == l2BlockHash
. - Several (or none) logs from the
L2_KNOWN_CODE_STORAGE_ADDRESS
with thekey == bytecodeHash
, where bytecode is marked as a known factory dependency. - Several (or none) logs from the
L2_BOOTLOADER_ADDRESS
with thekey == canonicalTxHash
wherecanonicalTxHash
is a hash of processed L1 -> L2 transaction. - Several (of none) logs from the
L2_TO_L1_MESSENGER
with thevalue == hashedMessage
wherehashedMessage
is a hash of an arbitrary-length message that is sent from L2 - 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 block 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.
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 can not 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!