System Contracts

An overview of system contracts in VM, detailing their unique privileges, locations, and update mechanisms.

Introduction to system contracts

System contracts are a specialized set of contracts in virtual machines (VM) that enhance the Ethereum Virtual Machine (EVM) by supporting opcodes not available by default. These contracts operate under unique conditions and have privileges not accessible to standard user contracts.

Location and privileges

System contracts reside in a designated area known as kernel space, specifically within the address space ranging from [0..2^16-1]. This limited address space sets them apart from regular user contracts and grants them special operating privileges.

Characteristics and deployment

  • Pre-deployed: System contracts are deployed at the genesis of the blockchain, ensuring they are in place from the very beginning.
  • Special Privileges: Unlike user contracts, system contracts can perform operations that require higher security clearance or access to core VM functionalities.
  • Update Mechanism: Modifying the code of system contracts is not a routine procedure. Updates can only be executed through a system upgrade managed from Layer 1 (L1), ensuring controlled and secure changes.

Functionality and implementation

While system contracts support a variety of operations, from simple arithmetic to complex memory and storage management, their detailed functionalities and the exact execution requirements are documented extensively in the doc-comments within their codebases. This documentation provides developers with insights into how these contracts operate and integrate with the broader system.

Role in the protocol

System contracts form an essential part of the protocol architecture by enabling functionality that extends beyond the basic capabilities of the EVM. The codes of all system contracts, including DefaultAccount, are integral to the protocol and can only be altered through a system upgrade initiated from L1.


SystemContext

This contract is used to support various system parameters not included in the VM by default, i.e. chainId, origin, ergsPrice, blockErgsLimit, coinbase, difficulty, baseFee, blockhash, block.number, block.timestamp.

It is important to note that the constructor is not run for system contracts upon genesis, i.e. the constant context values are set on genesis explicitly. Notably, if in the future we want to upgrade the contracts, we will do it via ContractDeployer and so the constructor will be run.

This contract is also responsible for ensuring validity and consistency of batches, L2 blocks and virtual blocks. The implementation itself is rather straightforward, but to better understand this contract, please take a look at Batches and L2 Blocks on ZKsync about the block processing on ZKsync.

AccountCodeStorage

The code hashes of accounts are stored inside the storage of this contract. Whenever a VM calls a contract with address address it retrieves the value under storage slot address of this system contract, if this value is non-zero, it uses this as the code hash of the account.

Whenever a contract is called, the VM asks the operator to provide the preimage for the codehash of the account. That is why data availability of the code hashes is paramount.

Constructing vs Non-Constructing Code Hash

In order to prevent contracts from being able to call a contract during its construction, we set the marker (i.e. second byte of the bytecode hash of the account) as 1. This way, the VM will ensure that whenever a contract is called without the isConstructor flag, the bytecode of the default account (i.e. EOA) will be substituted instead of the original bytecode.

BootloaderUtilities

This contract contains some of the methods which are needed purely for the bootloader functionality but were moved out from the bootloader itself for the convenience of not writing this logic in Yul.

DefaultAccount

Whenever a contract that does not both:

  • belong to kernel space
  • have any code deployed on it (the value stored under the corresponding storage slot in AccountCodeStorage is zero)

The code of the default account is used. The main purpose of this contract is to provide EOA-like experience for both wallet users and contracts that call it, i.e. it should not be distinguishable (apart of spent gas) from EOA accounts on Ethereum.

Ecrecover

The implementation of the ecrecover precompile. It is expected to be used frequently, so written in pure yul with a custom memory layout.

The contract accepts the calldata in the same format as EVM precompile, i.e. the first 32 bytes are the hash, the next 32 bytes are the v, the next 32 bytes are the r, and the last 32 bytes are the s.

It also validates the input by the same rules as the EVM precompile:

  • The v should be either 27 or 28,
  • The r and s should be less than the curve order.

After that, it makes a precompile call and returns empty bytes if the call failed, and the recovered address otherwise.

Empty contracts

Some of the contracts are relied upon to have EOA-like behaviour, i.e. they can be always called and get the success value in return. An example of such address is 0 address. We also require the bootloader to be callable so that the users could transfer ETH to it.

For these contracts, we insert the EmptyContract code upon genesis. It is basically a noop code, which does nothing and returns success=1.

SHA256 & Keccak256

Note that, unlike Ethereum, keccak256 is a precompile (not an opcode) on ZKsync.

These system contracts act as wrappers for their respective crypto precompile implementations. They are expected to be used frequently, especially keccak256, since Solidity computes storage slots for mapping and dynamic arrays with its help. That's why we wrote contracts on pure yul with optimizing the short input case.

