From 81fceaf6c73dd774ac712ee86d85941f99cc1067 Mon Sep 17 00:00:00 2001 From: Greg Colvin Date: Sat, 12 Sep 2026 11:42:15 +0000 Subject: [PATCH] Implement EIP-7979 CALLSUB, CALLDEST, RETURNSUB (EVMC_EXPERIMENTAL) EIP-7979 adds a per-frame return stack and three instructions to the EVM. This implements them in the Baseline interpreter, available from the EVMC_EXPERIMENTAL revision. - CALLSUB (0xB0, 8 gas) pops a destination, which must be a CALLDEST, pushes the position of the next instruction onto the return stack and transfers control. The return stack holds at most 1024 entries. - CALLDEST (0xB1, 1 gas) marks a subroutine entry; it is a no-op like JUMPDEST and, from EVMC_EXPERIMENTAL on, also a valid JUMP/JUMPI destination. - RETURNSUB (0xB2, 5 gas) pops the return stack into the program counter. The code analysis computes a second bitset of CALLDEST positions next to the JUMPDEST one in a single pass. Because the analysis is cached per code independently of the revision, CALLDESTs are kept out of the jumpdest bitset; jump_impl consults the CALLDEST bitset only when the JUMPDEST test fails and the revision has EIP-7979, so ordinary jumps are unaffected and pre-EIP behaviour is unchanged. Return-stack overflow and underflow report EVMC_STACK_OVERFLOW and EVMC_STACK_UNDERFLOW; an invalid CALLSUB destination reports EVMC_BAD_JUMP_DESTINATION. The Advanced interpreter does not implement the instructions, as for EIP-8024. Tests: the EIP's five test cases with their gas totals, invalid destinations, JUMP/JUMPI to a CALLDEST, tail call, the 1024 limit from both sides, and pre-EIP behaviour. --- lib/evmone/advanced_instructions.cpp | 3 + lib/evmone/baseline.hpp | 18 +- lib/evmone/baseline_analysis.cpp | 19 +- lib/evmone/execution_state.hpp | 6 + lib/evmone/instructions.hpp | 51 ++++- lib/evmone/instructions_opcodes.hpp | 4 + lib/evmone/instructions_traits.hpp | 8 + lib/evmone/instructions_xmacro.hpp | 6 +- test/unittests/CMakeLists.txt | 1 + test/unittests/evm_eip7979_callsub_test.cpp | 238 ++++++++++++++++++++ 10 files changed, 341 insertions(+), 13 deletions(-) create mode 100644 test/unittests/evm_eip7979_callsub_test.cpp diff --git a/lib/evmone/advanced_instructions.cpp b/lib/evmone/advanced_instructions.cpp index e57858a0a5..998f847953 100644 --- a/lib/evmone/advanced_instructions.cpp +++ b/lib/evmone/advanced_instructions.cpp @@ -273,6 +273,9 @@ constexpr std::array instruction_implementations = []( table[OP_DUPN] = op_undefined; table[OP_SWAPN] = op_undefined; table[OP_EXCHANGE] = op_undefined; + table[OP_CALLSUB] = op_undefined; + table[OP_CALLDEST] = op_undefined; + table[OP_RETURNSUB] = op_undefined; return table; }(); diff --git a/lib/evmone/baseline.hpp b/lib/evmone/baseline.hpp index 8ed1e5dc69..eeeda57247 100644 --- a/lib/evmone/baseline.hpp +++ b/lib/evmone/baseline.hpp @@ -64,12 +64,18 @@ class CodeAnalysis BitsetSpan m_jumpdest_bitset{nullptr}; + /// EIP-7979: the positions of CALLDEST instructions, the only valid CALLSUB targets + /// and, from the revision that has them, also valid jump destinations. + BitsetSpan m_calldest_bitset{nullptr}; + public: /// Constructor for legacy code. - CodeAnalysis(std::unique_ptr padded_code, size_t code_size, BitsetSpan map) + CodeAnalysis(std::unique_ptr padded_code, size_t code_size, BitsetSpan jumpdest_map, + BitsetSpan calldest_map) : m_code{padded_code.get(), code_size}, m_padded_code{std::move(padded_code)}, - m_jumpdest_bitset{map} + m_jumpdest_bitset{jumpdest_map}, + m_calldest_bitset{calldest_map} {} /// The executable code. This is where the interpreter should start execution. @@ -82,6 +88,14 @@ class CodeAnalysis return false; return m_jumpdest_bitset.test(static_cast(position)); } + + /// Check if given position is a CALLDEST, i.e. a valid CALLSUB destination (EIP-7979). + [[nodiscard]] bool check_calldest(uint64_t position) const noexcept + { + if (position >= m_code.size()) + return false; + return m_calldest_bitset.test(static_cast(position)); + } }; /// Analyze the EVM code in preparation for execution. diff --git a/lib/evmone/baseline_analysis.cpp b/lib/evmone/baseline_analysis.cpp index 6083fcea1e..95d138de46 100644 --- a/lib/evmone/baseline_analysis.cpp +++ b/lib/evmone/baseline_analysis.cpp @@ -15,7 +15,10 @@ static_assert(!std::is_copy_assignable_v); namespace { -void analyze_jumpdests(BitsetSpan map, bytes_view code) noexcept +/// Builds the map of valid jump destinations and, for EIP-7979, the map of +/// CALLDEST positions. The analysis is revision-independent, so CALLDESTs are +/// kept in their own map; jumps accept them only from the revision that has them. +void analyze_jumpdests(BitsetSpan jumpdest_map, BitsetSpan calldest_map, bytes_view code) noexcept { // To find if op is any PUSH opcode (OP_PUSH1 <= op <= OP_PUSH32) // it can be noticed that OP_PUSH32 is INT8_MAX (0x7f) therefore, @@ -28,7 +31,9 @@ void analyze_jumpdests(BitsetSpan map, bytes_view code) noexcept if (static_cast(op) >= OP_PUSH1) // If any PUSH opcode (see explanation above). i += op - size_t{OP_PUSH1 - 1}; // Skip PUSH data. else if (INTX_UNLIKELY(op == OP_JUMPDEST)) - map.set(i); + jumpdest_map.set(i); + else if (INTX_UNLIKELY(op == OP_CALLDEST)) + calldest_map.set(i); } } @@ -45,18 +50,20 @@ CodeAnalysis analyze_legacy(bytes_view code) const auto aligned_code_size = (padded_code_size + (BITSET_ALIGNMENT - 1)) / BITSET_ALIGNMENT * BITSET_ALIGNMENT; const auto bitset_words = (code.size() + (BitsetSpan::WORD_BITS)) / BitsetSpan::WORD_BITS; - const auto total_size = aligned_code_size + bitset_words * sizeof(BitsetSpan::word_type); + // Two bitsets: jump destinations and, for EIP-7979, CALLDEST positions. + const auto total_size = aligned_code_size + 2 * bitset_words * sizeof(BitsetSpan::word_type); auto storage = std::make_unique_for_overwrite(total_size); std::ranges::copy(code, storage.get()); // Copy code. std::fill_n(&storage[code.size()], total_size - code.size(), 0); // Pad code and init bitset. const auto bitset_storage = - new (&storage[aligned_code_size]) BitsetSpan::word_type[bitset_words]; + new (&storage[aligned_code_size]) BitsetSpan::word_type[2 * bitset_words]; const BitsetSpan jumpdest_bitset{bitset_storage}; - analyze_jumpdests(jumpdest_bitset, code); + const BitsetSpan calldest_bitset{bitset_storage + bitset_words}; + analyze_jumpdests(jumpdest_bitset, calldest_bitset, code); - return {std::move(storage), code.size(), jumpdest_bitset}; + return {std::move(storage), code.size(), jumpdest_bitset, calldest_bitset}; } } // namespace diff --git a/lib/evmone/execution_state.hpp b/lib/evmone/execution_state.hpp index 9511612a7c..32d1240b04 100644 --- a/lib/evmone/execution_state.hpp +++ b/lib/evmone/execution_state.hpp @@ -138,6 +138,11 @@ class ExecutionState /// Reference to original EVM code. bytes_view original_code; + /// EIP-7979: the return stack. Holds positions of the instructions following CALLSUBs, + /// pushed only by CALLSUB and popped only by RETURNSUB. + static constexpr size_t RETURN_STACK_LIMIT = 1024; + std::vector return_stack; + evmc_status_code status = EVMC_SUCCESS; size_t output_offset = 0; size_t output_size = 0; @@ -178,6 +183,7 @@ class ExecutionState host = {host_interface, host_ctx}; rev = revision; return_data.clear(); + return_stack.clear(); original_code = _code; status = EVMC_SUCCESS; output_offset = 0; diff --git a/lib/evmone/instructions.hpp b/lib/evmone/instructions.hpp index 76c6a63216..8fa7fbc30f 100644 --- a/lib/evmone/instructions.hpp +++ b/lib/evmone/instructions.hpp @@ -190,6 +190,7 @@ namespace instr::core inline void noop(StackTop /*stack*/) noexcept {} inline constexpr auto pop = noop; inline constexpr auto jumpdest = noop; +inline constexpr auto calldest = noop; ///< EIP-7979: a label, like JUMPDEST. template inline TermResult stop_impl( @@ -765,8 +766,14 @@ inline code_iterator jump_impl(ExecutionState& state, const uint256& dst) noexce const auto hi_part_is_nonzero = (dst[3] | dst[2] | dst[1]) != 0; if (hi_part_is_nonzero || !state.analysis.baseline->check_jumpdest(dst[0])) [[unlikely]] { - state.status = EVMC_BAD_JUMP_DESTINATION; - return nullptr; + // EIP-7979: a jump may also land on a CALLDEST. Checked only when the + // JUMPDEST test fails, so ordinary jumps pay nothing for it. + if (hi_part_is_nonzero || state.rev < EVMC_EXPERIMENTAL || + !state.analysis.baseline->check_calldest(dst[0])) + { + state.status = EVMC_BAD_JUMP_DESTINATION; + return nullptr; + } } return &state.analysis.baseline->code()[static_cast(dst[0])]; @@ -786,6 +793,46 @@ inline code_iterator jumpi(StackTop stack, ExecutionState& state, code_iterator return cond ? jump_impl(state, dst) : pos + 1; } +/// CALLSUB instruction implementation using baseline::CodeAnalysis (EIP-7979). +/// Pushes the position of the next instruction onto the return stack and +/// transfers control to the CALLDEST at the destination. +inline code_iterator callsub(StackTop stack, ExecutionState& state, code_iterator pos) noexcept +{ + const auto& dst = stack.pop(); + const auto hi_part_is_nonzero = (dst[3] | dst[2] | dst[1]) != 0; + if (hi_part_is_nonzero || !state.analysis.baseline->check_calldest(dst[0])) [[unlikely]] + { + state.status = EVMC_BAD_JUMP_DESTINATION; + return nullptr; + } + if (state.return_stack.size() >= ExecutionState::RETURN_STACK_LIMIT) [[unlikely]] + { + state.status = EVMC_STACK_OVERFLOW; + return nullptr; + } + + const auto code = state.analysis.baseline->code(); + state.return_stack.push_back(static_cast(pos + 1 - code.data())); + return &code[static_cast(dst[0])]; +} + +/// RETURNSUB instruction implementation using baseline::CodeAnalysis (EIP-7979). +/// Pops the return stack into the program counter. +inline code_iterator returnsub( + StackTop /*stack*/, ExecutionState& state, code_iterator /*pos*/) noexcept +{ + if (state.return_stack.empty()) [[unlikely]] + { + state.status = EVMC_STACK_UNDERFLOW; + return nullptr; + } + + const auto ret = state.return_stack.back(); + state.return_stack.pop_back(); + // The return position may be the code end, where the padding guarantees a STOP. + return state.analysis.baseline->code().data() + ret; +} + inline code_iterator pc(StackTop stack, ExecutionState& state, code_iterator pos) noexcept { stack.push(static_cast(pos - state.analysis.baseline->code().data())); diff --git a/lib/evmone/instructions_opcodes.hpp b/lib/evmone/instructions_opcodes.hpp index fdd2e9cde8..aab34d89ab 100644 --- a/lib/evmone/instructions_opcodes.hpp +++ b/lib/evmone/instructions_opcodes.hpp @@ -161,6 +161,10 @@ enum Opcode : uint8_t // NOLINT(*-use-enum-class) OP_LOG3 = 0xa3, OP_LOG4 = 0xa4, + OP_CALLSUB = 0xb0, + OP_CALLDEST = 0xb1, + OP_RETURNSUB = 0xb2, + OP_DUPN = 0xe6, OP_SWAPN = 0xe7, OP_EXCHANGE = 0xe8, diff --git a/lib/evmone/instructions_traits.hpp b/lib/evmone/instructions_traits.hpp index d42429f1a5..fd87f6314a 100644 --- a/lib/evmone/instructions_traits.hpp +++ b/lib/evmone/instructions_traits.hpp @@ -181,6 +181,10 @@ constexpr inline GasCostTable gas_costs = []() noexcept { table[EVMC_AMSTERDAM][OP_EXCHANGE] = 3; table[EVMC_EXPERIMENTAL] = table[EVMC_AMSTERDAM]; + // EIP-7979: Call and Return Opcodes for the EVM (mid, jumpdest, low). + table[EVMC_EXPERIMENTAL][OP_CALLSUB] = 8; + table[EVMC_EXPERIMENTAL][OP_CALLDEST] = 1; + table[EVMC_EXPERIMENTAL][OP_RETURNSUB] = 5; return table; }(); @@ -389,6 +393,10 @@ constexpr inline std::array traits = []() noexcept { table[OP_LOG3] = {"LOG3", 0, false, 5, -5, EVMC_FRONTIER}; table[OP_LOG4] = {"LOG4", 0, false, 6, -6, EVMC_FRONTIER}; + table[OP_CALLSUB] = {"CALLSUB", 0, false, 1, -1, EVMC_EXPERIMENTAL}; + table[OP_CALLDEST] = {"CALLDEST", 0, false, 0, 0, EVMC_EXPERIMENTAL}; + table[OP_RETURNSUB] = {"RETURNSUB", 0, false, 0, 0, EVMC_EXPERIMENTAL}; + table[OP_MCOPY] = {"MCOPY", 0, false, 3, -3, EVMC_CANCUN}; table[OP_CREATE] = {"CREATE", 0, false, 3, -2, EVMC_FRONTIER}; diff --git a/lib/evmone/instructions_xmacro.hpp b/lib/evmone/instructions_xmacro.hpp index 056b0fe168..bcf045fa04 100644 --- a/lib/evmone/instructions_xmacro.hpp +++ b/lib/evmone/instructions_xmacro.hpp @@ -219,9 +219,9 @@ ON_OPCODE_UNDEFINED(0xae) \ ON_OPCODE_UNDEFINED(0xaf) \ \ - ON_OPCODE_UNDEFINED(0xb0) \ - ON_OPCODE_UNDEFINED(0xb1) \ - ON_OPCODE_UNDEFINED(0xb2) \ + ON_OPCODE_IDENTIFIER(OP_CALLSUB, callsub) \ + ON_OPCODE_IDENTIFIER(OP_CALLDEST, calldest) \ + ON_OPCODE_IDENTIFIER(OP_RETURNSUB, returnsub) \ ON_OPCODE_UNDEFINED(0xb3) \ ON_OPCODE_UNDEFINED(0xb4) \ ON_OPCODE_UNDEFINED(0xb5) \ diff --git a/test/unittests/CMakeLists.txt b/test/unittests/CMakeLists.txt index 3bfc1410a4..4685ab3e10 100644 --- a/test/unittests/CMakeLists.txt +++ b/test/unittests/CMakeLists.txt @@ -20,6 +20,7 @@ target_sources( evm_eip2929_test.cpp evm_eip3198_basefee_test.cpp evm_eip3855_push0_test.cpp + evm_eip7979_callsub_test.cpp evm_eip3860_initcode_test.cpp evm_eip4844_blobhash_test.cpp evm_eip7516_blobbasefee_test.cpp diff --git a/test/unittests/evm_eip7979_callsub_test.cpp b/test/unittests/evm_eip7979_callsub_test.cpp new file mode 100644 index 0000000000..fd8c55f6a7 --- /dev/null +++ b/test/unittests/evm_eip7979_callsub_test.cpp @@ -0,0 +1,238 @@ +// evmone: Fast Ethereum Virtual Machine implementation +// Copyright 2026 The evmone Authors. +// SPDX-License-Identifier: Apache-2.0 + +/// This file contains EVM unit tests for EIP-7979: Call and Return Opcodes for the EVM. +/// https://eips.ethereum.org/EIPS/eip-7979 + +#include "evm_fixture.hpp" + +using namespace evmone::test; + +namespace +{ +constexpr auto REV = EVMC_EXPERIMENTAL; +} + +TEST_P(evm, callsub_undefined_before_experimental) +{ + rev = EVMC_AMSTERDAM; + execute("6004B000B1B2"); + EXPECT_STATUS(EVMC_UNDEFINED_INSTRUCTION); +} + +TEST_P(evm, calldest_not_a_jumpdest_before_experimental) +{ + if (is_advanced()) + return; + rev = EVMC_AMSTERDAM; + // PUSH1 4, JUMP, STOP, 0xB1, STOP: before EIP-7979 a 0xB1 byte is not a jump destination. + execute("60045600B100"); + EXPECT_STATUS(EVMC_BAD_JUMP_DESTINATION); +} + +// The test cases of the EIP. + +TEST_P(evm, eip7979_simple_routine) +{ + if (is_advanced()) + return; + rev = REV; + // PUSH1 4, CALLSUB, STOP, CALLDEST, RETURNSUB + execute("6004B000B1B2"); + EXPECT_GAS_USED(EVMC_SUCCESS, 17); +} + +TEST_P(evm, eip7979_two_levels_of_subroutines) +{ + if (is_advanced()) + return; + rev = REV; + execute("6004B000B16009B0B2B1B2"); + EXPECT_GAS_USED(EVMC_SUCCESS, 34); +} + +TEST_P(evm, eip7979_invalid_destination) +{ + if (is_advanced()) + return; + rev = REV; + execute("60FFB000B1B2"); + EXPECT_STATUS(EVMC_BAD_JUMP_DESTINATION); +} + +TEST_P(evm, eip7979_empty_return_stack) +{ + if (is_advanced()) + return; + rev = REV; + execute("B2"); + EXPECT_STATUS(EVMC_STACK_UNDERFLOW); +} + +TEST_P(evm, eip7979_subroutine_at_end_of_code) +{ + if (is_advanced()) + return; + rev = REV; + // PUSH1 5, JUMP, CALLDEST, RETURNSUB, JUMPDEST, PUSH1 3, CALLSUB: returns to the implicit STOP. + execute("600556B1B25B6003B0"); + EXPECT_GAS_USED(EVMC_SUCCESS, 29); +} + +// Destinations. + +TEST_P(evm, callsub_to_jumpdest) +{ + if (is_advanced()) + return; + rev = REV; + execute("6004B0005B"); + EXPECT_STATUS(EVMC_BAD_JUMP_DESTINATION); +} + +TEST_P(evm, callsub_into_push_data) +{ + if (is_advanced()) + return; + rev = REV; + // Destination 5 is the 0xB1 byte inside the PUSH1 immediate. + execute("6005B00060B100"); + EXPECT_STATUS(EVMC_BAD_JUMP_DESTINATION); +} + +TEST_P(evm, callsub_destination_overflows_uint64) +{ + if (is_advanced()) + return; + rev = REV; + execute(push(~intx::uint256{}) + "B000B1B2"); + EXPECT_STATUS(EVMC_BAD_JUMP_DESTINATION); +} + +TEST_P(evm, callsub_stack_underflow) +{ + if (is_advanced()) + return; + rev = REV; + execute("B000B1B2"); + EXPECT_STATUS(EVMC_STACK_UNDERFLOW); +} + +// CALLDEST is a valid JUMP/JUMPI destination (call elimination). + +TEST_P(evm, jump_to_calldest) +{ + if (is_advanced()) + return; + rev = REV; + // PUSH1 4, JUMP, STOP, CALLDEST, STOP + execute("60045600B100"); + EXPECT_GAS_USED(EVMC_SUCCESS, 12); +} + +TEST_P(evm, jumpi_to_calldest) +{ + if (is_advanced()) + return; + rev = REV; + // PUSH1 1, PUSH1 6, JUMPI, STOP, CALLDEST, STOP + execute("600160065700B100"); + EXPECT_GAS_USED(EVMC_SUCCESS, 17); +} + +TEST_P(evm, returnsub_after_unframed_jump) +{ + if (is_advanced()) + return; + rev = REV; + // Entering a subroutine by JUMP pushes no return address. + execute("60045600B1B2"); + EXPECT_STATUS(EVMC_STACK_UNDERFLOW); +} + +TEST_P(evm, tail_call) +{ + if (is_advanced()) + return; + rev = REV; + // main: PUSH1 4, CALLSUB, STOP; f: CALLDEST, PUSH1 8, JUMP; g: CALLDEST, RETURNSUB + execute("6004B000B1600856B1B2"); + EXPECT_GAS_USED(EVMC_SUCCESS, 29); +} + +TEST_P(evm, calldest_reached_by_fall_through) +{ + if (is_advanced()) + return; + rev = REV; + execute("B1B100"); + EXPECT_GAS_USED(EVMC_SUCCESS, 2); +} + +// The return stack. + +TEST_P(evm, subroutine_returns_value_to_caller) +{ + if (is_advanced()) + return; + rev = REV; + // main: PUSH1 , CALLSUB, then return the value the subroutine left on the stack. + // sub: CALLDEST, PUSH1 0x2a, RETURNSUB + const auto main = push(0) + OP_CALLSUB + ret_top(); + const auto code = + push(main.size()) + OP_CALLSUB + ret_top() + OP_CALLDEST + push(0x2a) + OP_RETURNSUB; + execute(code); + EXPECT_STATUS(EVMC_SUCCESS); + EXPECT_OUTPUT_INT(0x2a); +} + +TEST_P(evm, return_stack_overflow) +{ + if (is_advanced()) + return; + rev = REV; + // Recursion with no base case: PUSH1 4, CALLSUB, STOP; CALLDEST, PUSH1 4, CALLSUB, RETURNSUB + execute("6004B000B16004B0B2"); + EXPECT_STATUS(EVMC_STACK_OVERFLOW); +} + +TEST_P(evm, return_stack_limit) +{ + if (is_advanced()) + return; + rev = REV; + // Counted recursion `depth` levels deep; the return stack holds depth + 1 entries. + // main: PUSH2 depth, PUSH1 sub, CALLSUB, STOP (7 bytes) + // sub: CALLDEST, DUP1, ISZERO, PUSH1 done, JUMPI, (6 bytes) + // PUSH1 1, SWAP1, SUB, PUSH1 sub, CALLSUB, RETURNSUB, (8 bytes) + // done: JUMPDEST, RETURNSUB (2 bytes) + const auto run = [&](uint64_t depth) { + constexpr uint64_t sub_pos = 7; + constexpr uint64_t done_pos = sub_pos + 14; + // push(depth) is a PUSH2 for both depths tested, so main is 7 bytes. + const auto code = push(depth) + push(sub_pos) + OP_CALLSUB + OP_STOP + // + OP_CALLDEST + OP_DUP1 + OP_ISZERO + push(done_pos) + OP_JUMPI + // + push(1) + OP_SWAP1 + OP_SUB + push(sub_pos) + OP_CALLSUB + + OP_RETURNSUB + // + OP_JUMPDEST + OP_RETURNSUB; + execute(1'000'000, code); + }; + run(1023); + EXPECT_STATUS(EVMC_SUCCESS); + run(1024); + EXPECT_STATUS(EVMC_STACK_OVERFLOW); +} + +TEST_P(evm, return_stack_reset_between_executions) +{ + if (is_advanced()) + return; + rev = REV; + // A frame that halts with return addresses pending leaves nothing behind for the + // next execution: the return stack belongs to the frame. + execute("6004B000B16004B0B2"); + EXPECT_STATUS(EVMC_STACK_OVERFLOW); + execute("B2"); + EXPECT_STATUS(EVMC_STACK_UNDERFLOW); +}