Skip to content

feat[codegen]: experimental EIP-7979 subroutines for internal calls - #5266

Draft
gcolvin wants to merge 1 commit into
vyperlang:masterfrom
gcolvin:eip-7979
Draft

gcolvin wants to merge 1 commit into
vyperlang:masterfrom
gcolvin:eip-7979

Conversation

@gcolvin

@gcolvin gcolvin commented Sep 12, 2026

Copy link
Copy Markdown

Draft. A demonstration for the EIP-7979 discussion, not a merge candidate.

EIP-7979 (proposed for Hegotá) gives the EVM a return stack and three instructions: CALLSUB, CALLDEST, RETURNSUB. This PR makes the legacy pipeline use them for internal function calls under a new experimental EVM version, future, so the question "what does it cost Vyper to target this?" has an answer.

vyper --evm-version future -f asm_runtime calls.vy

What changes in the generated code

today with future
call PUSH ret; [buf]; PUSH f; JUMP / ret: JUMPDEST [buf]; PUSH f; CALLSUB
function entry f: JUMPDEST with return_pc on the stack f: CALLDEST, return_buffer only
cleanup JUMP to return_pc RETURNSUB

Size of the change

138 lines added across ten files, but the calling convention itself is 39 of them (32 without comments), in self_call.py and internal_function.py. The rest is plumbing: the version and opcodes (10), a SubroutineLabel kind assembled as CALLDEST (19), three IR ops in the lowerer (47), two guards in the assembly optimizer so a CALLSUB is never redirected onto a JUMPDEST (19), IR-node valency (7), and registering the ops (3).

Results on a small contract

calls.vy below has a plain internal call, a nested one, and a loop. Runtime under future: 4 CALLSUB, 3 RETURNSUB, 10 JUMPI, and no plain JUMP; the Prague build has 7, all of them calls and returns.

Run on the EIP-7979 reference implementation in execution-specs (ethereum/execution-specs#3575):

compute(a, b) Prague future
(3, 4) → 35 4180 gas 4152 gas
(0, 0) → 0 3645 3617
(10, 5) → 140 4313 4285

Runtime size 260 bytes vs 272. The gas saving per call is the return label's push and landing pad; Vyper passes return values through memory, so nothing else in the convention changes.

The output validates under EIP-8337 (static control flow, no underflow), checked with its reference validator (ethereum/execution-specs#3576). That took one design change: the shared global revert block was reached from the dispatcher and from inside every subroutine — from different entries, and from both framed and unframed code — which the validator rejects. Under future the shared revert block is a subroutine entry (CALLDEST), and subroutine code has its own. Four bytes. The general rule, for any optimizer targeting these EIPs: shared code must be entered as a subroutine, and must not be shared between subroutine code and top-level code.

Notes

  • IR node names double as raw opcode names, so the IR ops are gosub, retsub and subroutine rather than callsub etc.
  • The Venom backend is untouched and ignores the new convention.
  • Selector-table dispatch is unchanged; with only static JUMPIs left in the demo it validates, but a dense selector table's computed jump would not until the EVM has a static multi-way jump.
  • Tests: 2,580 of the existing suite pass on the default version; the new version has no tests, since py-evm and revm do not implement the instructions. An evmone implementation is at ipsilon/evmone#1706.

The demo contract

@internal
@pure
def square(x: uint256) -> uint256:
    return x * x

@internal
@pure
def sum_of_squares(a: uint256, b: uint256) -> uint256:
    return self.square(a) + self.square(b)

@internal
@pure
def triangle(n: uint256) -> uint256:
    s: uint256 = 0
    for i: uint256 in range(n, bound=100):
        s += i + 1
    return s

@external
@pure
def compute(a: uint256, b: uint256) -> uint256:
    return self.sum_of_squares(a, b) + self.triangle(b)

With `--evm-version future` (a new experimental version for instructions
proposed but not yet scheduled), internal function calls compile to the
call and return instructions of EIP-7979 instead of being synthesized
from jumps:

- a call is `PUSH <function label>; CALLSUB`, with no return label
  pushed and no landing pad after the call;
- a function entry is a CALLDEST label, with no return_pc on the stack;
- the cleanup routine ends with RETURNSUB.

Plumbing:
- opcodes: the "future" version and CALLSUB (0xB0, 8 gas), CALLDEST
  (0xB1, 1 gas), RETURNSUB (0xB2, 5 gas);
- assembler: a SubroutineLabel kind, assembled as CALLDEST; RETURNSUB
  terminates a block; the jumpdest-merging pass never redirects a
  CALLSUB onto a JUMPDEST (a surviving label becomes a subroutine entry;
  no jump threading through one); source map marks CALLSUB/RETURNSUB;
- IR: `gosub` (CALLSUB with args, like goto), `retsub`, and `subroutine`
  (a label assembled as CALLDEST), named to avoid the opcode-name
  collision that IR node names are subject to.

With EIP-8337 validation in mind, the shared revert block becomes a
subroutine entry, and subroutine code gets its own: code shared by
several subroutines must be entered as a subroutine, and must not be
shared between subroutine code and top-level code.

Out of scope: the Venom backend, which ignores the new convention.
@gcolvin gcolvin changed the title # feat[codegen]: experimental EIP-7979 subroutines for internal calls feat[codegen]: experimental EIP-7979 subroutines for internal calls Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant