Bootloader
In standard Ethereum clients, the process of executing blocks involves selecting and validating transactions one by one, executing them, and then applying the resulting state changes to the blockchain. This method is suitable for Ethereum's architecture but would be inefficient for ZKsync due to the need for running a complete proving workflow for each transaction.
Why ZKsync Uses a Bootloader
To address this inefficiency, ZKsync employs a bootloader. This component allows for processing not just one transaction at a time but an entire batch of transactions as a single large operation. This approach is similar to how an EntryPoint works under EIP4337, which also manages transactions in arrays to support the Account Abstraction protocol.
You can learn more about Batches & L2 blocks on ZKsync.
Operational Mechanism of the Bootloader
The bootloader's code is not stored on Layer 2 (L2) but its hash is stored on Layer 1 (L1) and can only be modified through a system upgrade.
This setup ensures that the bootloader functions as a kind of "formal" address that provides context and identity to this
, msg.sender
,
and similar references during transaction processing.
If someone interacts with this address, for instance, to handle transaction fees, it triggers the EmptyContract’s code.
System contracts
While most of the primitive EVM opcodes can be supported out of the box
(i.e. zero-value calls, addition/multiplication/memory/storage management, etc),
some of the opcodes are not supported by the VM by default
and they are implemented via “system contracts” —
these contracts are located in a special kernel space, i.e.
in the address space in range [0..2^16-1]
,
and they have some special privileges, which users’ contracts don’t have.
These contracts are pre-deployed at the genesis
and updating their code can be done only via system upgrade, managed from L1.
The use of each system contract will be explained down below.
zkEVM internals
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.
Registers and memory management
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 un-allowed 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.
Memory areas in zkEVM
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.
Managing returndata & 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 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.
Note, that the rule above is implemented by the principle
"it is not possible to return a slice of data with memory page id lower than the memory page id of the current heap",
since a memory page with smaller id could only be created before the call.
That's why a user contract can usually safely return a slice of previously returned returndata
(since it is guaranteed to have a higher memory page id).
However, system contracts have an exemption from the rule above.
It is needed in particular to the correct functionality of the CodeOracle
system contract.
You can read more about CodeOracle in System contracts.
So the rule of thumb is that returndata from CodeOracle
should never be passed along.
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.
Returndata & precompiles
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.
ZKsync specific opcodes
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
Only for kernel space
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 theecrecover
system contract, it performs the ecrecover operation - If it is the address of thesha256
/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.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.decommit
will return a pointer to a slice with the corresponding bytecode hash preimage. If this bytecode has been unpacked before, the memory page where it was unpacked will be reused. If it has never been unpacked before, it will be unpacked into the current heap.
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.
Generally accessible
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.
Flags for calls
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.
onlySystemCall
modifier
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).
Simulations via our compiler
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 on ZKsync Era Extension Simulation (call).
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.
Notes on security
To prevent unintended substitution, the compiler requires
--system-mode
flag to be passed during compilation for the above substitutions to work.
Bytecode hashes
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
).
Bytecode validity
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.
Account abstraction
One of the other important features of ZKsync is the support of account abstraction. It is highly recommended to read the documentation on our AA protocol.
Account versioning
Each account can also specify which version of the account abstraction protocol they support. This is needed to allow breaking changes of the protocol in the future.
Currently, two versions are supported:
None
(i.e. it is a simple contract and it should never be used as from
field of a transaction),
and Version1
.
Nonce ordering
Accounts can also signal to the operator which nonce ordering it should expect from these accounts: Sequential
or Arbitrary
.
Sequential
means that the nonces should be ordered in the same way as in EOAs.
This means, that, for instance, the operator will always wait for a transaction with nonce X
before processing a transaction with nonce X+1
.
Arbitrary
means that the nonces can be ordered in arbitrary order.
It is supported by the server right now, i.e. if there is a contract with arbitrary nonce ordering,
its transactions will likely either be rejected or get stuck in the mempool due to nonce mismatch.
Note, that this is not enforced by system contracts in any way. Some sanity checks may be present, but the accounts are allowed to do however they like. It is more of a suggestion to the operator on how to manage the mempool.
Returned magic value
Now, both accounts and paymasters are required to return a certain magic value upon validation. This magic value will be enforced to be correct on the mainnet, but will be ignored during fee estimation. Unlike Ethereum, the signature verification + fee charging/nonce increment are not included as part of the intrinsic costs of the transaction. These are paid as part of the execution and so they need to be estimated as part of the estimation for the transaction’s costs.
Generally, the accounts are recommended to perform as many operations as during normal validation, but only return the invalid magic in the end of the validation. This will allow to correctly (or at least as correctly as possible) estimate the price for the validation of the account.
Bootloader
Bootloader is the program that accepts an array of transactions and executes the entire ZKsync batch. This section will expand on its invariants and methods.
Playground bootloader vs proved bootloader
For convenience, we use the same implementation of the bootloader both in the mainnet batches and for emulating ethCalls or other testing activities. Only proved bootloader is ever used for batch-building and thus this document describes only it.
Batch Start
It is enforced by the ZKPs, that the state of the bootloader is equivalent to the state of a contract transaction with empty calldata. The only difference is that it starts with all the possible memory pre-allocated (to avoid costs for memory expansion).
For additional efficiency (and our convenience), the bootloader receives its parameters inside its memory. This is the only point of non-determinism: the bootloader starts with its memory pre-filled with any data the operator wants. That’s why it is responsible for validating the correctness of it and it should never rely on the initial contents of the memory to be correct & valid.
For instance, for each transaction, we check that it is properly ABI-encoded and that the transactions go exactly one after another. We also ensure that transactions do not exceed the limits of the memory space allowed for transactions.
Transaction Types and Validation
While the main transaction format is the internal
Transaction
format,
it is a struct that is used to represent various kinds of transactions types.
It contains a lot of reserved
fields
that could be used depending in the future types of transactions
without need for AA to change the interfaces of their contracts.
The exact type of the transaction is marked by the txType
field of the transaction type. There are 6 types currently
supported:
txType
: 0. It means that this transaction is of legacy transaction type. The following restrictions are enforced:maxFeePerErgs=getMaxPriorityFeePerErg
since it is pre-EIP-1559 tx type.reserved1..reserved4
as well aspaymaster
are 0.paymasterInput
is zero.- Note, that unlike type 1 and type 2 transactions,
reserved0
field can be set to a non-zero value, denoting that this legacy transaction is EIP-155-compatible and its RLP encoding (as well as signature) should contain thechainId
of the system. txType
: 1. It means that the transaction is of type 1, i.e. transactions access list. ZKsync does not support access lists in any way, so no benefits of fulfilling this list will be provided. The access list is assumed to be empty. The same restrictions as for type 0 are enforced, but alsoreserved0
must be 0.txType
: 2. It is EIP1559 transactions. The same restrictions as for type 1 apply, but nowmaxFeePerErgs
may not be equal togetMaxPriorityFeePerErg
.txType
: 113. It is ZKsync transaction type. This transaction type is intended for AA support. The only restriction that applies to this transaction type: fieldsreserved0..reserved4
must be equal to 0.txType
: 254. It is a transaction type that is used for upgrading the L2 system. This is the only type of transaction is allowed to start a transaction out of the name of the contracts in kernel space.txType
: 255. It is a transaction that comes from L1. There are almost no restrictions explicitly imposed upon this type of transaction, since the bootloader at the end of its execution sends the rolling hash of the executed priority transactions. The L1 contract ensures that the hash did indeed match the hashes of the priority transactions on L1.
You can also read more on L1->L2 transactions and upgrade transactions.
However, as already stated, the bootloader’s memory is not deterministic and the operator is free to put anything it wants there. For all of the transaction types above the restrictions are imposed in the following (method), which is called before starting processing the transaction.
Bootloader Memory Structure
The bootloader expects the following structure of the memory (here by word we denote 32-bytes, the same machine word as on EVM):
Batch Information
The first 8 words are reserved for the batch information provided by the operator.
0
— The address of the operator (the beneficiary of the transactions).1
— The hash of the previous batch. Its validation will be explained later on.2
— The timestamp of the current batch. Its validation will be explained later on.3
— The number of the new batch.4
— The L1 gas price provided by the operator.5
— The “fair” price for L2 gas, i.e. the price below which thebaseFee
of the batch should not fall. For now, it is provided by the operator, but it in the future it may become hardcoded.6
— The base fee for the batch that is expected by the operator. While the base fee is deterministic, it is still provided to the bootloader just to make sure that the data that the operator has coincides with the data provided by the bootloader.7
— Reserved word. Unused on proved batch.
The batch information slots are used at the beginning of the batch. Once read, these slots can be used for temporary data.
Temporary Data Descriptions
(This temporary data are used for debug and transaction processing purposes.)
[8..39]
– reserved slots for debugging purposes[40..72]
– slots for holding the paymaster context data for the current transaction. The role of the paymaster context is similar to the EIP4337’s one. You can read more about it in the account abstraction documentation.[73..74]
– slots for signed and explorer transaction hash of the currently processed L2 transaction.[75..110]
– 36 slots for the calldata for the KnownCodesContract call.[111..1134]
– 1024 slots for the refunds for the transactions.[1135..2158]
– 1024 slots for the overhead for batch for the transactions. This overhead is suggested by the operator, i.e. the bootloader will still double-check that the operator does not overcharge the user.[2159..3182]
– slots for the “trusted” gas limits by the operator. The user’s transaction will have at its disposalmin(MAX_TX_GAS(), trustedGasLimit)
, whereMAX_TX_GAS
is a constant guaranteed by the system. Currently, it is equal to 80 million gas. In the future, this feature will be removed.[3183..7282]
– slots for storing L2 block info for each transaction. You can read more on the difference L2 blocks and batches.[7283..40050]
– slots used for compressed bytecodes each in the following format:- 32 bytecode hash
- 32 zeroes (but then it will be modified by the bootloader to contain 28 zeroes and then the 4-byte selector of the
publishCompressedBytecode
function of theBytecodeCompressor
) - The calldata to the bytecode compressor (without the selector).
[40051..40052]
– slots where the hash and the number of current priority ops is stored. More on it in the priority operations section on Handling L1->L2 ops on ZKsync.
L1Messenger Pubdata
[40053..248052]
– slots where the final batch pubdata is supplied to be verified by the L1Messenger. More on how the L1Messenger system contracts handles the pubdata can be read on L2->L1 Communication before Boojum.
This [40053..248052]
space is used for the calldata to the L1Messenger’s publishPubdataAndClearState
function, which
accepts:
- List of the user L2→L1 logs,
- Published L2→L1 messages
- Bytecodes
- List of full state diff entries, which describe how each storage slot has changed as well as compressed state diffs.
This method will then check the correctness of the provided data and publish the hash of the correct pubdata to L1.
Note, that while the realistic number of pubdata that can be published in a batch is 120kb, the size of the calldata to L1Messenger may be significantly larger due to the fact that this method also accepts the original, uncompressed state diff entries.
These will not be published to L1, but will be used to verify the correctness of the compression. In a worst-case scenario, the number of bytes that may be needed for this scratch space is if all the pubdata consists of repeated writes (i.e. we’ll need only 4 bytes to include key) that turn into 0 (i.e. they’ll need only 1 byte to describe it).
However, each of these writes in the uncompressed form will be represented as 272 byte state diff entry and so we get the number
of diffs is 120k / 5 = 24k
. This means that they will have accommodate 24k * 272 = 6528000
bytes of calldata for the
uncompressed state diffs. Adding 120k on top leaves us with roughly 6650000
bytes needed for calldata. 207813
slots
are needed to accommodate this amount of data. We round up to 208000
slots to give space for constant-size factors for
ABI-encoding, like offsets, lengths, etc.
In theory, much more calldata could be used.
For instance, if one byte is used for enum
index.
It is the responsibility of the operator to ensure that it can form the correct calldata for the L1Messenger.
Transaction's meta descriptions
[586653..606652]
words — 20000 slots for 10000 transaction’s meta descriptions (their structure is explained below).
For internal reasons related to possible future integrations of zero-knowledge proofs about some of the contents of the bootloader’s memory, the array of the transactions is not passed as the ABI-encoding of the array of transactions, but:
- We have a constant maximum number of transactions. At the time of this writing, this number is 10000.
- Then, we have 10000 transaction descriptions, each ABI encoded as the following struct:
struct BootloaderTxDescription {
// The offset by which the ABI-encoded transaction's data is stored
uint256 txDataOffset;
// Auxilary data on the transaction's execution. In our internal versions
// of the bootloader it may have some special meaning, but for the
// bootloader used on the mainnet it has only one meaning: whether to execute
// the transaction. If 0, no more transactions should be executed. If 1, then
// we should execute this transaction and possibly try to execute the next one.
uint256 txExecutionMeta;
}
Reserved slots for the calldata for the paymaster’s postOp operation
[606653..606692]
words — 40 slots which could be used for encoding the calls for postOp methods of the paymaster.
To avoid additional copying of transactions for calls for the account abstraction,
we reserve some of the slots which could be then used to form the calldata
for the postOp
call for the account abstraction without having to copy the entire transaction’s data.
The actual transaction’s descriptions
[606693..927496]
Starting from the 487312 word, the actual descriptions of the transactions start. (The struct can be found by this link). The bootloader enforces that:
- They are correctly ABI encoded representations of the struct above.
- They are located without any gaps in memory (the first transaction starts at word 653 and each transaction goes right after the next one).
- The contents of the currently processed transaction (and the ones that will be processed later on are untouched).
Note, that we do allow overriding data from the already processed transactions as it helps to preserve efficiency
by not having to copy the contents of the
Transaction
each time we need to encode a call to the account.
VM Hook Pointers
[927497..927499]
These are memory slots that are used purely for debugging purposes (when the VM writes to these slots, the server side can catch these calls and give important insight information for debugging issues).
Result Pointer
[927500..937499]
These are memory slots that are used to track the success status of a transaction.
If the transaction with number i
succeeded, the slot 937499 - 10000 + i
will be marked as 1 and 0 otherwise.
Bootloader execution flow
- At the start of the batch it reads the initial batch information and sends the information about the current batch to the SystemContext system contract.
- It goes through each of
transaction’s descriptions
and checks whether the
execute
field is set. If not, it ends processing of the transactions and ends execution of the batch. If the execute field is non-zero, the transaction will be executed and it goes to step 3. - Based on the transaction’s type it decides whether the transaction is an L1 or L2 transaction and processes them accordingly. More on the processing of the L1 transactions can be read in the L1->L2 transactions section. More on L2 transactions can be read in the L2 transactions section.
L2 Transactions
On ZKsync, every address is a contract.
Users can start transactions from their EOA accounts, because every address that
does not have any contract deployed on it implicitly contains the code defined in the
DefaultAccount.sol
file.
Whenever anyone calls a contract that is not in kernel space
(i.e. the address is ≥ 2^16) and does not have any
contract code deployed on it, the code for DefaultAccount
will be used as the contract’s code.
Note, that if you call an account that is in kernel space and does not have any code deployed there, right now, the transaction will revert.
We process the L2 transactions according to our account abstraction protocol: https://code.zksync.io/tutorials/native-aa-multisig#prerequisites.
- We deduct the transaction’s upfront payment for the overhead for the block’s processing. You can read more on how that works in the fee model description.
- Then we calculate the gasPrice for these transactions according to the EIP1559 rules.
- We
conduct the validation step
of the AA protocol:
- We calculate the hash of the transaction.
- If enough gas has been provided, we near_call the validation function in the bootloader.
It sets the tx.origin to the address of the bootloader, sets the
ergsPrice
. It also marks the factory dependencies provided by the transaction as marked and then invokes the validation method of the account and verifies the returned magic. - Calls the accounts and, if needed, the paymaster to receive the payment for the transaction.
Note, that accounts may not use
block.baseFee
context variable, so they have no way to know what exact sum to pay. That’s why the accounts typically firstly sendtx.maxFeePerErg * tx.ergsLimit
and the bootloader refunds for any excess funds sent.
- We perform the execution of the transaction.
Note, that if the sender is an EOA,
tx.origin
is set equal to thefrom
the value of the transaction. During the execution of the transaction, the publishing of the compressed bytecodes happens: for each factory dependency if it has not been published yet and its hash is currently pointed to in the compressed bytecodes area of the bootloader, a call to the bytecode compressor is done. Also, at the end the call to theKnownCodeStorage
is done to ensure all the bytecodes have indeed been published. - We
refund
the user for any excess funds he spent on the transaction:
- Firstly, the
postTransaction
operation is called to the paymaster. - The bootloader asks the operator to provide a refund. During the first VM run without proofs the provide directly inserts the refunds in the memory of the bootloader. During the run for the proved batches, the operator already knows what which values have to be inserted there. You can read more about it in Fee model
- The bootloader refunds the user.
- Firstly, the
- We notify the operator about the refund that was granted to the user. It will be used for the correct displaying of gasUsed for the transaction in explorer.
L1->L2 Transactions
L1->L2 transactions are transactions that were initiated on L1.
We assume that from
has already authorized the L1→L2 transactions.
It also has its L1 pubdata price as well as ergsPrice set on L1.
Most of the steps from the execution of L2 transactions are omitted
and we set tx.origin
to the from
, and ergsPrice
to the one provided by transaction.
After that, we use mimicCall
to provide the operation itself from the name of the sender account.
Note, that for L1→L2 transactions, reserved0
field denotes the amount of ETH that should be minted on L2 as a result
of this transaction. reserved1
is the refund receiver address, i.e. the address that would receive the refund for the
transaction as well as the msg.value if the transaction fails.
There are two kinds of L1->L2 transactions:
- Priority operations, initiated by users (they have type
255
). - Upgrade transactions, that can be initiated during system upgrade (they have type
254
).
Read more about differences between the different L1->L2 transaction types.
End of the batch
At the end of the batch we set tx.origin
and tx.gasprice
context variables to zero to both save L1 gas on calldata
and to
send the entire Bootloader balance to the operator. This effectively sends all the fees collected by the Bootloader to the operator.
Also, we
set
the fictive L2 block’s data.
Then, we call the system context to ensure that it publishes the timestamp of the L2 block as well as L1 batch.
We also reset the txNumberInBlock
counter to avoid its state diffs from being published on L1.
You can read more about block processing on ZKsync.
After that, we publish the hash as well as the number of priority operations in this batch. Handling L1->L2 ops on ZKsync.
Then, we call the L1Messenger system contract for it to compose the pubdata to be published on L1. You can read more on Handling pubdata.