The zkSync zkEVM plays a fundamentally different role in the zkStack than the EVM does in Ethereum. The EVM is used to execute code in Ethereum's state transition function. This STF needs a client to implement and run it. Ethereum has a multi-client philosophy, there are multiple clients, and they are written in Go, Rust, and other traditional programming languages, all running and verifying the same STF.
We have a different set of requirements, we need to produce a proof that some client executed the STF correctly. The first consequence is that the client needs to be hard-coded, we cannot have the same multi-client philosophy. This client is the zkEVM, it can run the STF efficiently, including execution of smart contracts similarly to the EVM. The zkEVM was also designed to be proven efficiently.
For efficiency reasons it the zkEVM is similar to the EVM. This makes executing smart programs inside of it easy. It also has special features that are not in the EVM but are needed for the rollup's STF, storage, gas metering, precompiles and other things. Some of these features are implemented as system contracts while others are built into the VM. System Contracts are contracts with special permissions, deployed at predefined addresses. Finally, we have the bootloader, which is also a contract, although it is not deployed at any address. This is the STF that is ultimately executed by the zkEVM, and executes the transaction against the state.
Full specification of the zkEVM is beyond the scope of this document. However, this section will give you most of the details needed for understanding the L2 system smart contracts & basic differences between EVM and zkEVM. Note also that usually understanding the EVM is needed for efficient smart contract development. Understanding the zkEVM goes beyond this, it is needed for developing the rollup itself.
On EVM, during transaction execution, the following memory areas are available:
memory
itself.calldata
the immutable slice of parent memory.returndata
the immutable slice returned by the latest call to another contract.stack
where the local variables are stored.
Unlike EVM, which is stack machine, zkEVM has 16 registers. Instead of receiving input from calldata
, zkEVM starts by
receiving a pointer in its first register (basically a packed struct with 4 elements: the memory page id, start and
length of the slice to which it points to) to the calldata page of the parent. Similarly, a transaction can receive
some other additional data within its registers at the start of the program: whether the transaction should invoke the
constructor
more about deployments here,
whether the transaction has isSystem
flag, etc. The meaning of each of these flags will be expanded further in this
section.
Pointers are separate type in the VM. It is only possible to:
- Read some value within a pointer.
- Shrink the pointer by reducing the slice to which pointer points to.
- Receive the pointer to the
returndata
as a calldata. - Pointers can be stored only on stack/registers to make sure that the other contracts can not read
memory/returndata
of contracts they are not supposed to. - A pointer can be converted to the u256 integer representing it, but an integer can not be converted to a pointer to prevent unallowed memory access.
- It is not possible to return a pointer that points to a memory page with id smaller than the one for the current page.
What this means is that it is only possible to
return
only pointer to the memory of the current frame or one of the pointers returned by the subcalls of the current frame.
For each frame, the following memory areas are allocated:
- Heap (plays the same role as
memory
on Ethereum). - AuxHeap (auxiliary heap). It has the same properties as Heap, but it is used for the compiler to encode
calldata/copy the
returndata
from the calls to system contracts to not interfere with the standard Solidity memory alignment. - Stack. Unlike Ethereum, stack is not the primary place to get arguments for opcodes. The biggest difference between stack on zkEVM and EVM is that on zkSync stack can be accessed at any location (just like memory). While users do not pay for the growth of stack, the stack can be fully cleared at the end of the frame, so the overhead is minimal.
- Code. The memory area from which the VM executes the code of the contract. The contract itself can not read the code page, it is only done implicitly by the VM.
Also, as mentioned in the previous section, the contract receives the pointer to the calldata.
Whenever a contract finishes its execution, the parent’s frame receives a pointer as returndata
. This pointer may
point to the child frame’s Heap/AuxHeap or it can even be the same returndata
pointer that the child frame received
from some of its child frames.
The same goes with the calldata
. Whenever a contract starts its execution, it receives the pointer to the calldata.
The parent frame can provide any valid pointer as the calldata, which means it can either be a pointer to the slice of
parent’s frame memory (heap or auxHeap) or it can be some valid pointer that the parent frame has received before as
calldata/returndata.
Contracts simply remember the calldata pointer at the start of the execution frame (it is by design of the compiler) and remembers the latest received returndata pointer.
Some important implications of this is that it is now possible to do the following calls without any memory copying:
A → B → C
where C receives a slice of the calldata received by B.
The same goes for returning data:
A ← B ← C
There is no need to copy returned data if the B returns a slice of the returndata returned by C.
Note, that you can not use the pointer that you received via calldata as returndata (i.e. return it at the end of the
execution frame). Otherwise, it would be possible that returndata points to the memory slice of the active frame and
allow editing the returndata
. It means that in the examples above, C could not return a slice of its calldata without
memory copying.
Some of these memory optimizations can be seen utilized in the EfficientCall library that allows to perform a call while reusing the slice of calldata that the frame already has, without memory copying.
Some of the operations which are opcodes on Ethereum, have become calls to some of the system contracts. The most
notable examples are Keccak256
, SystemContext
, etc. Note, that, if done naively, the following lines of code would
work differently on zkSync and Ethereum:
pop(call(...))
keccak(...)
returndatacopy(...)
Since the call to keccak precompile would modify the returndata
. To avoid this, our compiler does not override the
latest returndata
pointer after calls to such opcode-like precompiles.
While some Ethereum opcodes are not supported out of the box, some of the new opcodes were added to facilitate the development of the system contracts.
Note, that this lists does not aim to be specific about the internals, but rather explain methods in the SystemContractHelper.sol
These opcodes are allowed only for contracts in kernel space (i.e. system contracts). If executed in other places they
result in revert(0,0)
.
mimic_call
. The same as a normalcall
, but it can alter themsg.sender
field of the transaction.to_l1
. Sends a system L2→L1 log to Ethereum. The structure of this log can be seen here.event
. Emits an L2 log to zkSync. Note, that L2 logs are not equivalent to Ethereum events. Each L2 log can emit 64 bytes of data (the actual size is 88 bytes, because it includes the emitter address, etc). A single Ethereum event is represented with multipleevent
logs constitute. This opcode is only used byEventWriter
system contract.precompile_call
. This is an opcode that accepts two parameters: the uint256 representing the packed parameters for it as well as the ergs to burn. Besides the price for the precompile call itself, it burns the provided ergs and executes the precompile. The action that it does depend onthis
during execution:- If it is the address of the
ecrecover
system contract, it performs the ecrecover operation - If it is the address of the
sha256
/keccak256
system contracts, it performs the corresponding hashing operation. - It does nothing (i.e. just burns ergs) otherwise. It can be used to burn ergs needed for L2→L1 communication or publication of bytecodes onchain.
- If it is the address of the
setValueForNextFarCall
setsmsg.value
for the nextcall
/mimic_call
. Note, that it does not mean that the value will be really transferred. It just sets the correspondingmsg.value
context variable. The transferring of ETH should be done via other means by the system contract that uses this parameter. Note, that this method has no effect ondelegatecall
, sincedelegatecall
inherits themsg.value
of the previous frame.increment_tx_counter
increments the counter of the transactions within the VM. The transaction counter used mostly for the VM’s internal tracking of events. Used only in bootloader after the end of each transaction.
Note, that currently we do not have access to the tx_counter
within VM (i.e. for now it is possible to increment it
and it will be automatically used for logs such as event
s as well as system logs produced by to_l1
, but we can not
read it). We need to read it to publish the user L2→L1 logs, so increment_tx_counter
is always accompanied by the
corresponding call to the
SystemContext
contract.
More on the difference between system and user logs can be read
here. -
set_pubdata_price
sets the price (in gas) for publishing a single byte of pubdata.
Here are opcodes that can be generally accessed by any contract. Note that while the VM allows to access these methods, it does not mean that this is easy: the compiler might not have convenient support for some use-cases yet.
near_call
. It is basically a “framed” jump to some location of the code of your contract. The difference between thenear_call
and ordinary jump are:- It is possible to provide an ergsLimit for it. Note, that unlike “
far_call
”s (i.e. calls between contracts) the 63/64 rule does not apply to them. - If the near call frame panics, all state changes made by it are reversed. Please note, that the memory changes will not be reverted.
- It is possible to provide an ergsLimit for it. Note, that unlike “
getMeta
. Returns an u256 packed value of ZkSyncMeta struct. Note that this is not tight packing. The struct is formed by the following rust code.getCodeAddress
— receives the address of the executed code. This is different fromthis
, since in case of delegatecallsthis
is preserved, butcodeAddress
is not.
Besides the calldata, it is also possible to provide additional information to the callee when doing call
,
mimic_call
, delegate_call
. The called contract will receive the following information in its first 12 registers at
the start of execution:
- r1 — the pointer to the calldata.
- r2 — the pointer with flags of the call. This is a mask, where each bit is set only if certain flags have been set
to the call. Currently, two flags are supported: 0-th bit:
isConstructor
flag. This flag can only be set by system contracts and denotes whether the account should execute its constructor logic. Note, unlike Ethereum, there is no separation on constructor & deployment bytecode. More on that can be read here. 1-st bit:isSystem
flag. Whether the call intends a system contracts’ function. While most of the system contracts’ functions are relatively harmless, accessing some with calldata only may break the invariants of Ethereum, e.g. if the system contract usesmimic_call
: no one expects that by calling a contract some operations may be done out of the name of the caller. This flag can be only set if the callee is in kernel space. - The rest r3..r12 registers are non-empty only if the
isSystem
flag is set. There may be arbitrary values passed, which we callextraAbiParams
.
The compiler implementation is that these flags are remembered by the contract and can be accessed later during execution via special simulations.
If the caller provides inappropriate flags (i.e. tries to set isSystem
flag when callee is not in the kernel space),
the flags are ignored.
Some of the system contracts can act on behalf of the user or have a very important impact on the behavior of the
account. That’s why we wanted to make it clear that users can not invoke potentially dangerous operations by doing a
simple EVM-like call
. Whenever a user wants to invoke some of the operations which we considered dangerous, they must
provide “isSystem
” flag with them.
The onlySystemCall
flag checks that the call was either done with the “isSystemCall” flag provided or the call is done
by another system contract (since Matter Labs is fully aware of system contracts).
In the future, we plan to introduce our “extended” version of Solidity with more supported opcodes than the original
one. However, right now it was beyond the capacity of the team to do, so in order to represent accessing zkSync-specific
opcodes, we use call
opcode with certain constant parameters that will be automatically replaced by the compiler with
zkEVM native opcode.
Example:
function getCodeAddress() internal view returns (address addr) {
address callAddr = CODE_ADDRESS_CALL_ADDRESS;
assembly {
addr := staticcall(0, callAddr, 0, 0xFFFF, 0, 0)
}
}
In the example above, the compiler will detect that the static call is done to the constant CODE_ADDRESS_CALL_ADDRESS
and so it will replace it with the opcode for getting the code address of the current execution.
Full list of opcode simulations can be found here.
We also use verbatim-like statements to access zkSync-specific opcodes in the bootloader.
All the usages of the simulations in our Solidity code are implemented in the SystemContractHelper library and the SystemContractsCaller library.
Simulating near_call
(in Yul only)
In order to use near_call
i.e. to call a local function, while providing a limit of ergs (gas) that this function can
use, the following syntax is used:
The function should contain ZKSYNC_NEAR_CALL
string in its name and accept at least 1 input parameter. The first input
parameter is the packed ABI of the near_call
. Currently, it is equal to the number of ergs to be passed with the
near_call
.
Whenever a near_call
panics, the ZKSYNC_CATCH_NEAR_CALL
function is called.
Important note: the compiler behaves in a way that if there is a revert
in the bootloader, the
ZKSYNC_CATCH_NEAR_CALL
is not called and the parent frame is reverted as well. The only way to revert only the
near_call
frame is to trigger VM’s panic (it can be triggered with either invalid opcode or out of gas error).
Important note 2: The 63/64 rule does not apply to near_call
. Also, if 0 gas is provided to the near call, then
actually all of the available gas will go to it.
To prevent unintended substitution, the compiler requires --system-mode
flag to be passed during compilation for the
above substitutions to work.
On zkSync the bytecode hashes are stored in the following format:
- The 0th byte denotes the version of the format. Currently the only version that is used is “1”.
- The 1st byte is
0
for deployed contracts’ code and1
for the contract code that is being constructed. - The 2nd and 3rd bytes denote the length of the contract in 32-byte words as big-endian 2-byte number.
- The next 28 bytes are the last 28 bytes of the sha256 hash of the contract’s bytecode.
The bytes are ordered in little-endian order (i.e. the same way as for bytes32
).
A bytecode is valid if it:
- Has its length in bytes divisible by 32 (i.e. consists of an integer number of 32-byte words).
- Has a length of less than 2^16 words (i.e. its length in words fits into 2 bytes).
- Has an odd length in words (i.e. the 3rd byte is an odd number).
Note, that it does not have to consist of only correct opcodes. In case the VM encounters an invalid opcode, it will simply revert (similar to how EVM would treat them).
A call to a contract with invalid bytecode can not be proven. That is why it is essential that no contract with invalid bytecode is ever deployed on zkSync. It is the job of the KnownCodesStorage to ensure that all allowed bytecodes in the system are valid.