Dr. David J. Pearce

Digging into the EVM Object Format (EOF)


The EVM Object Format (EOF) introduces a structured container format for EVM bytecode. The EOF proposal is spread over several EIPs (see the “Mega EOF Endgame” for an overview). My goal here is to provide a high level overview and, in particular, to clarify what problems it is trying to solve.

The EOF proposal was recently presented at the All Core Devs Execution Layer meeting where concerns were raised around its complexity and whether or not it could be ready for Prague (see the slides here). The following comments made during the meeting captures the sentiment:

(1:18:55) “I know the Ipsilon team and Danno have spent a lot of time working on this and it’s quite harsh to say that after all this time we might not be shipping it but I think it’s even worse to say, ‘Let’s see,’ and then in like we push it another two years and then we say, ‘Oh, we are not going to ship it after all.’ and so I think we should make a decision at Devconnect whether this is something that we want”

So, what is the EOF and why is it important? That’s what I want to dig into here. Roughly speaking, the EOF introduces a versioned container format for EVM bytecode which offers a mechanism for managing breaking changes to the EVM.

Overview

The Ethereum Virtual Machine (EVM) was specified as part of the Yellow Paper and, more recently, through the Execution Layer Specification. The EVM has evolved in many directions. For example, new instructions have been added (e.g. SHL, CREATE2,PUSH0, etc), gas costs have been tweaked, a code size limit has been imposed, instructions have been supplanted and even (hopefully) deprecated. Allowing the EVM to evolve seems important for the future of Ethereum! And yet, the nature of EVM bytecode makes this unnecessarily difficult.

I’m going to give some examples to backup this claim that the current nature of the EVM hinders its evolution. These examples are not mine: they are actually part of the EOF proposal. I’m calling them examples because that’s what I think they are: good examples where evolving the EVM is currently difficult. There are many other examples as well. The challenges faced with deprecating SELFDESTRUCT provide another example, as do attempts to manage Address Space Expansion.

Immediate Operands

Instructions with immediate operands cannot easily be added to the EVM. Whilst the exact reasons for this are somewhat involved (see Appendix below), the more important question is: why do we want instructions with immediate operands? Whilst this is hard to pin down, two cases are illustrative:

The EOF addresses this in several ways: firstly, it enables instructions with immediate operands (EIP3670); secondly, it replaces dynamic branching instructions (e.g. JUMP) with static branching instructions (e.g. RJUMP) (EIP-4200); thirdly, it introduces instructions for calling subroutines (CALLF, JUMPF, RETF) (EIP-4750,-5450,-6206); finally, it introduces DUPN and SWAPN (EIP-663).

Gas Observability

On several occasions the gas cost of an existing operation has been tweaked for some reason. Sometimes costs are increased, whilst other times they are decreased. Generally speaking, reducing costs is not considered a breaking change (even though it clearly can be in some cases). However, changes which increase costs are normally considered to be breaking changes.

Examples where costs decreased include:

Examples where costs increased include:

What seems clear from this is that: (1) changes to the gas schedule will be ongoing (e.g. as CPU/GPU characteristics change or algorithmic performance increases, etc); (2) such changes will continue to cause problems unless some new mechanism for managing them is introduced.

To address these concerns, the EOF proposal includes a goal of removing gas observability. What does this mean? Well, consider a hypothetical contract containing something like this:

    ...
    GAS
    PUSH 0xffffffff5795
    EQ
    ...

Since the equality check (EQ) depends upon the exact gas available at a specific point, changes to the gas schedule (e.g. increasing the costs of SSTORE) could impact the outcome. A complete contract illustrating this is here which, on Shanghai, executes to STOP. In contrast, on Istanbul it executes to INVALID. Yes, this is a very artificial example! Yes, people should never write code like this! But, there are some situations where things like this arise. For example, Solidity uses a default stipend of 2300 gas for transfer() calls. Furthermore, stipends sometimes are deducted manually with code like staticcall(gas()-2000,...).

Removing gas observability would allow the gas schedule to change more easily with minimal impact. The challenges faced with SSTORE are a key motivator here. However, to actually do this means dropping instructions that expose gas costs. Specifically: GAS, CALL, CALLCODE, DELEGATECALL, STATICCALL, CREATE, and CREATE2. This would be a significant breaking change which would be hard to do without EOF (or something like it). The EOF handles this by: firstly, replacing the first five instructions above with: CALL2, STATICCALL2, DELEGATECALL2 (EIP-7069); secondly, by replacing CREATE / CREATE2 with CREATE3 / CREATE4 (EIP-TBC).

Code Observability

This is an ambitious part of the EOF proposal but is also something Vitalik makes a strong case for. The goal is to allow on-chain contracts to be safely and automatically upgraded (e.g. to exploit new instructions). For example, contracts using PUSH1 0x0 could now be upgraded to use PUSH0. That would be very neat!

As with gas observability, the key challenge here lies with instructions that can observe the bytecode of a contract. That is, if the logic of a contract depends on the exact bytecode of another (or itself), then changing that bytecode (i.e. through automated upgrading) could potentially alter its execution. Instructions which can observe a contract’s bytecode include:

Additionally, CREATE2 poses another potential hazard since the new contract address is (partly) determined by the bytecode of the contract created. Thus, if our automatic upgrading system also upgrades as-yet-undeployed initcode, then this would in turn alter the final contract address. Any existing contract which relied on an exact address (for whatever reason) would then break.

