EIP-7979: Call and Return Opcodes for the EVM

EIP-7979: Call and Return Opcodes for the EVM

This is the smallest possible change to the EVM to support calls and returns.

This proposal introduces three new control-flow instructions to the EVM:

  • CALLSUB transfers control to the destination on top of the stack..
  • ENTERSUB marks a CALLSUB destination.
  • RETURNSUB returns to the PC after the most recent CALLSUB.

These changes are backwards compatible: the instructions behave identically wherever they appear.

Update Log

External Reviews

Outstanding Issues

yyyy-mm-dd: Issue description, link to issue

8 Likes

This EIP is great! Puts an important part which EVM is missing that can have immediate benefits, reducing gas costs with backwards compatibility. This is awesome.

The main question right now (and the reason it’s still a draft PR) is whether we need an ENTERSUB code. You can find them all in one pass by marking the destinations of JUMPSUB, but that’s the hard way.

ENTERSUB means a little bit more development time, but it’s a worthwhile addition. It won’t add any new runtime behaviour, it just makes the structure explicit.

Bottom line, it’s a minimal addition with a lot of benefit.

Yes – it amounts to a form of JUMPDEST. It’s also not clear that a validation algorithm can be made to work efficiently without it. Unfortunately Max – the compiler expert who was working with me on that algorithm – died a few weeks ago.

Damn that’s terrible news, I’m sorry to hear about Max.

ENTERSUB makes even more sense now, if the validation algorithm becomes harder with it, then its an almsot obvious decision.

I’m not a compiler expert but I’d like to help however I can. Let me know if there’s a way I can move this forward.

Thanks.

What some people seem not to understand (and I don’t blame them) is the basics of control flow graphs, why they can be an essential tool for static analysis, how to extract them from machine code, why dynamic jumps can make extracting them go quadratic, and why – unlike most applications – we need to extract them in linear time. And of course – given that static analysis is anything you can do to code without actually executing it – how many important things are forms of static analysis, including symbolic execution, translating EVM stack code to register code for faster interpretation and compiling to machine code, proofs of EVM code correctness and more. There is an entire academic literature devoted to working around the problems caused by dynamic jumps.

All this amounts to two or three university courses, some of which I never took – I finished school in 1982 and learned most of what I do know on the job. So I’m not the best person to explain all of this, let alone briefly enough to fit in an EIP.

Honsetly this gave me a much clearer view of what it can do. I didn’t study control flow graphs formally either, but the way you framed it makes a good case for structured control flow and ENTERSUB.

I’d love to help maybe I can write a short draft that explains the CFG/static analysis angle in layman’s terms as supporting context for people(like me haha) so it’s easier to understand. Let me know if that’s useful happy to contribute however I can

I support this EIP, as Ruby puts it, minimal addition with a lof of benefits!=.

1 Like

It feels like ENTERSUB behaving as a JUMPDEST just complicates static analysis, and I’m not sure the Rationale’s note does a good enough job explaining why that is desired behaviour:

[…] an ENTERSUB marks an entry, where the baseline resets and a jump eliminates a call. Without the distinction, the validator could not tell a merge from an entrance.

If someone wants to allow jumps and subroutines to the same “place”, then they could do a JUMPDEST followed by an ENTERSUB, making the added analysis complexity more visible.


The mnemonic ENTERSUB is a verb, when the actual behaviour is more a place/label. JUMPDEST is a noun.

I’d recommend naming it SUBROUTINE (or some contraction) instead because that doesn’t imply taking an action.

Thanks for the feedback @SamWilsn. Things have changed a bit since this merge and it will be a few days before the new version is ready. Your points are well taken and I’ll try to incorporate them.

It feels like ENTERSUB behaving as a JUMPDEST just complicates static analysis, and I’m not sure the Rationale’s note does a good enough job explaining why that is desired behaviour:

At this point the core validation function is less than 100 lines of Python, so complexity isn’t an issue, but conceptual clarity is.

The ability to jump into subroutines when it is safe to do so is essential to important optimizations, and the newest Rationale is specific about that:

A JUMP to an ENTERSUB enters the subroutine without pushing a return address: where a CALLSUB would be the last action before a RETURNSUB, the jump does the same work and the call is eliminated. Call elimination supports tail recursion, mutual recursion and state machines, shared epilogues, and the outlining of repeated code — the transformations optimizing compilers rely on.

[…] an ENTERSUB marks an entry, where the baseline resets and a jump eliminates a call. Without the distinction, the validator could not tell a merge from an entrance.