The system contracts accept the input and transform it into the format that the zk-circuit expects. This way, some of the work is shifted from the crypto to smart contracts, which are easier to audit and maintain.

Both contracts should apply padding to the input according to their respective specifications, and then make a precompile call with the padded data. All other hashing work will be done in the zk-circuit. It's important to note that the crypto part of the precompiles expects to work with padded data. This means that a bug in applying padding may lead to an unprovable transaction.

L2BaseToken & MsgValueSimulator

Unlike Ethereum, zkEVM does not have any notion of any special native token. That’s why we have to simulate operations with Ether via two contracts: L2BaseToken & MsgValueSimulator.

L2BaseToken is a contract that holds the balances of ETH for the users. This contract does NOT provide ERC20 interface. The only method for transferring Ether is transferFromTo. It permits only some system contracts to transfer on behalf of users. This is needed to ensure that the interface is as close to Ethereum as possible, i.e. the only way to transfer ETH is by doing a call to a contract with some msg.value. This is what MsgValueSimulator system contract is for.

Whenever anyone wants to do a non-zero value call, they need to call MsgValueSimulator with:

  • The calldata for the call equal to the original one.
  • Pass value and whether the call should be marked with isSystem in the first extra abi params.
  • Pass the address of the callee in the second extraAbiParam.

Support for .send/.transfer

On Ethereum, whenever a call with non-zero value is done, some additional gas is charged from the caller's frame and in return a 2300 gas stipend is given out to the callee frame. This stipend is usually enough to emit a small event, but it is enforced that it is not possible to change storage within these 2300 gas. This also means that in practice some users might opt to do call with 0 gas provided, relying on the 2300 stipend to be passed to the callee. This is the case for .call/.transfer.

While using .send/.transfer is generally not recommended, as a step towards better EVM compatibility, since vm1.5.0 a partial support of these functions is present with ZKsync Era. It is the done via the following means:

  • Whenever a call is done to the MsgValueSimulator system contract, 27000 gas is deducted from the caller's frame and it passed to the MsgValueSimulator on top of whatever gas the user has originally provided. The number was chosen to cover for the execution of the transfering of the balances as well as other constant size operations by the MsgValueSimulator. Note, that since it will be the frame of MsgValueSimulator that will actually call the callee, the constant must also include the cost for decommitting the code of the callee. Decoding bytecode of any size would be prohibitively expensive and so we support only callees of size up to 100000 bytes.
  • MsgValueSimulator ensures that no more than 2300 out of the stipend above gets to the callee, ensuring the reentrancy protection invariant for these functions holds.

Note, that unlike EVM any unused gas from such calls will be refunded.

The system preserves the following guarantees about .send/.transfer:

  • No more than 2300 gas will be received by the callee. Note, that a smaller, but a close amount may be passed.
  • It is not possible to do any storage changes within this stipend. This is enforced by having cold write cost more than 2300 gas. Also, cold write cost always has to be prepaid whenever executing storage writes. More on it can be read on Fee model I/O pricing.
  • Any callee with bytecode size of up to 100000 will work.

The system does not guarantee the following:

  • That callees with bytecode size larger than 100000 will work. Note, that a malicious operator can fail any call to a callee with large bytecode even if it has been decommitted before. More on it can be read on Fee model I/O pricing.

As a conclusion, using .send/.transfer should be generally avoided, but when avoiding is not possible it should be used with small callees, e.g. EOAs, which implement DefaultAccount.

KnownCodeStorage

This contract is used to store whether a certain code hash is “known”, i.e. can be used to deploy contracts. On ZKsync, the L2 stores the contract’s code hashes and not the codes themselves. Therefore, it must be part of the protocol to ensure that no contract with unknown bytecode (i.e. hash with an unknown preimage) is ever deployed.

The factory dependencies field provided by the user for each transaction contains the list of the contract’s bytecode hashes to be marked as known. We can not simply trust the operator to “know” these bytecodehashes as the operator might be malicious and hide the preimage. We ensure the availability of the bytecode in the following way:

  • If the transaction comes from L1, i.e. all its factory dependencies have already been published on L1, we can simply mark these dependencies as “known”.
  • If the transaction comes from L2, i.e. (the factory dependencies are yet to publish on L1), we make the user pays by burning ergs proportional to the bytecode’s length. After that, we send the L2→L1 log with the bytecode hash of the contract. It is the responsibility of the L1 contracts to verify that the corresponding bytecode hash has been published on L1.

It is the responsibility of the ContractDeployer system contract to deploy only those code hashes that are known.

The KnownCodesStorage contract is also responsible for ensuring that all the “known” bytecode hashes are also valid.

