EIPs/EIPS/eip-2315.md
Greg Colvin 6b476e296a
Update eip-2315.md (#3767)
* Update eip-2315.md

* Update eip-2315.md

* Update eip-2315.md

* Update EIPS/eip-2315.md

Co-authored-by: Micah Zoltu <micah@zoltu.net>

* Update EIPS/eip-2315.md

Co-authored-by: Micah Zoltu <micah@zoltu.net>

* Update EIPS/eip-2315.md

Co-authored-by: Micah Zoltu <micah@zoltu.net>

* Update eip-2315.md

* Update eip-2315.md

* Update eip-2315.md

* Update eip-2315.md

* Update eip-2315.md

* Update eip-2315.md

* Update eip-2315.md

Co-authored-by: Micah Zoltu <micah@zoltu.net>
2021-08-28 18:41:12 +00:00

21 KiB
Raw Blame History

eip title status type category author discussions-to created
2315 Simple Subroutines for the EVM Draft Standards Track Core Greg Colvin <greg@colvin.org>, Martin Holst Swende (@holiman) https://ethereum-magicians.org/t/eip-2315-simple-subroutines-for-the-evm/3941 2019-10-17

Abstract

This proposal introduces three opcodes to support subroutines: BEGINSUB, JUMPSUB and RETURNSUB. It deprecates JUMP and JUMPI.

This change supports substantial reductions in the gas costs of using subroutines.

This change also allows for the validation of some important safety properties, including

  • valid jump destinations,
  • valid instructions,
  • no stack underflows, and
  • no stack overflows without recursion

Valid contracts will not halt with an exception unless they run out of gas or overflow stack during a recursion.

Code is validated at contract creation time not runtime by the provided algorithm. This is a one-pass algorithm, linear in the size of the bytecode, so as not to enable DoS attacks.

Motivation

The EVM does not provide subroutines as a primitive. Instead, calls can be synthesized by fetching and pushing the current program counter on the data stack and jumping to the subroutine address; returns can be synthesized by getting the return address to the top of the stack and jumping back to it. These conventions are more costly than necessary, and impede static analysis of EVM code, especially rapid validation of some important safety properties.

Facilities to directly support subroutines are provided by all but one of the real and virtual machines programmed by the lead author, including the Burroughs 5000, CDC 7600, IBM 360, DEC PDP 11 and VAX, Motorola 68000, a few generations of Intel silicon, Sun SPARC, UCSD p-Machine, Sun JVM, Wasm, and the sole exception the EVM. In whatever form, these operations provide for

  • capturing the current context of execution,
  • transferring control to a new context, and
  • returning to the original context
    • after possible further transfers of control
    • to some arbitrary depth.

The concept goes back to Turing, 1946:

We also wish to be able to arrange for the splitting up of operations into subsidiary operations. This should be done in such a way that once we have written down how an operation is done we can use it as a subsidiary to any other operation. ... When we wish to start on a subsidiary operation we need only make a note of where we left off the major operation and then apply the first instruction of the subsidiary. When the subsidiary is over we look up the note and continue with the major operation. Each subsidiary operation can end with instructions for this recovery of the note. How is the burying and disinterring of the note to be done? There are of course many ways. One is to keep a list of these notes in one or more standard size delay lines, (1024) with the most recent last. The position of the most recent of these will be kept in a fixed TS, and this reference will be modified every time a subsidiary is started or finished...

We propose to use Turing's simple mechanism to implement subroutines, as specified below. Note that this specification is entirely semantic. It constrains only stack usage and control flow and imposes no syntax on code beyond being a sequence of bytes to be executed.

Specification

We introduce one more stack into the EVM in addition to the existing data stack, which we call the return stack. The return stack is limited to 1024 items. This stack supports three new instructions for subroutines.

BEGINSUB( 0x5c)

Marks an entry point to a subroutine. Execution of a BEGINSUB is a no-op. The cost is jumpdest.

JUMPSUB (0x5d) location

Transfers control to a subroutine.

  1. Decode the location from the immediate data. The data is encoded as three bytes, MSB-first.
  2. If the opcode at location is not a BEGINSUB abort.
  3. If the return stack already has 1024 items abort.
  4. Push the current PC + 1 to the return stack.
  5. Set PC to location.

The cost is low.

  • pops one item off the data stack
  • pushes one item on the return stack

RETURNSUB (0x5e)

Returns control to the caller of a subroutine.

  1. If the return stack is empty abort.
  2. Pop PC off the return stack.

The cost is verylow.

  • pops one item off the return stack

Notes:

  • If a resulting PC to be executed is beyond the last instruction then the opcode is implicitly a STOP, which is not an error.
  • Values popped off the return stack do not need to be validated, since they are alterable only by JUMPSUB and RETURNSUB.
  • The description above lays out the semantics of this feature in terms of a return stack. But the actual state of the return stack is not observable by EVM code or consensus-critical to the protocol. (For example, a node implementor may code JUMPSUB to unobservably push PC on the return stack rather than PC + 1, which is allowed so long as RETURNSUB observably returns control to the PC + 1 location.)
  • The return stack is the functional equivalent of Turing's "delay line".

Dependencies

We need EIP-3540: EVM Object Format (EOF) to allow for immediate arguments without special encoding.

We need EOF Static Relative Jumps or the equivalent to define and validate the rules for contract safety. These jumps RJUMP offset and RJUMPI offset transfer control to a location in the bytecode at a fixed offset relative to the current PC.

Rationale

We modeled this design on Moore's 1970 Forth virtual machine. It is a two-stack design the data stack is supplemented with a return stack to support jumping into and returning from subroutines, as specified above. The return address (Turing's "note") is pushed onto the return stack (Turing's "delay line") when calling, and the stack is popped into the PC when returning.

The alternative design is to push the return address and the destination address on the data stack before jumping, and to pop the data stack and jump back to the popped PC to return. We prefer the separate return stack because it ensures that the return address cannot be overwritten or mislaid, uses fewer data stack slots, and obviates any need to swap the return address past the arguments or return values on the stack. Crucially, a dynamic jump is not needed to implement subroutine returns, allowing for deprecation of JUMP and JUMPI.

The low cost of JUMPSUB is justified by needing only about six Go operations to push the return address on the return stack, and decode the immediate two byte destination to the PC. The verylow cost of RETURNSUB is justified by needing only about three Go operations to pop the return stack into the PC. No 256-bit arithmetic or checking for valid destinations is needed. Also, JUMP is assigned mid, and JUMPSUB should be more efficient, as decoding immediate bytes should be cheaper than than converting 32-byte stack items, and the destination address will not need to be checked for either JUMPSUB or RETURNSUB. Benchmarking will be needed to tell if the costs are well-balanced.

Backwards and Forwards Compatibility

These changes affect the semantics of existing EVM code. The EVM Object Format is required to allow for immediate data.

These changes are compatible with using EIP-3337 to provide stack frames, by associating a frame with each subroutine.

Gas Costs in Practice

These opcodes reduce the gas costs of both ordinary subroutine calls and low-level optimizations. The savings reported here will of course be less relevant to programs that use a few large subroutines rather than being a factored than in to smaller ones. Note that the choice of gas costs for the new opcodes above does not make a large difference in this analysis, as much of the improvement is due to PUSH and SWAP operations that are no longer needed.

Subroutine Call

Consider this example of calling a minimal subroutine using JUMPSUB

ADD:
    beginsub          ; 1 gas
    0x02              ; 3 gas
    0x03              ; 3 gas
    jumpsub ADDITION  ; 5 gas
    returnsub         ; 3 gas

ADDITION:
    beginsub          ; 1 gas
    add               ; 3 gas
    returnsub         ; 3 gas

Total 22 gas.

The same code, using JUMP.

TEST_ADD:
   jumpdest           ; 1 gas
   RTN_ADD            ; 3 gas
   0x02               ; 3 gas
   0x03               ; 3 gas
   ADDITION           ; 3 gas
   jump               ; 8 gas
RTN_ADD:
   jumpdest           ; 1 gas
   swap1              ; 3 gas
   jump               ; 8 gas

ADDITION:
   jumpdest           ; 1 gas
   add                ; 3 gas
   swap1              ; 3 gas
   jump               ; 8 gas

Total: 48 gas

Using JUMP uses 48 - 22 = 26 more gas than using JUMPSUB.

Tail Call Optimization

Of course in cases like this one we can optimize the tail call, so that the final RETURNSUB in ADDITION actually returns from TEST_ADD.

TEST_ADD:
    beginsub          ; 1 gas
    0x02              ; 3 gas
    0x03              ; 3 gas
    rjump ADDITION    ; 3 gas

ADDITION:
    beginsub          ; 1 gas
    add               ; 3 gas
    returnsub         ; 3 gas

Total: 20 gas

Or the same code, using JUMP

TEST_ADD:
   jumpdest           ; 1 gas
   0x02               ; 3 gas
   0x03               ; 3 gas
   ADDITION           ; 3 gas
   jump               ; 8 gas

ADDITION:
   jumpdest           ; 1 gas
   add                ; 3 gas
   swap1              ; 3 gas
   jump               ; 8 gas

Total: 33 gas

Using JUMP cost 33 - 20 = 13 more gas than using JUMPSUB.

Tail Call Elimination

We can even take advantage of ADDITION just happening to directly follow TEST_ADD and just fall through rather than jump at all.

TEST_ADD:
    beginsub          ; 1 gas
    0x02              ; 3 gas
    0x03              ; 3 gas
ADDITION:
    beginsub          ; 1 gas
    add               ; 3 gas
    returnsub         ; 3 gas

Total 16 gas.

The same code, using JUMP.

TEST_ADD:
   jumpdest           ; 1 gas
   0x02               ; 3 gas
   0x03               ; 3 gas
ADDITION:
   jumpdest           ; 1 gas
   add                ; 3 gas
   swap1              ; 3 gas
   jump               ; 8 gas

Total: 24 gas

Using JUMP costs 22 - 14 = 8 more gas than using JUMPSUB.

Security Considerations

These changes do introduce new flow control instructions, so any software which does static/dynamic analysis of EVM code needs to be modified accordingly. The JUMPSUB semantics are similar to JUMP (but jumping to a BEGINSUB), whereas the RETURNSUB instruction is different, since it can 'land' on any opcode (but the possible destinations can be statically inferred).

Safety and amenability to static analysis of valid programs are comparable to EIP-615, but without imposing syntactic constraints, and thus with improved low-level optimizations (as shown above.) Validity can ensured by following the rules given in the next section, and programs can be validated with the provided algorithm. The validation algorithm is simple and bounded by the size of the code, allowing for validation at creation time. And compilers can easily follow the rules.

Validity

We define safety here as avoiding exceptional halting states.

Exceptional Halting States

Execution is as defined in the Yellow Paper a sequence of changes to the EVM state. The conditions on valid code are preserved by state changes. At runtime, if execution of an instruction would violate a condition the execution is in an exceptional halting state. The Yellow Paper defines five such states.

  1. Insufficient gas
  2. More than 1024 stack items
  3. Insufficient stack items
  4. Invalid jump destination
  5. Invalid instruction

We would like to consider EVM code valid iff no execution of the program can lead to an exceptional halting state, but we must be able to validate code in linear time to avoid denial of service attacks. So in practice, we can only partially meet these requirements. Our validation algorithm does not consider the codes data and computations, only its control flow and stack use. This means we will reject programs with any invalid code paths, even if those paths are not reachable at runtime. Further, conditions 1 and 2 Insufficient gas and stack overflow must in general be checked at runtime. Conditions 3, 4, and 5 cannot occur if the code conforms to the following rules.

The Rules

  1. JUMP and JUMPI are deprecated.
  2. RJUMP and RJUMPI address only valid JUMPDEST instructions.
  3. JUMPSUB addresses only valid BEGINSUB instructions.
  4. For each instruction in the code the stack depth is always the same.
  5. The stack depth is always positive and at most 1024.

Rule 0, deprecating JUMP and JUMPI, forbids dynamic jumps. Absent dynamic jumps another mechanism is needed for subroutine returns, as provided here.

Jump destinations are currently checked at runtime. Static jumps allow them to be validated at creation time, per Rule 1 and Rule 2. Note: Valid instructions are not part of immediate data.

For Rule 3 and Rule 4 we need to define stack depth. The Yellow Paper has the stack pointer or SP pointing just past the top item on the data stack. We define the stack base as where the SP pointed before the most recent JUMPSUB, or 0 on program entry. So we can define the stack depth as the number of stack elements between the current SP and the current stack base.

Given our definition of stack depth, Rule 3 ensures that control flows which return to the same place with a different stack depth are invalid. These can be caused by irreducible paths like jumping into loops and subroutines, and calling subroutines with different numbers of arguments. Taken together, these rules allow for code to be validated by following the control-flow graph, traversing each edge only once.

Finally, Rule 4 catches all stack underflows. It also catches stack overflows in programs that overflow without (or before) recursion.

Validation

The following is a pseudo-Go implementation of an algorithm for enforcing program validity being a symbolic execution that recursively traverses the bytecode, following its control flow and stack use and checking for violations of the rules above.

This algorithm runs in time equal to O(vertices + edges) in the program's control-flow graph, where vertices represent control-flow instructions and the edges represent basic blocks thus the algorithm takes time proportional to the size of the bytecode.

(For simplicity we assume an advance_pc() routine to skip immediate data and removed_items() and added_items() functions that return the effect of each instruction on the stack. We also don't specify JUMPTABLE which amounts to a loop over RJUMP.)

   var bytecode []byte
   var stack_depth []int
   var SP := 0

   func validate(PC :=0) boolean {
      // traverse code sequentially
      // recurse for subroutines and conditional jumps
      while true {
         instruction = bytecode[PC]
         if is_invalid(instruction) {
            return false;
         }

         // stack depth must be the same
         if SP != stack_depth[PC] {
               return false
         }
         // if stack depth non-zero we have been here before 
         // return true to break cycle in control flow graph
         if stack_depth[PC] != 0 { 
            return true
         }
         stack_depth[PC] = SP

         // effect of instruction on stack
         SP -= removed_items(instruction)
         SP += added_items(instruction)
         if SP < 0 || 1024 < SP {
             return false
         }

         // successful validation of path
         if instruction == STOP, RETURN, or SUICIDE {
             return true
         }

         if instruction == RJUMP {

             // check for valid destination
             jumpdest = *PC, PC++, jumpdest << 8, jumpdest = *PC
             if bytecode[jumpdest] != JUMPDEST {
                 return false
             }

             // reset PC to destination of jump 
             PC += jumpdest
             continue
         }
         if instruction == RJUMPI {

             // check for valid destination
             jumpdest = *PC, PC++, jumpdest << 8, jumpdest = *PC
             if bytecode[jumpdest] != JUMPDEST {
                 return false
             }

             // reset PC to destination of jump 
             PC += jumpdest

             // recurse to jump to code to validate 
             if !validate(stack[SP])) {
                 return false
             }
             continue 
         }
         if instruction == JUMPSUB {

             // check for valid destination
             jumpdest = *PC, PC++, jumpdest << 8
             if bytecode[jumpdest] != BEGINSUB {
                 return false
             }

             // recurse to jump to code to validate
             prevSP = SP
             depth = SP - prevSP
             SP = depth
             if  !validate(stack[SP]+1)) {
                 return false
             }
             SP = prevSP - depth + SP
             PC = prevPC
             continue
         }
         if instruction == RETURNSUB {
             PC = prevPC
             return true
         }

         // advance PC according to instruction
         PC = advance_pc(PC, instruction)
      }    
   }