If someone wants to allow jumps and subroutines to the same “place”, then they could do a JUMPDEST followed by an ENTERSUB, making the added analysis complexity more visible.

They could, but this amounts to de-optimizing an optimization.

The mnemonic ENTERSUB is a verb, when the actual behaviour is more a place/label. JUMPDESTis a noun.

I’d recommend naming it SUBROUTINE (or some contraction) instead because that doesn’t imply taking an action.

Good point. SUBROUTINE doesn’t seem right, as it obscures the fact that subroutines need not be contiguous stretches of code and can have multiple entry points, and doesn’t make clear that it marks a destination.

CALLDEST might be good. Makes clear that it’s the same sort of label as a JUMPDEST, so it’s less surprising that you can only jump to a JUMPDEST, but you can call or jump to a CALLDEST.

Having a third CALLORJUMPDEST or such would be a mistake – nothing different happens at the CALLDEST site when you jump to it rather than call it, so it shouldn’t need to be changed just because some caller decided to optimize a call to a jump. It’s the validators job to tell whether the optimization is safe.

The validation angle makes sense, but there’s another perspective worth considering - explicit entry points are what debuggers and formal verification tools need to work cleanly, otherwise every tool ends up reconstructing the structure itself. Just having it at the instruction level is cleaner.

1 Like

