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:
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.