Note: These safety guarantees are also made in EIP-615), with similar rules and a similar validation algorithm.

Test Cases

Simple routine

This should jump into a subroutine, back out and stop.

Bytecode: 0x60045e005c5d (PUSH1 0x04, JUMPSUB, STOP, BEGINSUB, RETURNSUB)

Pc Op Cost Stack RStack
0 JUMPSUB 5 [] []
3 RETURNSUB 5 [] [0]
4 STOP 0 [] []

Output: 0x Consumed gas: 10

Two levels of subroutines

This should execute fine, going into one two depths of subroutines

Bytecode: 0x6800000000000000000c5e005c60115e5d5c5d (PUSH9 0x00000000000000000c, JUMPSUB, STOP, BEGINSUB, PUSH1 0x11, JUMPSUB, RETURNSUB, BEGINSUB, RETURNSUB)

Pc Op Cost Stack RStack
0 JUMPSUB 5 [] []
3 JUMPSUB 5 [] [0]
4 RETURNSUB 5 [] [0,3]
5 RETURNSUB 5 [] [3]
6 STOP 0 [] []

Consumed gas: 20

Failure 1: invalid jump

This should fail, since the given location is outside of the code-range. The code is the same as previous example, except that the pushed location is 0x01000000000000000c instead of 0x0c.