My input was requested by ACDE and here my brief thoughts

  • Yes of course we would use subroutine opcodes, that’s not a question
  • I much preferred EIP-2315. I see this has been designed to mirror the JUMP/JUMPDEST architecture which is.. fine? EIP-2315 was much more elegant
  • Shipping this EIP does not obviate the need for dynamic jumps. You still need jumptables (the most recent form I am familiar with is RJUMPV from EIP-4200: EOF - Static relative jumps and EIP-8013: Static relative jumps and calls for the EVM

Overall I do not feel strongly about this EIP. Yes useful, fairly strong preference for EIP-2315, does not get rid of dynamic jumps without *JUMPV style instructions.

Thanks, Charles.

We’ve discussed this privately, and mostly agree. 2315 died, and I don’t want to go back – it prevented important optimizations, which EOF had to add special opcodes to allow, with partial success. In the next fork we should add a JUMPV and maybe a CALLSUBV, but I can’t see delaying useful functionality while we work that out. In the meantime dynamic JUMP is still available.

I’ve never understood the “will compilers use them” question. Yul already has “function”, and Claude wrote me a Yul-to-7979 compiler in seconds – it’s trivial, and saves space and gas.

But I’m not entirely sure why you would prefer 2315. It prevented more optimizations and, as I recall it, actually bans JUMPV.

1 Like

EIP-7979 (Call and Return Opcodes): implementation status

Since last week’s ACDE deferral, the whole chain now exists and tests pass. Thank you, Claude.

Spec — reference implementation in execution-specs, 44 tests, 130 fixtures, CI green, which their docs make the precondition for CFI: https://github.com/ethereum/execution-specs/pull/3575.

Validation — EIP-8337 validation is 66 tests, 200 fixtures, all 43 of the EIP’s vectors: https://github.com/ethereum/execution-specs/pull/3576.

Solidity — the IR pipeline emits CALLSUB/CALLDEST/RETURNSUB for internal calls under --evm-version @future: https://github.com/argotorg/solidity/pull/17005. 162 lines. No plain JUMPs left in the output; it runs correctly, uses less gas, and validates under 8337.

Vyper — the same, under --evm-version future: https://github.com/vyperlang/vyper/pull/5266. The calling convention itself is about 35 lines; the rest is opcode plumbing. The output validates under 8337.

evmone — the three instructions in the Baseline interpreter, gated on EVMC_EXPERIMENTAL, all 1285 unit tests passing: https://github.com/ipsilon/evmone/pull/1706. Solidity’s output runs on it at 10–12% less gas.

geth — the three opcodes in core/vm, enabled through ExtraEips, go test ./core/vm/ passing: https://github.com/ethereum/go-ethereum/compare/master...gcolvin:go-ethereum:eip-7979. A branch, since geth takes PRs only after CFI.

A tentative complexity assessment in the STEEL template is on ethspecs/pm#72: https://github.com/ethspecs/pm/issues/72. Twenty of 28 rows are zero; the total is 9 to 13 depending on two judgment calls, against 15 for FOCIL and 38 for Frame Transactions. That makes 7979, as far as I can tell, the smallest EIP proposed for this fork.

I’m asking that 7979 be considered for CFI at the next ACDE. Reviews welcome on any of the above.

3 Likes

Updated implementation and measurements.

Below are links to EIP-7979 prototypes on Solidity, Vyper, geth, and evmone. By lines of code it is not a large change:

Code Comments Removed Tests
Solidity 289 68 25 158
Vyper 128 30 29 103
geth 153 64 0 205
evmone 75 18 13 211
Implementations total 645 180 67 677
EELS, EIP-7979 225 20 101 1,025
EELS, EIP-8337 validation 430 41 4 679
Specification total 655 61 105 1,704
Grand total 1,300 241 172 2,381

Updated 23 September: Solidity gained an optimizer post-pass that makes blocks shared across subroutines into subroutine entries; Vyper gained --linear-selector-section, a static dispatcher; EIP-8337’s validator now treats framing as a property of entries, with tests. With these, all of Vyper’s example contracts and 98.9% of Solidity’s 1,756 semantic-test contracts validate under EIP-8337.

Updated 24 September: Solidity’s full semantic test suite passes under --evm-version @future , with and without the optimizer, on evmone 0.22.0 with EIP-7979 added; all 1,756 of its single-source test contracts, and all 17 of Vyper’s examples, validate under EIP-8337. The first run found two places in Solidity’s assembly optimizer that needed to learn that CALLSUB falls through, and one where the CSE separated a CALLSUB from its pushed destination; those are the fifty extra lines.

Hi @gcolvin,

Thank you for continuing to advance EIP-7979. Below is an architectural breakdown examining the ENTERSUB vs. JUMPDEST choice, the proposed gas schedule, and concrete data structure models for client implementations (**revm**and execution-specs).

1. Control-Flow Invariants: The Case for ENTERSUB

Reusing JUMPDEST reduces the proposal to two opcodes, but retaining an explicit ENTERSUB marker introduces structural invariants that simplify static analysis, formal verification, and ZK-EVM circuit design:

  • Control-Flow Graph (CFG) Isolation:

    • Reusing JUMPDEST merges procedural boundaries with basic intra-function branching. A rogue JUMP could land directly inside a subroutine body, or a CALLSUB could branch into an arbitrary loop header.

    • Enforcing ENTERSUB as the exclusive destination for CALLSUB (and asserting that standard JUMP / JUMPI cannot target ENTERSUB) partitions the bytecode into disjoint sub-graphs: intra-procedure basic blocks vs. procedure entrypoints.

  • Tooling and Decompilation Ergonomics:

    • Without structured containers (EOF), tools like Slither, Heimdall, and symbolic executors must trace dynamic stack values to differentiate function entrypoints from branch targets.

    • A dedicated ENTERSUB byte allows linear scanners to detect procedure boundaries in a single pass without running abstract interpretation.

  • ZK-EVM Circuit Lookups:

    • In ZK-EVM architectures (e.g., Polygon zkEVM, Scroll, Taiko), provers must constrain destination validity. Separating ENTERSUB from JUMPDEST allows provers to route subroutine calls against a smaller, dedicated procedure table rather than expanding the universal jump destination table.

2. Proposed Gas Calibration

To avoid introducing underpriced execution loops or DoS vectors, the gas costs should align with existing stack and jump tiers:

Instruction Proposed Cost Comparative Baseline Technical Justification
ENTERSUB 1 gas(GasFastestStep) JUMPDEST (1 gas) Pure landing-pad marker. Validates destination and increments the program counter ($PC$).
CALLSUB 5 gas(or 7 gas) JUMP(8 gas) / PUSH+ JUMP(11 gas) Reads target from data stack, checks return stack depth limit ($< 1024$), pushes $PC + 1$ to return stack, and updates $PC$. Lower than PUSH + JUMP because it bypasses data-stack thrashing while accounting for internal pointer management.
RETURNSUB 3 gas(or 5 gas) JUMP(8 gas) / SWAP+ JUMP(11 gas) Pops top $PC$ from return stack and jumps. Operates without stack operands, reducing opcode decoding and stack-pointer validation overhead.

3. Client Implementation & Memory Mechanics (revm / execution-specs)

To prevent performance degradation or memory-thrashing vulnerabilities during nested calls, the return stack must be implemented with zero-allocation semantics:

High-Performance Rust Model (revm)

In high-throughput environments like revm, allocating a heap-backed dynamic vector (Vec<usize>) on every nested execution context introduces allocation overhead and cache misses.

Instead, the return stack should be an in-place, fixed-capacity structure embedded directly in the execution context interpreter loop:

pub const MAX_RETURN_STACK_DEPTH: usize = 1024;

pub struct ReturnStack {
    // Fixed array avoids heap allocations; 1024 * 4 bytes = 4 KiB, fits cleanly into L1 cache
    frames: [u32; MAX_RETURN_STACK_DEPTH],
    len: usize,
}

impl ReturnStack {
    #[inline(always)]
    pub fn push(&mut self, pc: u32) -> Result<(), InterpreterError> {
        if self.len >= MAX_RETURN_STACK_DEPTH {
            return Err(InterpreterError::ReturnStackOverflow);
        }
        self.frames[self.len] = pc;
        self.len += 1;
        Ok(())
    }

    #[inline(always)]
    pub fn pop(&mut self) -> Result<u32, InterpreterError> {
        if self.len == 0 {
            return Err(InterpreterError::ReturnStackUnderflow);
        }
        self.len -= 1;
        Ok(self.frames[self.len])
    }
}

  • L1 Cache Locality: At 1024 entries of 32-bit offsets ($4,\text{KiB}$), the entire return stack fits comfortably within modern L1 data caches ($\sim 32\text{–}48,\text{KiB}$), ensuring zero-overhead pointer increments.

  • Context Cleanse: When a frame halts (RETURN, REVERT, or unhandled trap), resetting len = 0 instantly drops the stack with no heap deallocation cycles.

  • Python Reference Model (execution-specs)

In canonical Ethereum execution specifications (ethereum/execution-specs), the structure should be captured explicitly in the execution environment state:

MAX_RETURN_STACK_DEPTH = 1024

@dataclass
class EvmReturnStack:
    stack: List[int] = field(default_factory=list)

    def push(self, pc: int) -> None:
        if len(self.stack) >= MAX_RETURN_STACK_DEPTH:
            raise ReturnStackOverflow()
        self.stack.append(pc)

    def pop(self) -> int:
        if not self.stack:
            raise ReturnStackUnderflow()
        return self.stack.pop()

Frame Isolation Rules

  • No Cross-Frame Leakage: The return stack is strictly isolated to its call context (CallFrame). It is never inherited, read, or modified across external context shifts (CALL, STATICCALL, DELEGATECALL, CALLCODE).

  • Deterministic Traps: Both underflow (RETURNSUB on empty stack) and overflow (CALLSUB at depth 1024) must consume all remaining frame gas, identical to data-stack exceptions.

Thanks for the review, @Obaresearch. A few notes before bed.

The spec currently does allow a JUMP to land on a CALLDEST – otherwise a number of important optimizations are lost, such as tail calls, mutual recursion, and shared epilogues. And the spec explicitly does not provide structured containers, again, so as not to impede important optimizations.

So 7979 standing alone is necessary but not sufficient to provide static control flow with the structure needed by some of the tools you mention. For that purpose code conforming to EIP-8337: Validated EVM Code or a similar standard is needed. In 8337 subroutines are defined as subgraphs of the control-flow graph which I believe meet the needs of static analysis, symbolic execution, ZK-proofs, and other such – I’d appreciate your take.

Thanks for clarifying this. I agree that my earlier description overstated what EIP-7979 guarantees on its own. Since JUMP and JUMPI may target a CALLDEST, it cannot be treated as an exclusive procedure boundary; that flexibility is deliberate and enables optimizations such as tail calls and shared epilogues.

EIP-8337 looks like a useful way to offer stronger static guarantees without making them mandatory for all EIP-7979 bytecode. For code that opts into its MAGIC format and passes validation, statically known destinations, return-framing checks, and subroutines defined as CFG subgraphs seem like a reasonable basis for tools that need a validated control-flow graph—while preserving EIP-7979’s more permissive execution model for other code.

I would qualify the tooling claim: these guarantees can help static analysis, symbolic execution, and proof systems, but do not by themselves establish semantic correctness, termination, or every invariant a particular analysis or ZK circuit may require. Those tools still need to state their assumptions, and consumers need to know that the code they treat as validated actually passed the relevant validation.

My revised understanding is that EIP-7979 supplies the call/return mechanism and optimization flexibility, while EIP-8337 can provide an optional, checkable static subset for consumers that need stronger control-flow properties. Is that the intended relationship between the two proposals?

Yes, exactly. I have not yet proposed EIP-8137 – EIP-7979 is a big lift already, and without it compilers and tools can’t experiment with generating validated code and using validated code. Working with Claude today we found a small tweak to the 8337 rules that allows Solidity to generate most all of its code to be 8337 compliant.