ContractDeployer and ImmutableSimulator

ContractDeployer is a system contract responsible for deploying contracts on ZKsync. It is better to understand how it works in the context of how the contract deployment works on ZKsync. Unlike Ethereum, where create/create2 are opcodes, on ZKsync these are implemented by the compiler via calls to the ContractDeployer system contract.

For additional security, we also distinguish the deployment of normal contracts and accounts. That’s why the main methods that will be used by the user are create, create2, createAccount, create2Account, which simulate the CREATE-like and CREATE2-like behavior for deploying normal and account contracts respectively.

Address derivation

Each rollup that supports L1→L2 communications needs to make sure that the addresses of contracts on L1 and L2 do not overlap during such communication (otherwise it would be possible that some evil proxy on L1 could mutate the state of the L2 contract). Generally, rollups solve this issue in two ways:

  • XOR/ADD some kind of constant to addresses during L1→L2 communication. That’s how rollups closer to full EVM-equivalence solve it, since it allows them to maintain the same derivation rules on L1 at the expense of contract accounts on L1 having to redeploy on L2.
  • Have different derivation rules from Ethereum. That is the path that ZKsync has chosen, mainly because since we have different bytecode than on EVM, CREATE2 address derivation would be different in practice anyway.

You can see the rules for our address derivation in getNewAddressCreate2/ getNewAddressCreate methods in the ContractDeployer.

Note, that we still add a certain constant to the addresses during L1→L2 communication in order to allow ourselves some way to support EVM bytecodes in the future.

Deployment nonce

On Ethereum, the same nonce is used for CREATE for accounts and EOA wallets. On ZKsync this is not the case, we use a separate nonce called “deploymentNonce” to track the nonces for accounts. This was done mostly for consistency with custom accounts and for having multicalls feature in the future.

General process of deployment

  • After incrementing the deployment nonce, the contract deployer must ensure that the bytecode that is being deployed is available.
  • After that, it puts the bytecode hash with a special constructing marker as code for the address of the to-be-deployed contract.
  • Then, if there is any value passed with the call, the contract deployer passes it to the deployed account and sets the msg.value for the next as equal to this value.
  • Then, it uses mimic_call for calling the constructor of the contract out of the name of the account.
  • It parses the array of immutables returned by the constructor (we’ll talk about immutables in more details later).
  • Calls ImmutableSimulator to set the immutables that are to be used for the deployed contract.

Note how it is different from the EVM approach: on EVM when the contract is deployed, it executes the initCode and returns the deployedCode. On ZKsync, contracts only have the deployed code and can set immutables as storage variables returned by the constructor.

Constructor

On Ethereum, the constructor is only part of the initCode that gets executed during the deployment of the contract and returns the deployment code of the contract. On ZKsync, there is no separation between deployed code and constructor code. The constructor is always a part of the deployment code of the contract. In order to protect it from being called, the compiler-generated contracts invoke constructor only if the isConstructor flag provided (it is only available for the system contracts).

After execution, the constructor must return an array of:

struct ImmutableData {
  uint256 index;
  bytes32 value;
}

basically denoting an array of immutables passed to the contract.

Immutables

Immutables are stored in the ImmutableSimulator system contract. The way how index of each immutable is defined is part of the compiler specification. This contract treats it simply as mapping from index to value for each particular address.

Whenever a contract needs to access a value of some immutable, they call the ImmutableSimulator.getImmutable(getCodeAddress(), index). Note that on ZKsync it is possible to get the current execution address.

Return value of the deployment methods

If the call succeeded, the address of the deployed contract is returned. If the deploy fails, the error bubbles up.

DefaultAccount

The implementation of the default account abstraction. This is the code that is used by default for all addresses that are not in kernel space and have no contract deployed on them. This address:

  • Contains minimal implementation of our account abstraction protocol. Note that it supports the built-in paymaster flows.
  • When anyone (except bootloader) calls it, it behaves in the same way as a call to an EOA, i.e. it always returns success = 1, returndatasize = 0 for calls from anyone except for the bootloader.

L1Messenger

A contract used for sending arbitrary length L2→L1 messages from ZKsync to L1. While ZKsync natively supports a rather limited number of L1→L2 logs, which can transfer only roughly 64 bytes of data a time, we allowed sending nearly-arbitrary length L2→L1 messages with the following trick:

The L1 messenger receives a message, hashes it and sends only its hash as well as the original sender via L2→L1 log. Then, it is the duty of the L1 smart contracts to make sure that the operator has provided full preimage of this hash in the commitment of the batch.

The L1Messenger is also responsible for validating the total pubdata to be sent on L1. You can read more about it on Handling pubdata.