Eliminating code observability requires, at a minimum, that the above instructions are replaced with alternatives. Again, this would be a fairly significant breaking change. The EOF addresses this in several ways: firstly, a specific data section is introduced (EIP-3540); secondly, operations (DATALOAD, DATASIZE, etc) for accessing it are introduced (EIP-7480); finally, CREATE / CREATE2 are replaced with CREATE3 / CREATE4 / RETURNCONTRACT (EIP-TBC).

Evolution

We should also consider how evolution under EOF might look. We can assume the legacy EVM will be needed to execute existing on-chain contracts. Thus, new EOF contracts and legacy EVM contracts will co-exist. To further evolve the EVM, new EOF versions (e.g. EOFv2) will be released and, thus, older EOF contracts (e.g. EOFv1) will coexist with newer ones. The rate of new EOF versions, however, should be fairly low (e.g. a new version every 5-10 years). Remember that a new EOF version is only necessary for breaking changes. Thus, non-breaking changes can be automatically added to the current EOF version.

Naturally, all this raises questions around the burden of maintaining multiple EVM variants (Vitalik talks specifically about this). The cost of maintaining a particular EOF version presumably depends on its nature. For example, a version which only changes gas costs should be fairly easy to maintain (i.e. assuming the gas schedule is a configuration parameter). Likewise, a version which adds or removes new instructions may be relatively low cost (i.e. by configuring which instructions are enabled / disabled in a particular version). Finally, when code observability is abolished, it might even be possible to deprecate EOF versions (i.e. by automatically upgrading contracts for a given version to a more recent version).

Conclusion

Evolving the legacy EVM is proving to be increasingly difficult as time goes on. There is talk of the EVM ossifying to the point where its evolution in certain directions stalls. Of course, non-breaking changes will always continue (e.g. adding new instructions, new precompiles, etc). Its the breaking changes that become harder and harder (e.g. removing an instruction, increasing gas costs, etc). The real benefit of EOF comes from versioning: we can have multiple bytecode versions “in flight” at the same time. There are concerns this could lead to a glut of different versions on chain. But, the fact is, we are already living in this world. Whenever a change is made which could break existing code (e.g. changing gas costs, deprecating instructions, imposing new limits, etc), we effectively create a new version of bytecode. The only difference is how we manage it: either in an ad-hoc fashion (as is done now); or, with a more structured mechanism (as with EOF).

Acknowledgements. Thanks to Danno Ferrin for pointing out some missing bits of the history in an earlier draft of this article!

Appendix — Immediate Operands

As discussed above, we cannot easily add instructions with immediate operands to the EVM. The purpose here is just to clarify what the problem is. The relevant points for our discussion are:

  1. Bytecode is Unstructured. An EVM bytecode program is just a collection of bytes with (almost) no other structured imposed. Execution begins with the first instruction at offset 0. Some bytes represent instructions, some bytes can represent data and some bytes can simply be “dead code”. Determining whether or not a given byte is part of an instruction, is unused or represents data is not at all straightforward. The solidity compiler, for example, appends metadata to the end of a contract’s bytecode.

  2. Mostly No Immediates. Unlike many other bytecode formats (e.g. JVM Bytecode), EVM instructions generally do not take immediate operands and, instead, operands are supplied on the stack. The only exception here are the PUSHXX instructions which allow constants to be loaded on the stack.

  3. Jump Destinations. Every branch in an EVM bytecode program must land at a JUMPDEST instruction (opcode 0x5b), otherwise a runtime exception is raised. Furthermore, branches must land on instruction boundaries (i.e. not on bytes within the immediate operand of another instruction).

Let’s consider a simple (but currently valid) sequence of EVM bytecode: 0x600456e05b00. This corresponds to the following assembly:

   push1 lab
   jump
   db 0xe0
lab:
   jumpdest
   stop

Observe that the raw data byte 0xe0 is mixed in with other instructions (which is permitted under the current EVM specification). The EVM simply views this as an INVALID instruction which has no operands. Specifically, as defined in Section 9.4.3 (“Jump Destination Validity”) of the Yellow Paper:

Illustrating Section 9.4.3 of the Yellow Paper which clarifies jump destination validity.

Now, let us consider EIP-4200 which adds a new instruction RJUMP taking a two-byte immediate operand. Funnily enough, the opcode of RJUMP is 0xe0. So, what’s the problem? Well, our bytecode sequence now looks like this:

   push1 0x4
   jump
   rjump 0x5b00

Observe how the JUMPDEST and STOP bytecodes are now bytes within the immediate operand of the RJUMP instruction! This has completely changed the meaning of our original program, and its not clear how to fix this. We could try to make an exception for RJUMP and allow legacy branches into its immediate opcode bytes (i.e. so the above retains its original meaning). But, this makes disassembling bytecode contracts much harder! Furthermore, we can now write bytecode programs which do this on purpose. At that point, a given byte can be interpreted as a different instruction depending on the PC of the executing EVM. That is not ideal!

A final point worth noting is that we can still introduce instructions such as RJUMP when we know they don’t actually break anything. Essentially, we could analyse mainnet to check whether any bad situations (such as above) actually exist. If not, then we’re good to go! Or, if they do exist, we could repeat the analysis for a different opcode value (e.g. 0xe1 in this case). This kind of approach has been used before (e.g. for the 0xEF marker opcode), however its unclear how well it would work in this case. Furthermore, as more contracts are deployed and as more instructions are added to the EVM, it gets harder and harder to successfully apply this technique. Thus, even if this could be used for adding RJUMP, its not a long term solution.