In todays post, we will shed some light on how the Ethereum virtual machine (EVM) actually works under the hood. We start with an overview of the most relevant data structures and methods and explain the big picture before we look at the interpreter main loop in the next post.
The Go-Ethereum EVM – an overview
To be able to analyze in depth what really happens if a specific opcode is executed, it is helpful to take a look at both the yellow paper and the source code of the Go-Ethereum (geth) client implementing what the yellow paper describes. The code for the EVM is in this folder (I have used version 1.10.6 for the analysis, but the structure should be rather stable across releases).
Let us first try to understand the data structures involved. The diagram below shows the most important classes, attributes and methods that we need to understand.
First, there is the block context. This class is simple, it simply contains some data fields that represent attributes of the block in which the transaction is located and is used to realize opcodes like
DIFFICULTY. Similarly, the transaction context (
TxContext) holds some fields of the transaction as part of which we execute the smart contract.
Let us now turn to the
Contract class. The name of this class is a bit misleading, as it does in fact not represent a smart contract, but the execution of a smart contract, either as the result of a transaction or, more generally, of a message call. Its most important attributes (at least for our present discussion) are
- The code, i.e. the smart contract code which is actually executed
- the input provided
- the gas available for the execution
- the address at which the smart contract resides (
- the address of the caller (
It is important to understand the meanings of the various addressed contained in this structure. First, there is the
self attribute, which is the contract address, i.e. the address at which the contract itself resides. This is the address which is called Ia in the yellow paper, which is returned by the
ADDRESS opcode and which is the address holding the state manipulated by the code, for instance when we run an
SSTORE operation. This is also the address returned by the
Address() method of the contract.
Next, there is the
caller and the
callerAddress. In most cases, these two addresses are identical and represent the source of the message call, i.e. what is called the sender Is in the yellow paper. There are cases, however, namely so called delegate calls, where these address are not identical. We will come back to this in the next post.
The contract object also maintains the gas available for the execution. This field is initialized when the the execution starts and can then be reduced by calling
UseGas() to consume a certain amount of gas.
Next, there is the EVM itself. The EVM refers to a state (
StateDB), a transaction context and a block context. It also holds an attribute
abort which can be set to abort the execution, and a field
callGasTemp which is used to hold the gas value in some cases, we will see this field in action later.
Finally, there is the EVM interpreter. The interpreter is doing all the hard work of running a piece of code. For that purpose, it references a jump table which is essentially a list of opcodes together with references to corresponding Go functions that need to be run whenever this opcode is encountered. The interpreter also maintains the scope context which is a structure bundling the data that is refreshed with every execution of a smart contract – the content of the memory, the content of the stack and the contract execution, represented by a contract object.
Code execution in the yellow paper
Before we move on to understand how the code execution actually works, let us take a short look at the yellow paper, in particular sections 6, 8 and 8 describing contract execution, and try to map the data structures and functions described there to the part of the source code that we have just explored.
The central function that describes the execution of a contract code in the yellow paper is a function denoted by a capital Theta (Θ) in the yellow paper. This function has the following arguments.
- the state on which the code operates
- the sender of the message call or transaction
- the origin of the transaction (which is always an EOA and the address which signed the transaction)
- the recipient of the message call
- the address at which the code to be executed is located (this is typically the same as the recipient, but might again differ in the case of delegated calls)
- the gas available for the execution
- the gas price
- the value to be transferred as part of the message call (again, there is a subtlety for delegate calls that we postpone to the next post)
- the input data of the message call
- the depth of the call stack
- a flag that can be used to prevent the transaction from making any changes to the state (this is required for the
If you compare this list with the data structures displayed above, you will find that this is essentially the combination of the EVM attributes, the transaction context, the scope context and the contract execution object. All this data is tied together in the EVM class, so it is natural to assume that the function Θ itself is realized by a method of this class – in fact, this is the
Call method that we will look at in the next section.
The output of Θ is the updated state, the remaining gas, an object known as accrued substate that contains touched and destroyed accounts, the logs generated during the execution and the gas to be refunded.
The inner workings of Θ are described in section 8 of the yellow paper, First, the value to be transferred is deducted from the balance of the sender and added to the balance of the recipient. Then, the actual code is executed – this happens by calling another function denoted by Ξ (a capital greek xi) – again, there is an exception to this rule for pre-compiled contracts that we discuss in the next post. If the execution is not succesful, then the state is reset to the its previous value, if it is successful, the state returned by Ξ is used. The function Ξ is again not terribly to identify in the source code – it is the method
Run() of the EVM interpreter which will be the subject of the next post.
The call method of the EVM
Let us now take a closer look at the method Call() of the EVM which implements what the yellow paper calls Θ. The source code for this method can be found here. For today, I will ignore pre-compiled contracts completely which we will discuss in the next post.
The method starts by running a few checks, like making sure that we do not exceed the call depth limit (which is defined to be 1024 at the moment) or that we do not attempt to transfer more than the available balance.
The next step is to take a snapshot of the current state. Internally, Go-Ethereum uses revisions to keep track of different versions of the state, and taking a snapshot simply amounts to remembering a revision to which we can revert later if needed.
Next, we check whether the contract address already exists. This might be a bit confusing, as it does not seem to make sense to call a contract at a non-existing address, or, more precisely, at an address not yet initialized in the state DB. Note, however, that “calling” means a bit more general “sending a message to an account”, which is also done if you simply want to transfer Ether to an account. Sending a message to a non-contract account is perfectly valid, and it might even be that this account has never been used before and is therefore not part of the cached state.
The next step is to actually perform the transfer of any Ether involved in the message call, i.e. we send
value Wei from the sender to the recipient. We then get into the actual bytecode execution by performing the following steps.
- get the code associated with the contract address (i.e. the runtime bytecode) from the state
- if the length of the code is zero, return – there is nothing left to be done
- initialize a new
Contractobject that represents the current execution.
- initialize the contract code
- call the
Runmethod of the interpreter
We then collect the return value from the
Run method and a potential error code and set
contract.Gas – this represents the gas still remaining after executing the code. We then determine the final return values according to the following logic.
Rundid not result in an error, return the return value, error code and remaining gas just assembled
Runreturned a special error code indicating that the execution was reverted, reset the state to the previously created snapshot
- If the error code returned by
Runis not a reverted execution, also fall back to the snapshot but in addition, set the remaining gas to zero, i.e. such an error will consume all the available gas
Invocations of the call method
Having understood how
Call works, we are now left with two tasks. First, we need to understand how the EVM interpreters
Run method works, which will be the topic of our next post. Second, we have to learn where Call is actually invoked within the Go-Ethereum source code.
Not quite surprisingly, this happens at several points (ignoring tests). First, in a previous post, I have already shown you that the EVM’s
Call method is invoked whenever a transaction is processed as part of a state transition. This call happens here, and the parameters are as we would expect – the the caller is the sender of the transaction, the contract address is the recipient, and the input data, gas and value are taken from the StateTransition object. The remaining gas returned is again stored in the state transition object and used as a basis for computing the gas refunded to the sender. Note that this entry point is (via the ApplyMessage function) also used by the JSON API when the eth_call method or the eth_estimateGas method are requested.
However, this is not the only point in the code where we find a reference to the Call method. A second point is actually the EVM interpreter itself, more precisely the function
opCall in instructions.go. The background of this is at in addition to a call due to a transaction, i.e. a call initiated by an EOA, we can of course also call a smart contract from another smart contract using the
CALL opcode. This opcode is implemented by the opCall function, and it turns out that it uses the EVM
Call method as well. In this case, the parameters are taken from the stack respectively from the memory location referenced by the stack items.
- the top level item on the stack is the gas that is made available (as we will see in the next post, this is not exactly true, but almost)
- the next item on the stack is the target address
- the third item is the value to be transferred
- the next two items determine offset and length of the input data which is taken from memory
- the last two items similarly determine offset and length of the return data area in memor
It is interesting to compare the handling of the returned error code. First, it is used to determine the status code that is returned. If there was an error, the status code is set to zero, otherwise it is set to one. Then, the returned data is stored in memory in case the execution was successful or explicitly reverted, for other errors no return data is passed. Finally, the unused gas is again returned to the currently executing contract.
This has an important consequence – there is no automatic propagation of errors in the EVM! If a contract A calls a contract B, and contract B reverts (either explicitly or due another error), then the call will technically go through, and contract A does not automatically revert as well. Instead, you will have to explicitly check the status code that the
CALL opcode puts on the stack and handle the case that contract B fails somehow. Not doing this will make your contract vulnerable to the “King of the Ether” problem that we have discussed in my previous post on contract security.
Finally, scanning the code will reveal that there is a third point where the
Call method is invoked – the EVM utility that allows you to run a specified bytecode outside of the Go-Ethereum client from the command line. It is fun to play with this, here is an example for its usage to invoke the
sayHello method of our sample contract (again, assuming that you have cloned my repository for this series and are working in the root directory of the repository). Note that in order to install the evm utility, you will have to download the full geth archive, containing all the tools, and make the evm executable available in a folder in your path.
VERSION=$(python3 -c 'import solcx ; print(solcx.get_solc_version())') DIR=$(python3 -c 'import solcx ; print(solcx.get_solcx_install_folder())') SOLC="$DIR/solc-v$VERSION" CODE=$($SOLC contracts/Hello.sol --bin-runtime | grep "6080") evm \ --code $CODE\ --input 0xef5fb05b \ --debug run
This little experiment completes this post. In the next post, we will try to fill up the missing parts that we have not yet studied – how the code execution, i.e. the
Run method, actually works, what pre-compiled contracts are and how gas is handled during the execution. We will also take a closer look at contract-to-contract calls and its variations.