NonceHolder

Serves as storage for nonces for our accounts. Besides making it easier for operator to order transactions (i.e. by reading the current nonces of account), it also serves a separate purpose: making sure that the pair (address, nonce) is always unique.

It provides a function validateNonceUsage which the bootloader uses to check whether the nonce has been used for a certain account or not. Bootloader enforces that the nonce is marked as non-used before validation step of the transaction and marked as used one afterwards. The contract ensures that once marked as used, the nonce can not be set back to the “unused” state.

Note that nonces do not necessarily have to be monotonic (this is needed to support more interesting applications of account abstractions, e.g. protocols that can start transactions on their own, tornado-cash like protocols, etc). That’s why there are two ways to set a certain nonce as “used”:

  • By incrementing the minNonce for the account (thus making all nonces that are lower than minNonce as used).
  • By setting some non-zero value under the nonce via setValueUnderNonce. This way, this key will be marked as used and will no longer be allowed to be used as nonce for accounts. This way it is also rather efficient, since these 32 bytes could be used to store some valuable information.

The accounts upon creation can also provide which type of nonce ordering do they want: Sequential (i.e. it should be expected that the nonces grow one by one, just like EOA) or Arbitrary, the nonces may have any values. This ordering is not enforced in any way by system contracts, but it is more of a suggestion to the operator on how it should order the transactions in the mempool.

EventWriter

A system contract responsible for emitting events.

It accepts in its 0-th extra abi data param the number of topics. In the rest of the extraAbiParams he accepts topics for the event to emit. Note, that in reality the event the first topic of the event contains the address of the account. Generally, the users should not interact with this contract directly, but only through Solidity syntax of emit-ing new events.

Compressor

One of the most expensive resource for a rollup is data availability, so in order to reduce costs for the users we compress the published pubdata in several ways:

  • We compress published bytecodes.
  • We compress state diffs.

This contract contains utility methods that are used to verify the correctness of either bytecode or state diff compression. You can read more on how we compress state diffs and bytecodes in Handling L1->L2 ops.

Pubdata Chunk Publisher

This contract is responsible for separating pubdata into chunks that each fit into a 4844 blob and calculating the hash of the preimage of said blob. If a chunk's size is less than the total number of bytes for a blob, we pad it on the right with zeroes as the circuits will require that the chunk is of exact size. The hash is then sent to L1 via system logs and used as part of the batch commitment. One important thing to note is that regardless of the size of the pubdata, we will always send the two system logs. The value of the hash of the empty blob will then be bytes32(0).

CodeOracle

It is a contract that accepts the versioned hash of a bytecode and returns the preimage of it. It is similar to the extcodecopy functionality on Ethereum.

It works the following way:

  1. It accepts a versioned hash and double checks that it is marked as “known”, i.e. the operator must know the preimage for such hash.
  2. After that, it uses the decommit opcode, which accepts the versioned hash and the number of ergs to spent, which is proportional to the length of the preimage. If the preimage has been decommitted before, the requested cost will be refunded to the user.
    Note, that the decommitment process does not only happen using the decommit opcode, but during calls to contracts. Whenever a contract is called, its code is decommitted into a memory page dedicated to contract code. We never decommit the same preimage twice, regardless of whether it was decommitted via an explicit opcode or during a call to another contract, the previous unpacked bytecode memory page will be reused. When executing decommit inside the CodeOracle contract, the user will be firstly precharged with maximal possible price and then it will be refunded in case the bytecode has been decommitted before.
  3. The decommit opcode returns to the slice of the decommitted bytecode. Note, that the returned pointer always has length of 2^21 bytes, regardless of the length of the actual bytecode. So it is the job of the CodeOracle system contract to shrink the length of the returned data.

P256Verify

This contract exerts the same behavior as the P256Verify precompile from RIP-7212. Note, that since Era has different gas schedule, we do not comply with the gas costs, but otherwise the interface is identical.

GasBoundCaller

This is not a system contract, but it will be predeployed on a fixed user space address. This contract allows users to set an upper bound of how much pubdata can a subcall take, regardless of the gas per pubdata. Learn more on how pubdata works on ZKsync see Fee model.

Note, that it is a deliberate decision not to deploy this contract in the kernel space, since it can relay calls to any contracts and so may break the assumption that all system contracts can be trusted.

Known issues to be resolved

The protocol, while conceptually complete, contains some known issues which will be resolved in the short to middle term.

  • Fee modeling is yet to be improved. See Fee model to learn more.
  • We may add some kind of default implementation for the contracts in the kernel space (i.e. if called, they wouldn’t revert but behave like an EOA).

Made with ❤️ by the ZKsync Community