Bytecode: (PUSH9 0x01000000000000000c, JUMPSUB, 0x6801000000000000000c5e005c60115e5d5c5d, STOP, BEGINSUB, PUSH1 0x11, JUMPSUB, RETURNSUB, BEGINSUB, RETURNSUB)

Pc Op Cost Stack RStack
0 JUMPSUB 10 [18446744073709551628] []
Error: at pc=10, op=JUMPSUB: invalid jump destination

Failure 2: shallow return stack

This should fail at first opcode, due to shallow return_stack

Bytecode: 0x5d5858 (RETURNSUB, PC, PC)

Pc Op Cost Stack RStack
0 RETURNSUB 5 [] []
Error: at pc=0, op=RETURNSUB: invalid retsub

Subroutine at end of code

In this example. the JUMPSUB is on the last byte of code. When the subroutine returns, it should hit the 'virtual stop' after the bytecode, and not exit with error

Bytecode: 0x6005565c5d5b60035e (PUSH1 0x05, JUMP, BEGINSUB, RETURNSUB, JUMPDEST, PUSH1 0x03, JUMPSUB)

Pc Op Cost Stack RStack
0 PUSH1 3 [] []
2 JUMP 8 [5] []
5 JUMPDEST 1 [] []
6 JUMPSUB 5 [] []
2 RETURNSUB 5 [] [2]
7 STOP 0 [] []

Consumed gas: 30