From eaf604358eddf3b0c11f7edf98685534e10fdda3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Mon, 24 Aug 2026 10:13:41 +0200 Subject: [PATCH 01/20] Implement EIP-8037: "State Creation Gas Cost Increase" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the Amsterdam two-dimensional gas model: state-creation costs move out of regular gas into a separate state-gas dimension, priced at COST_PER_STATE_BYTE (1530) per byte of new state. - evmc: add `state_gas` to the message and `state_gas_left`/`state_gas_spilled` to the result, threading a per-frame state-gas reservoir through the VM. - StateGas (state_gas.hpp): a (reservoir-left, spilled) pair. Charges draw from the reservoir first and spill into regular gas_left; refunds refill in LIFO order; a frame's net use derives as `initial - left + spilled`. Frames roll their state gas back on revert/halt (make_execution_result). - Charges at state-creation sites: new account by CREATE/CREATE2 (at the deployment-address access), by value-CALL — including the depth-0 value-transfer charge the EIP-2780 decomposition later builds on — and by SELFDESTRUCT to a new beneficiary (NEW_ACCOUNT = 120 bytes); SSTORE 0->non-zero slot allocation (64 bytes, with the 0->Y->0 LIFO refill; the regular set cost drops to its 2900 component); code deposit per byte. Failed creations refund the charge. Opcode CREATE and the create transaction keep the legacy 32000 execution cost here: EIP-8037 defers its execution component to EIP-8038's CREATE_ACCESS, and EIP-8038 states that the flat GAS_CREATE is what CREATE_ACCESS replaces, so the reprice lands with it. - Transaction processing: execution gas splits into a regular budget (capped by TX_MAX_GAS_LIMIT - intrinsic) and the state-gas reservoir. Amsterdam lifts the Osaka per-tx gas cap; validation instead caps the regular intrinsic and applies the per-dimension block-inclusion rules against the new block state-gas budget. - Block accounting: per-tx receipts carry regular/state components; block gas_used = max(sum_regular, sum_state) (EIP-7778 2D formula). - System calls get a separate 16-SSTORE state-gas reservoir so the state dimension cannot OOG them. The intrinsic cost otherwise keeps the pre-Amsterdam formula; the EIP-2780 resource decomposition lands separately. The EIP-7702 per-authorization state charges (AUTH_BASE and the authority's NEW_ACCOUNT) are not part of this commit: they are only expressible through the top-frame charging model that the EIP-2780 intrinsic decomposition introduces, so they land with it. The intrinsic keeps the pre-Amsterdam formula here. Includes the state-gas unit tests and the GAS_ALLOWANCE_EXCEEDED / BlockException.GAS_USED_OVERFLOW acceptance, which exists because the per-dimension inclusion checks keep an over-block-gas transaction a transaction-level rule. --- evmc/include/evmc/evmc.h | 26 +++- evmc/include/evmc/evmc.hpp | 2 + evmc/include/evmc/mocked_host.hpp | 7 +- lib/evmone/constants.hpp | 21 +++ lib/evmone/execution_state.hpp | 34 ++++- lib/evmone/instructions.hpp | 29 +++- lib/evmone/instructions_calls.cpp | 73 ++++++++- lib/evmone/instructions_storage.cpp | 27 +++- lib/evmone/state_gas.hpp | 75 ++++++++++ test/state/account.hpp | 7 + test/state/host.cpp | 139 ++++++++++++++++-- test/state/state.cpp | 121 ++++++++++++--- test/state/state.hpp | 4 +- test/state/system_contracts.cpp | 12 ++ test/state/transaction.hpp | 11 ++ test/unittests/CMakeLists.txt | 1 + test/unittests/state_transition.cpp | 7 +- test/unittests/state_transition.hpp | 4 + .../state_transition_create_test.cpp | 6 +- .../state_transition_eip8037_test.cpp | 109 ++++++++++++++ test/unittests/state_tx_test.cpp | 33 +++-- test/utils/block_transition.cpp | 23 ++- test/utils/error_matching.cpp | 4 + test/utils/statetest_runner.cpp | 3 +- test/utils/test_state.cpp | 6 +- test/utils/test_state.hpp | 7 +- 26 files changed, 712 insertions(+), 79 deletions(-) create mode 100644 lib/evmone/state_gas.hpp create mode 100644 test/unittests/state_transition_eip8037_test.cpp diff --git a/evmc/include/evmc/evmc.h b/evmc/include/evmc/evmc.h index 2807838ab1..cc8d2f1093 100644 --- a/evmc/include/evmc/evmc.h +++ b/evmc/include/evmc/evmc.h @@ -45,7 +45,7 @@ enum * * @see @ref versioning */ - EVMC_ABI_VERSION = 18 + EVMC_ABI_VERSION = 19 }; @@ -189,6 +189,13 @@ struct evmc_message * The length of the code to be executed. */ size_t code_size; + + /** + * The amount of state gas available (EIP-8037). + * + * It draws from a reservoir allocated at transaction level. + */ + int64_t state_gas; }; /** The transaction and block data for execution. */ @@ -455,6 +462,23 @@ struct evmc_result * function to the result itself allows VM composition. */ evmc_release_result_fn release; + + /** + * The amount of state gas left after execution (EIP-8037). + * + * Returned to the caller so it can restore its own state_gas tracking. + */ + int64_t state_gas_left; + + /** + * The portion of consumed state gas that spilled into gas_left (EIP-8037). + * + * Tracked so refunds and frame rollback restore gas in LIFO order: the + * spilled portion returns to gas_left, the rest to the reservoir + * (state_gas_left). On a successful child this accumulates into the + * caller; on revert/halt the frame refills itself before returning. + */ + int64_t state_gas_spilled; }; diff --git a/evmc/include/evmc/evmc.hpp b/evmc/include/evmc/evmc.hpp index 6eeb912e3a..f464703078 100644 --- a/evmc/include/evmc/evmc.hpp +++ b/evmc/include/evmc/evmc.hpp @@ -332,6 +332,8 @@ class Result : private evmc_result using evmc_result::gas_refund; using evmc_result::output_data; using evmc_result::output_size; + using evmc_result::state_gas_left; + using evmc_result::state_gas_spilled; using evmc_result::status_code; /// Creates the result from the provided arguments. diff --git a/evmc/include/evmc/mocked_host.hpp b/evmc/include/evmc/mocked_host.hpp index 7e785e0a7b..1876856fff 100644 --- a/evmc/include/evmc/mocked_host.hpp +++ b/evmc/include/evmc/mocked_host.hpp @@ -409,7 +409,12 @@ class MockedHost : public Host call_msg.input_data = input_copy.data(); } } - return Result{call_result}; + auto result = Result{call_result}; + // A zero state_gas_left means "the callee consumed the caller's whole reservoir". + // The mock runs no code, so echo the reservoir it was handed unless a test set one. + if (result.state_gas_left == 0) + result.state_gas_left = msg.state_gas; + return result; } /// Get transaction context (EVMC host method). diff --git a/lib/evmone/constants.hpp b/lib/evmone/constants.hpp index 6bbb9f9a6f..dd4d207c42 100644 --- a/lib/evmone/constants.hpp +++ b/lib/evmone/constants.hpp @@ -3,6 +3,8 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include + namespace evmone { /// The limit of the size of created contract @@ -27,4 +29,23 @@ constexpr auto MAX_NONCE = 0xffff'ffff'ffff'ffff; /// The gas given back to a value-transferring CALL, the Yellow Paper's G_callstipend. constexpr auto CALL_STIPEND = 2300; + +/// The fixed cost per state byte (EIP-8037). +constexpr int64_t COST_PER_STATE_BYTE = 1530; + +/// State bytes charged for creating a new account (EIP-8037). +constexpr int64_t STATE_BYTES_PER_NEW_ACCOUNT = 120; + +/// State bytes charged when a storage slot is newly allocated, i.e. SSTORE 0 -> non-zero +/// (EIP-8037). +constexpr int64_t STATE_BYTES_PER_STORAGE_SET = 64; + +/// State-gas cost of creating a new account: CREATE/CREATE2, CALL with value to a +/// nonexistent account, a new SELFDESTRUCT beneficiary (EIP-8037). +constexpr int64_t NEW_ACCOUNT_STATE_GAS = STATE_BYTES_PER_NEW_ACCOUNT * COST_PER_STATE_BYTE; + +/// State-gas cost of allocating a storage slot, i.e. SSTORE 0 -> non-zero (EIP-8037). +constexpr int64_t STORAGE_SET_STATE_GAS = STATE_BYTES_PER_STORAGE_SET * COST_PER_STATE_BYTE; + +// State-gas charging and refills live on the StateGas type (state_gas.hpp). } // namespace evmone diff --git a/lib/evmone/execution_state.hpp b/lib/evmone/execution_state.hpp index 9511612a7c..1bbb693ad3 100644 --- a/lib/evmone/execution_state.hpp +++ b/lib/evmone/execution_state.hpp @@ -3,6 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include "state_gas.hpp" #include #include #include @@ -154,6 +155,13 @@ class ExecutionState const advanced::AdvancedCodeAnalysis* advanced; } analysis{}; + /// The frame's state-gas reservoir + spill; used is derived (EIP-8037). + /// + /// Declared in the cold tail: inserting it earlier shifts `status` and `host` past the + /// x86-64 disp8 window, which costs 3 bytes of encoding on every one of the ~195 `status` + /// accesses in each dispatch loop. + StateGas state_gas; + /// Stack space allocation. /// /// This is the last field to make other fields' offsets of reasonable values. @@ -164,7 +172,11 @@ class ExecutionState ExecutionState(const evmc_message& message, evmc_revision revision, const evmc_host_interface& host_interface, evmc_host_context* host_ctx, bytes_view _code) noexcept - : msg{&message}, host{host_interface, host_ctx}, rev{revision}, original_code{_code} + : msg{&message}, + host{host_interface, host_ctx}, + rev{revision}, + original_code{_code}, + state_gas{.left = message.state_gas} {} /// Resets the contents of the ExecutionState so that it could be reused. @@ -173,6 +185,7 @@ class ExecutionState bytes_view _code) noexcept { gas_refund = 0; + state_gas = {.left = message.state_gas}; memory.clear(); msg = &message; host = {host_interface, host_ctx}; @@ -202,13 +215,30 @@ class ExecutionState /// success, and the output is the memory range recorded in the state. inline evmc_result make_execution_result(ExecutionState& state, int64_t gas_left) noexcept { + // A rolled-back frame created no state, so its net state gas used is zero: the reservoir is + // restored to the frame's budget and the spilled portion returns to `gas_left`, kept on a + // revert and consumed by the halt's gas_left = 0 below (EIP-8037). + if (state.rev >= EVMC_AMSTERDAM && state.status != EVMC_SUCCESS) + { + gas_left += state.state_gas.spilled; + state.state_gas.left = state.msg->state_gas; + state.state_gas.spilled = 0; + } + // An exceptional halt consumes all gas; only a success or revert keeps gas_left. if (state.status != EVMC_SUCCESS && state.status != EVMC_REVERT) gas_left = 0; const auto gas_refund = (state.status == EVMC_SUCCESS) ? state.gas_refund : 0; assert(state.output_size != 0 || state.output_offset == 0); - return evmc::make_result(state.status, gas_left, gas_refund, + auto result = evmc::make_result(state.status, gas_left, gas_refund, state.output_size != 0 ? &state.memory[state.output_offset] : nullptr, state.output_size); + + // Return the leftover reservoir and spill; the caller derives the net used as + // `initial - state_gas_left + state_gas_spilled` (EIP-8037). + assert(state.state_gas.left >= 0); + result.state_gas_left = state.state_gas.left; + result.state_gas_spilled = state.state_gas.spilled; + return result; } } // namespace evmone diff --git a/lib/evmone/instructions.hpp b/lib/evmone/instructions.hpp index 76c6a63216..1dccd6c9d6 100644 --- a/lib/evmone/instructions.hpp +++ b/lib/evmone/instructions.hpp @@ -4,6 +4,7 @@ #pragma once #include "baseline.hpp" +#include "constants.hpp" #include "execution_state.hpp" #include "instructions_traits.hpp" #include "instructions_xmacro.hpp" @@ -114,6 +115,21 @@ constexpr int64_t copy_cost(uint64_t size_in_bytes) noexcept return num_words(size_in_bytes) * WordCopyCost; } + +/// Threads a child frame's state gas back to the parent: take its leftover reservoir and +/// accumulate its spill. A failed child already rolled itself back at its boundary, so success +/// and failure are handled identically. With the child's reservoir merged in, a successful child +/// also repays the frame's outstanding spill from it, so a refill the child credited to the +/// reservoir reaches the `gas_left` that funded the matching charge (EIP-8037). +inline void accumulate_child_state_gas( + int64_t& gas_left, ExecutionState& state, const evmc::Result& result) noexcept +{ + state.state_gas.left = result.state_gas_left; + state.state_gas.spilled += result.state_gas_spilled; + if (result.status_code == EVMC_SUCCESS) + state.state_gas.repay_spill(gas_left); +} + /// Grows EVM memory and checks its cost. /// /// This function should not be inlined because this may affect other inlining decisions: @@ -1081,8 +1097,17 @@ inline TermResult selfdestruct(StackTop stack, int64_t gas_left, ExecutionState& // sending value to a non-existing account. if (!state.host.account_exists(beneficiary)) { - if ((gas_left -= 25000) < 0) - return {EVMC_OUT_OF_GAS, gas_left}; + if (state.rev >= EVMC_AMSTERDAM) + { + // The new account leaf is paid in state gas (EIP-8037). + if (!state.state_gas.charge(gas_left, NEW_ACCOUNT_STATE_GAS)) + return {EVMC_OUT_OF_GAS, gas_left}; + } + else + { + if ((gas_left -= 25000) < 0) + return {EVMC_OUT_OF_GAS, gas_left}; + } } } } diff --git a/lib/evmone/instructions_calls.cpp b/lib/evmone/instructions_calls.cpp index 97df42e064..c7dc1350c8 100644 --- a/lib/evmone/instructions_calls.cpp +++ b/lib/evmone/instructions_calls.cpp @@ -119,11 +119,29 @@ Result call_impl(StackTop stack, int64_t gas_left, ExecutionState& state) noexce const auto& code_addr = std::get(target_addr_or_result); + // State gas for creating the called account, i.e. a value-CALL to a nonexistent one. Tracked + // at function scope so every non-success exit below can refill it: a light failure or a child + // revert/halt undoes the account creation (EIP-8037). + int64_t new_account_state_gas = 0; + const auto refund_new_account_state_gas = [&]() noexcept { + if (new_account_state_gas != 0) + state.state_gas.refill(gas_left, new_account_state_gas); + }; + if constexpr (Op == OP_CALL) { if ((has_value || state.rev < EVMC_SPURIOUS_DRAGON) && !state.host.account_exists(dst)) { - if ((gas_left -= ACCOUNT_CREATION_COST) < 0) + if (state.rev >= EVMC_AMSTERDAM) + { + // The state charge comes after every regular cost of this instruction is + // committed (reservoir model), so a regular OOG cannot leave committed + // state growth behind. + new_account_state_gas = NEW_ACCOUNT_STATE_GAS; + if (!state.state_gas.charge(gas_left, new_account_state_gas)) + return {EVMC_OUT_OF_GAS, gas_left}; + } + else if ((gas_left -= ACCOUNT_CREATION_COST) < 0) return {EVMC_OUT_OF_GAS, gas_left}; } } @@ -171,12 +189,22 @@ Result call_impl(StackTop stack, int64_t gas_left, ExecutionState& state) noexce msg.gas += CALL_STIPEND; gas_left += CALL_STIPEND; if (intx::be::load(state.host.get_balance(state.msg->recipient)) < value) + { + refund_new_account_state_gas(); // No transfer, so no account created. return {EVMC_SUCCESS, gas_left}; // "Light" failure. + } } } if (state.msg->depth >= 1024) + { + refund_new_account_state_gas(); // Child never runs, so no account created. return {EVMC_SUCCESS, gas_left}; // "Light" failure. + } + + // The reservoir passes to the child in full; the 63/64 rule applies to gas_left only + // (EIP-8037). + msg.state_gas = state.state_gas.left; const auto result = state.host.call(msg); state.return_data.assign(result.output_data, result.output_size); @@ -188,6 +216,11 @@ Result call_impl(StackTop stack, int64_t gas_left, ExecutionState& state) noexce const auto gas_used = msg.gas - result.gas_left; gas_left -= gas_used; state.gas_refund += result.gas_refund; + // Thread the child's state gas back. A failed child rolls the created account back, so its + // NEW_ACCOUNT charge is refilled (EIP-8037). + accumulate_child_state_gas(gas_left, state, result); + if (result.status_code != EVMC_SUCCESS) + refund_new_account_state_gas(); return {EVMC_SUCCESS, gas_left}; } @@ -249,13 +282,33 @@ Result create_impl(StackTop stack, int64_t gas_left, ExecutionState& state) noex const auto init_code = bytes_view{init_code_size > 0 ? &state.memory[init_code_offset] : nullptr, init_code_size}; - evmc_message msg{.kind = to_call_kind(Op)}; - msg.recipient = (Op == OP_CREATE) ? compute_create_address(sender, sender_nonce) : - compute_create2_address(sender, salt, init_code); + // Compute the address of the account to be created. The Host bumps the sender's + // nonce on create-frame entry, so CREATE uses the pre-bump value read above. + const auto create_addr = (Op == OP_CREATE) ? compute_create_address(sender, sender_nonce) : + compute_create2_address(sender, salt, init_code); // Access to the new address is warmed and never reverted (EIP-2929). if (state.rev >= EVMC_BERLIN) - state.host.access_account(msg.recipient); + state.host.access_account(create_addr); + + // Charge NEW_ACCOUNT for a deployment onto a not-alive address (EIP-161), after warming and + // before the 63/64 split so a reservoir spill correctly lowers the gas forwarded to the + // child. Refilled below when no account is created (EIP-8037). + int64_t create_state_gas_charged = 0; + if (state.rev >= EVMC_AMSTERDAM) + { + // EIP-161 aliveness. account_exists() is the same predicate: its pre-Spurious-Dragon + // arm is unreachable under the Amsterdam gate, leaving `acc != nullptr && !is_empty()`. + if (!state.host.account_exists(create_addr)) + { + create_state_gas_charged = NEW_ACCOUNT_STATE_GAS; + if (!state.state_gas.charge(gas_left, create_state_gas_charged)) + return {EVMC_OUT_OF_GAS, gas_left}; + } + } + + evmc_message msg{.kind = to_call_kind(Op)}; + msg.recipient = create_addr; msg.gas = gas_left; if (state.rev >= EVMC_TANGERINE_WHISTLE) @@ -267,9 +320,19 @@ Result create_impl(StackTop stack, int64_t gas_left, ExecutionState& state) noex msg.depth = state.msg->depth + 1; msg.value = intx::be::store(endowment); + // The reservoir passes to the child in full; the 63/64 rule applies to gas_left only + // (EIP-8037). + msg.state_gas = state.state_gas.left; + const auto result = state.host.call(msg); gas_left -= msg.gas - result.gas_left; state.gas_refund += result.gas_refund; + // Thread the child's state gas back. A non-success result — a rolled-back initcode or an + // address collision — creates no account, so its NEW_ACCOUNT charge is refilled; a create + // onto an already-alive account was never charged (EIP-8037). + accumulate_child_state_gas(gas_left, state, result); + if (create_state_gas_charged != 0 && result.status_code != EVMC_SUCCESS) + state.state_gas.refill(gas_left, create_state_gas_charged); state.return_data.assign(result.output_data, result.output_size); if (result.status_code == EVMC_SUCCESS) diff --git a/lib/evmone/instructions_storage.cpp b/lib/evmone/instructions_storage.cpp index 58d91dea73..9648dcb714 100644 --- a/lib/evmone/instructions_storage.cpp +++ b/lib/evmone/instructions_storage.cpp @@ -42,7 +42,8 @@ constexpr auto storage_cost_spec = []() noexcept { tbl[EVMC_PRAGUE] = tbl[EVMC_LONDON]; tbl[EVMC_OSAKA] = tbl[EVMC_LONDON]; tbl[EVMC_AMSTERDAM] = tbl[EVMC_LONDON]; - tbl[EVMC_EXPERIMENTAL] = tbl[EVMC_LONDON]; + tbl[EVMC_AMSTERDAM].set = 2900; // EIP-8037: regular component only (was 20000). + tbl[EVMC_EXPERIMENTAL] = tbl[EVMC_AMSTERDAM]; return tbl; }(); @@ -51,6 +52,9 @@ struct StorageStoreCost { int16_t gas_cost; int16_t gas_refund; + /// State gas for the slot allocation: positive to charge, negative to refill, zero before + /// Amsterdam. Wider than int16_t because 64 * COST_PER_STATE_BYTE is 97'920 (EIP-8037). + int32_t state_gas = 0; }; // The lookup table of SSTORE costs by the storage update status. @@ -89,6 +93,14 @@ constexpr auto sstore_costs = []() noexcept { e[EVMC_STORAGE_MODIFIED_RESTORED] = { c.warm_access, static_cast(c.reset - c.warm_access)}; } + + // Allocating a slot (0 -> non-zero) costs state gas; undoing it in the same + // transaction (0 -> Y -> 0) refills it (EIP-8037). + if (rev >= EVMC_AMSTERDAM) + { + e[EVMC_STORAGE_ADDED].state_gas = STORAGE_SET_STATE_GAS; + e[EVMC_STORAGE_ADDED_DELETED].state_gas = -STORAGE_SET_STATE_GAS; + } } return tbl; @@ -134,10 +146,21 @@ Result sstore(StackTop stack, int64_t gas_left, ExecutionState& state) noexcept 0; const auto status = state.host.set_storage(state.msg->recipient, key, value); - const auto [gas_cost_warm, gas_refund] = sstore_costs[state.rev][status]; + const auto [gas_cost_warm, gas_refund, state_gas] = sstore_costs[state.rev][status]; const auto gas_cost = gas_cost_warm + gas_cost_cold; + + // A refill (0 -> Y -> 0) is applied BEFORE the regular charge, as in EELS, so gas returned + // to gas_left from a prior spill can fund that charge (EIP-8037). + if (state_gas < 0) + state.state_gas.refill(gas_left, -state_gas); + + // Charge regular gas FIRST, then state gas: this order prevents a state-gas spill from + // counting committed state growth behind a subsequent regular OOG (EIP-8037). if ((gas_left -= gas_cost) < 0) return {EVMC_OUT_OF_GAS, gas_left}; + + if (!state.state_gas.charge(gas_left, state_gas)) + return {EVMC_OUT_OF_GAS, gas_left}; state.gas_refund += gas_refund; return {EVMC_SUCCESS, gas_left}; } diff --git a/lib/evmone/state_gas.hpp b/lib/evmone/state_gas.hpp new file mode 100644 index 0000000000..c2567cf4d6 --- /dev/null +++ b/lib/evmone/state_gas.hpp @@ -0,0 +1,75 @@ +// evmone: Fast Ethereum Virtual Machine implementation +// Copyright 2026 The evmone Authors. +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +namespace evmone +{ +/// A frame's state gas as a (reservoir-left, spilled) pair, metered independently +/// from the regular `gas_left` (EIP-8037). +/// +/// `left` is the remaining reservoir a frame draws state-gas charges from; +/// `spilled` is the portion of those charges that had to draw from `gas_left` +/// because the reservoir was insufficient. `spilled` is tracked so refunds and +/// frame rollback restore the exact pools the charge drew from, in LIFO order. +/// +/// The net state gas a frame (and its children) consumed is not stored — it is +/// derived from the frame's initial reservoir: `used = initial - left + spilled`. +/// This holds across nested calls because a child's initial reservoir is the +/// parent's `left` at call time. +struct StateGas +{ + int64_t left = 0; ///< Remaining state-gas reservoir (`state_gas_reservoir`). + int64_t spilled = 0; ///< Consumed state gas that drew from `gas_left`. + + /// Charges `cost`, drawing from the reservoir first and spilling any remainder into the + /// regular `gas_left`. Atomic: returns false without mutating any field when neither pool + /// can cover the cost. + [[nodiscard]] bool charge(int64_t& gas_left, int64_t cost) noexcept + { + if (cost <= 0) + return true; + if (left >= cost) + { + left -= cost; + return true; + } + const auto spill = cost - left; + if (gas_left < spill) + return false; + gas_left -= spill; + spilled += spill; + left = 0; + return true; + } + + /// Credits a `cost` refund in LIFO order: the pool charged last is refilled first — + /// `gas_left` up to `spilled`, then the reservoir — so the refund restores the exact + /// pools the matching charge drew from. + void refill(int64_t& gas_left, int64_t cost) noexcept + { + const auto from_gas_left = std::min(cost, spilled); + gas_left += from_gas_left; + spilled -= from_gas_left; + left += cost - from_gas_left; + } + + /// Returns reservoir gas to `gas_left`, up to the spill still outstanding. + /// + /// A refill need not land in the frame whose charge spilled: a slot's original value is the + /// value at transaction start, so a frame may clear a slot an earlier frame allocated. The + /// credit then sits in the reservoir while the `gas_left` that funded the charge stays + /// reduced. Applied when a child merges, this moves the credit up to the first frame with an + /// outstanding spill. It undoes no state creation, so the net state gas used is unchanged. + void repay_spill(int64_t& gas_left) noexcept + { + const auto amount = std::min(left, spilled); + gas_left += amount; + left -= amount; + spilled -= amount; + } +}; +} // namespace evmone diff --git a/test/state/account.hpp b/test/state/account.hpp index b135ec7c04..4da03edc47 100644 --- a/test/state/account.hpp +++ b/test/state/account.hpp @@ -97,4 +97,11 @@ struct Account return nonce == 0 && balance == 0 && code_hash == EMPTY_CODE_HASH; } }; + +/// Whether a looked-up account is alive, i.e. has a state leaf: it exists and is not empty +/// (EIP-161). A null pointer is a non-existent account. +[[nodiscard]] inline bool is_alive(const Account* account) noexcept +{ + return account != nullptr && !account->is_empty(); +} } // namespace evmone::state diff --git a/test/state/host.cpp b/test/state/host.cpp index bbefb81ea5..f4c86063a9 100644 --- a/test/state/host.cpp +++ b/test/state/host.cpp @@ -6,9 +6,21 @@ #include "precompiles.hpp" #include "system_contracts.hpp" #include +#include namespace evmone::state { +namespace +{ +/// Sets the state-gas fields on a returned Result. `used` is not stored; the caller derives it +/// as `initial - left + spilled` (EIP-8037). +void set_state_gas(evmc::Result& r, int64_t left, int64_t spilled) noexcept +{ + r.state_gas_left = left; + r.state_gas_spilled = spilled; +} +} // namespace + bool Host::account_exists(const address& addr) const noexcept { const auto* const acc = m_state.find(addr); @@ -183,6 +195,9 @@ evmc::Result Host::create(const evmc_message& msg) noexcept // TODO: find()+insert() probes m_modified twice for a new recipient. auto* new_acc = m_state.find(msg.recipient); + // The created account's NEW_ACCOUNT state gas is charged at this access when the deployment + // address has no leaf; captured before any mutation (EIP-8037, EIP-161, EELS #3126). + const bool target_alive = is_alive(new_acc); if (new_acc == nullptr) { new_acc = &m_state.insert(msg.recipient); @@ -191,7 +206,10 @@ evmc::Result Host::create(const evmc_message& msg) noexcept else { if (is_create_collision(*new_acc)) - return evmc::Result{EVMC_FAILURE}; // TODO: Add EVMC errors for creation failures. + { + // TODO: Add EVMC errors for creation failures. + return evmc::Result{EVMC_FAILURE}; + } m_state.journal_create(msg.recipient); } @@ -217,10 +235,31 @@ evmc::Result Host::create(const evmc_message& msg) noexcept auto create_msg = msg; create_msg.input_data = nullptr; create_msg.input_size = 0; + + // The create frame's state gas, held across the initcode execution: the depth-0 tx-level + // create charges the created account's NEW_ACCOUNT here (the opcode CREATE charges it in + // create_impl), the initcode frame's pools merge back in below, and the code deposit draws + // from the total (charge-at-access, EELS #3126) (EIP-8037). + StateGas state_gas{.left = create_msg.state_gas}; + if (m_rev >= EVMC_AMSTERDAM && msg.depth == 0 && !target_alive) + { + if (!state_gas.charge(create_msg.gas, NEW_ACCOUNT_STATE_GAS)) + return evmc::Result{EVMC_OUT_OF_GAS}; + create_msg.state_gas = state_gas.left; + } + const bytes_view initcode{msg.input_data, msg.input_size}; auto result = m_vm.execute(*this, m_rev, create_msg, initcode.data(), initcode.size()); if (result.status_code != EVMC_SUCCESS) + { + // No account created, so the NEW_ACCOUNT charge is refunded: Host::call restores the + // reservoir portion, while the spilled portion returns to gas only on a revert — an + // exceptional halt consumes it as regular gas (matches EELS refill_frame_state_gas then + // gas_left = 0). + if (result.status_code == EVMC_REVERT) + result.gas_left += state_gas.spilled; return result; + } auto gas_left = result.gas_left; assert(gas_left >= 0); @@ -235,14 +274,33 @@ evmc::Result Host::create(const evmc_message& msg) noexcept if (m_rev >= EVMC_LONDON && code.starts_with(0xEF)) return evmc::Result{EVMC_CONTRACT_VALIDATION_FAILURE}; - // Code deployment cost. - const auto cost = std::ssize(code) * 200; - gas_left -= cost; - if (gas_left < 0) + // Merge the initcode frame's pools back, keeping the NEW_ACCOUNT charge's spill so the + // created account's state gas is reported on success. + state_gas.left = result.state_gas_left; + state_gas.spilled += result.state_gas_spilled; + if (m_rev >= EVMC_AMSTERDAM) { - return (m_rev == EVMC_FRONTIER) ? - evmc::Result{EVMC_SUCCESS, result.gas_left, result.gas_refund} : - evmc::Result{EVMC_FAILURE}; + // The code deposit splits into a regular and a state component (EIP-8037). + const auto regular_cost = 6 * ((std::ssize(code) + 31) / 32); + const auto state_cost = std::ssize(code) * COST_PER_STATE_BYTE; + gas_left -= regular_cost; + if (gas_left < 0 || !state_gas.charge(gas_left, state_cost)) + return evmc::Result{EVMC_FAILURE}; + } + else + { + const auto cost = std::ssize(code) * 200; + gas_left -= cost; + if (gas_left < 0) + { + if (m_rev == EVMC_FRONTIER) + { + auto r = evmc::Result{EVMC_SUCCESS, result.gas_left, result.gas_refund}; + set_state_gas(r, state_gas.left, state_gas.spilled); + return r; + } + return evmc::Result{EVMC_FAILURE}; + } } if (!code.empty()) @@ -252,7 +310,9 @@ evmc::Result Host::create(const evmc_message& msg) noexcept new_acc->code_changed = true; } - return evmc::Result{result.status_code, gas_left, result.gas_refund}; + auto r = evmc::Result{result.status_code, gas_left, result.gas_refund}; + set_state_gas(r, state_gas.left, state_gas.spilled); + return r; } evmc::Result Host::execute_message(const evmc_message& msg) noexcept @@ -260,6 +320,32 @@ evmc::Result Host::execute_message(const evmc_message& msg) noexcept if (msg.kind == EVMC_CREATE || msg.kind == EVMC_CREATE2) return create(msg); + // The frame's regular gas: the depth-0 state charge below can spill into it, so it is not + // `msg.gas` for the rest of the function. + auto gas = msg.gas; + + // TODO: This depth-0 charge belongs to the transaction pre-execution phase in transition(), + // beside the EIP-7702 authorizations it follows, not in the per-frame dispatcher. Moving it + // drops the `msg.depth == 0` special cases here and the gas plumbed around them. + // A top-level value transfer pays NEW_ACCOUNT for the recipient it materializes, evaluated + // against the pre-transfer state, after the authorizations and before any opcode. Charged + // here rather than in the interpreter because such a transfer runs no code (EIP-8037). + // `msg.state_gas` stays the entry reservoir while `top_level_sg` holds the post-charge pools, + // which a consuming path commits on success; on failure Host::call restores them. + StateGas top_level_sg{.left = msg.state_gas}; + if (m_rev >= EVMC_AMSTERDAM && msg.depth == 0) + { + const auto recipient_alive = is_alive(m_state.find(msg.recipient)); + if (!evmc::is_zero(msg.value) && !recipient_alive) + { + // A new account is materialized by the value transfer: pay NEW_ACCOUNT state gas. + // This includes a previously-zero-balance precompile (EIP-161): funding it + // creates a state account just like any other recipient. + if (!top_level_sg.charge(gas, NEW_ACCOUNT_STATE_GAS)) + return evmc::Result{EVMC_OUT_OF_GAS, 0}; + } + } + if (msg.kind == EVMC_CALL) { auto* recipient_acc = m_state.find(msg.recipient); @@ -296,13 +382,37 @@ evmc::Result Host::execute_message(const evmc_message& msg) noexcept // Calls to precompile address via EIP-7702 delegation execute empty code instead of precompile. if ((msg.flags & EVMC_DELEGATED) == 0 && is_precompile(m_rev, msg.code_address)) - return call_precompile(m_rev, msg); + { + auto precompile_msg = msg; + precompile_msg.gas = gas; + auto r = call_precompile(m_rev, precompile_msg); + // A precompile consumes no execution state gas, but funding a zero-balance one paid + // NEW_ACCOUNT above: on success the account persists so the charge is committed, on + // failure nothing persists and Host::call refills it (EIP-8037, EIP-2780). + if (r.status_code == EVMC_SUCCESS) + set_state_gas(r, top_level_sg.left, top_level_sg.spilled); + return r; + } // TODO: get_code() performs the account lookup. Add a way to get an account with code? const auto code = m_state.get_code(msg.code_address); if (code.empty()) - return evmc::Result{EVMC_SUCCESS, msg.gas}; // Skip trivial execution. + { + auto r = evmc::Result{EVMC_SUCCESS, gas}; // Skip trivial execution. + // An empty-code call consumes no execution state gas, but the value transfer above may + // have paid NEW_ACCOUNT: commit those pools, a no-op when nothing was charged. + set_state_gas(r, top_level_sg.left, top_level_sg.spilled); + return r; + } + // The depth-0 charge cannot reach here: it implies a not-alive recipient, which has empty + // code and returned above. Asserted rather than carried out, because it must be refilled on + // failure while the authorization charges beside it must survive one. + // TODO: The premise couples two addresses that coincide only by convention: liveness is read + // from `msg.recipient`, code from `msg.code_address`, and they differ under EVMC_DELEGATED. + // Should they ever part, NDEBUG turns this into a silently dropped charge and an under-paid + // transaction. Moving the charge to transition() (TODO above) removes the coupling. + assert(gas == msg.gas && top_level_sg.left == msg.state_gas && top_level_sg.spilled == 0); return m_vm.execute(*this, m_rev, msg, code.data(), code.size()); } @@ -324,6 +434,13 @@ evmc::Result Host::call(const evmc_message& msg) noexcept if (result.status_code != EVMC_SUCCESS) { + // A rolled-back frame created no state, so it carries no state gas out: restore the entry + // reservoir and drop the spill, which the frame either returned to its own gas_left + // (revert) or consumed with it (halt). Enforced here for every failure path, including + // the ones this Host builds itself (EIP-8037). + result.state_gas_left = msg.state_gas; + result.state_gas_spilled = 0; + // The 0x03 (RIPEMD-160) touch quirk: a touch on this address is // never reverted. It only matters when the account is empty, so gate it by rev range. static constexpr auto ADDR_03 = 0x03_address; diff --git a/test/state/state.cpp b/test/state/state.cpp index 1046e295a5..441f26643b 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -211,6 +211,7 @@ evmc_message build_message(const Transaction& tx, int64_t execution_gas_limit) n .code_address = recipient, .code = nullptr, .code_size = 0, + .state_gas = 0, // Set by the caller for Amsterdam+. }; } } // namespace @@ -436,7 +437,7 @@ void State::rollback(size_t checkpoint) /// @return Execution gas limit or transaction validation error. std::variant validate_transaction( const StateView& state_view, const BlockInfo& block, const Transaction& tx, evmc_revision rev, - int64_t block_gas_left, int64_t blob_gas_left) noexcept + int64_t block_gas_left, int64_t blob_gas_left, int64_t state_block_gas_left) noexcept { if (tx.chain_id_protected() && tx.chain_id != block.chain_id) return make_error_code(INVALID_CHAIN_ID); @@ -497,11 +498,29 @@ std::variant validate_transaction( assert(tx.max_priority_gas_price <= tx.max_gas_price); - if (rev >= EVMC_OSAKA && tx.gas_limit > MAX_TX_GAS_LIMIT) + // The per-tx gas-limit cap is lifted again by EIP-8037; the reservoir model instead caps the + // regular-gas intrinsic and the per-dimension block inclusion below. + if (rev >= EVMC_OSAKA && rev < EVMC_AMSTERDAM && tx.gas_limit > MAX_TX_GAS_LIMIT) return make_error_code(GAS_LIMIT_EXCEEDS_MAXIMUM); - if (tx.gas_limit > block_gas_left) - return make_error_code(GAS_ALLOWANCE_EXCEEDED); + // The tx must fit in the block's remaining gas. Checked ahead of the sender's nonce and + // balance, matching the pre-existing order. Note EELS check_transaction runs the whole of + // validate_transaction (including the intrinsic checks below) before this, so a transaction + // invalid in several ways can report a different one of them here. + if (rev < EVMC_AMSTERDAM) + { + if (tx.gas_limit > block_gas_left) + return make_error_code(GAS_ALLOWANCE_EXCEEDED); + } + else + { + // A per-dimension worst-case check on bare `tx.gas`, with no intrinsic subtraction + // (EIP-8037 inclusion rule 2). + if (std::min(MAX_TX_GAS_LIMIT, tx.gas_limit) > block_gas_left) + return make_error_code(GAS_ALLOWANCE_EXCEEDED); + if (tx.gas_limit > state_block_gas_left) + return make_error_code(GAS_ALLOWANCE_EXCEEDED); + } if (tx.max_gas_price < block.base_fee) return make_error_code(INSUFFICIENT_MAX_FEE_PER_GAS); @@ -546,11 +565,26 @@ std::variant validate_transaction( return make_error_code(INSUFFICIENT_ACCOUNT_FUNDS); const auto [intrinsic_cost, min_cost] = compute_tx_intrinsic_cost(rev, tx); + + // max(intrinsic_regular_gas, calldata_floor_gas_cost) <= TX_MAX_GAS_LIMIT + // (EIP-8037 §"Transaction validation" condition 1). + // Amsterdam lifts the per-tx cap on tx.gas_limit (above) but keeps this + // cap on the regular-gas intrinsic so that the reservoir-model invariant + // regular_gas_budget = TX_MAX_GAS_LIMIT - intrinsic_regular_gas + // stays non-negative. EELS validate_transaction bounds `intrinsic.execution` and + // `intrinsic.calldata_floor` against TX_MAX_GAS_LIMIT separately; `max()` of the two is + // the same condition. + // The framework maps this to INTRINSIC_GAS_TOO_LOW (the tx can't pay + // its intrinsic within the reservoir bound), not the Osaka-era + // GAS_LIMIT_EXCEEDS_MAXIMUM. + if (rev >= EVMC_AMSTERDAM && std::max(intrinsic_cost, min_cost) > MAX_TX_GAS_LIMIT) + return make_error_code(INTRINSIC_GAS_TOO_LOW); + if (tx.gas_limit < std::max(intrinsic_cost, min_cost)) return make_error_code(INTRINSIC_GAS_TOO_LOW); const auto execution_gas_limit = tx.gas_limit - intrinsic_cost; - return TransactionProperties{execution_gas_limit, min_cost}; + return TransactionProperties{execution_gas_limit, intrinsic_cost, min_cost}; } StateDiff finalize(const StateView& state_view, evmc_revision rev, const address& coinbase, @@ -646,32 +680,75 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc } } - const auto result = host.call(message); - - const auto gas_used_b4_refund = tx.gas_limit - result.gas_left; + // Split execution gas into a regular budget and a state-gas reservoir. The intrinsic — regular + // only, the state-dependent charges being applied at the top frame — is already subtracted + // from gas_limit (EIP-8037). + // regular = min(MAX_TX_GAS_LIMIT - intrinsic_regular, exec_gas); reservoir = exec_gas - + // regular. + if (rev >= EVMC_AMSTERDAM) + { + const auto exec_gas = tx_props.execution_gas_limit; + const auto regular_cap = std::max( + int64_t{0}, static_cast(MAX_TX_GAS_LIMIT) - tx_props.intrinsic_regular_gas); + const auto regular_exec = std::min(exec_gas, regular_cap); + message.gas = regular_exec; + message.state_gas = exec_gas - regular_exec; + } - const auto max_refund_quotient = rev >= EVMC_LONDON ? 5 : 2; - const auto refund_limit = gas_used_b4_refund / max_refund_quotient; - const auto refund = std::min(delegation_refund + result.gas_refund, refund_limit); - auto gas_used = gas_used_b4_refund - refund; - assert(gas_used > 0); + const auto result = host.call(message); - // The gas used by the transaction must be at least the min_gas_cost (EIP-7623). - gas_used = std::max(gas_used, tx_props.min_gas_cost); + // Net state gas consumed by the execution, derived from the reservoir the top frame was + // handed: initial - left + spilled. Zero on a top-level failure, the frame having refilled + // itself. Clamped at 0 defensively (EIP-8037). + const auto exec_state_gas = + std::max(0, message.state_gas - result.state_gas_left + result.state_gas_spilled); - // For block gas accounting, compute the gas refund capped by the min gas cost (EIP-7778). - const auto block_gas_used = std::max(gas_used_b4_refund, tx_props.min_gas_cost); - const auto gas_refund = block_gas_used - gas_used; + // Gas consumed = gas_limit - regular_unspent - reservoir_unspent, pre-refund and pre-floor. + // Kept immutable: the receipt's gas_refund is derived from it (EIP-8037). + const auto gas_used_b4_refund = tx.gas_limit - result.gas_left - result.state_gas_left; - sender_acc.balance += tx_max_cost - gas_used * effective_gas_price; - state.touch(block.coinbase).balance += gas_used * priority_gas_price; + // The refund is capped at 1/5 of the gas consumed (1/2 before EIP-3529). The sender pays the + // rest, floored at the EIP-7623 calldata floor (EELS: max(before_refund - refund, floor)). + const auto refund_limit = gas_used_b4_refund / (rev >= EVMC_LONDON ? 5 : 2); + const auto refund = std::min(delegation_refund + result.gas_refund, refund_limit); + assert(gas_used_b4_refund - refund > 0); + // The post-refund, post-floor gas the sender pays for (== receipt gas_used). + const auto sender_gas_cost = std::max(gas_used_b4_refund - refund, tx_props.min_gas_cost); + + // The block's 2D gas components (EIP-7778): pre-Amsterdam the block tracks a single + // dimension, so all of the gas the sender paid for is regular. + auto regular_block_gas = sender_gas_cost; + int64_t state_block_gas = 0; + if (rev >= EVMC_AMSTERDAM) + { + // `exec_state_gas` captures all state gas and the intrinsic state gas is zero, so the + // remainder — including any CREATE-collision burned gas — is the regular component, + // floored at the calldata floor so state-gas spending cannot discount it (EELS: + // max(before_refund - state, floor)) (EIP-7778, EIP-8037). + state_block_gas = exec_state_gas; + regular_block_gas = std::max(gas_used_b4_refund - exec_state_gas, tx_props.min_gas_cost); + } + sender_acc.balance += tx_max_cost - sender_gas_cost * effective_gas_price; + state.touch(block.coinbase).balance += sender_gas_cost * priority_gas_price; // Cumulative gas used is unknown in this scope. - TransactionReceipt receipt{tx.type, result.status_code, gas_used, gas_refund, {}, - host.take_logs(), {}, state.build_diff(rev)}; + TransactionReceipt receipt{}; + receipt.type = tx.type; + receipt.status = result.status_code; + // Receipt gas_used = what the sender paid for: post-refund, floored at the + // EIP-7623 calldata floor. + receipt.gas_used = sender_gas_cost; + receipt.regular_block_gas = regular_block_gas; + receipt.state_block_gas = state_block_gas; + // Per-tx refund applied to receipt.gas_used: the floored pre-refund gas minus what the + // sender paid, so gas_used + gas_refund is the pre-refund gas the block accumulates + // (EIP-7778) and never goes negative when the calldata floor binds. + receipt.gas_refund = std::max(gas_used_b4_refund, tx_props.min_gas_cost) - receipt.gas_used; + receipt.logs = host.take_logs(); // Cannot put it into constructor call because logs are std::moved from host instance. receipt.logs_bloom_filter = compute_bloom_filter(receipt.logs); + receipt.state_diff = state.build_diff(rev); return receipt; } diff --git a/test/state/state.hpp b/test/state/state.hpp index 7240704fcc..50aa811dad 100644 --- a/test/state/state.hpp +++ b/test/state/state.hpp @@ -143,8 +143,10 @@ TransactionReceipt transition(const StateView& state, const BlockInfo& block, /// Validate a transaction. /// +/// @param state_block_gas_left Pre-Amsterdam: ignored. Amsterdam+: remaining +/// block state-gas budget (EIP-8037). /// @return Computed execution gas limit or validation error. [[nodiscard]] std::variant validate_transaction( const StateView& state_view, const BlockInfo& block, const Transaction& tx, evmc_revision rev, - int64_t block_gas_left, int64_t blob_gas_left) noexcept; + int64_t block_gas_left, int64_t blob_gas_left, int64_t state_block_gas_left) noexcept; } // namespace evmone::state diff --git a/test/state/system_contracts.cpp b/test/state/system_contracts.cpp index 7a5cd431ae..7b41d7dece 100644 --- a/test/state/system_contracts.cpp +++ b/test/state/system_contracts.cpp @@ -6,6 +6,7 @@ #include "errors.hpp" #include "host.hpp" #include "state_view.hpp" +#include namespace evmone::state { @@ -73,10 +74,19 @@ static_assert(std::ranges::is_sorted(REQUESTS_SYSTEM_CONTRACTS, by_rev), "system contract entries must be ordered by revision"); +/// Cap on the number of SSTOREs a system contract may fund out of its state-gas budget. The value +/// is observable: `system_contract_reaches_gas_limit` sizes a contract to exactly +/// `30M + SYSTEM_MAX_SSTORES_PER_CALL × STORAGE_SET_STATE_GAS` (EIP-8037). +constexpr int64_t SYSTEM_MAX_SSTORES_PER_CALL = 16; + evmc::Result execute_system_call(State& state, const BlockInfo& block, const BlockHashes& block_hashes, evmc_revision rev, evmc::VM& vm, const address& addr, bytes_view code, bytes_view input) { + // A system call gets a state reservoir covering `SYSTEM_MAX_SSTORES_PER_CALL` zero→non-zero + // SSTOREs, so state gas cannot OOG it. The reservoir is separate from the 30M regular + // gas_left, which the GAS opcode, the 63/64 forwarding base and a >30M regular-gas burn all + // observe (EIP-8037 §"System contracts and system transactions"). const evmc_message msg{ .kind = EVMC_CALL, .gas = 30'000'000, @@ -84,6 +94,8 @@ evmc::Result execute_system_call(State& state, const BlockInfo& block, .sender = SYSTEM_ADDRESS, .input_data = input.data(), .input_size = input.size(), + .state_gas = + (rev >= EVMC_AMSTERDAM) ? SYSTEM_MAX_SSTORES_PER_CALL * STORAGE_SET_STATE_GAS : 0, }; const Transaction empty_tx{}; diff --git a/test/state/transaction.hpp b/test/state/transaction.hpp index 70879aec1c..524f545db6 100644 --- a/test/state/transaction.hpp +++ b/test/state/transaction.hpp @@ -104,6 +104,10 @@ struct TransactionProperties /// The amount of gas provided to the EVM for the transaction execution. int64_t execution_gas_limit = 0; + /// The regular portion of the intrinsic cost (EIP-8037 keeps the state-dependent charges out + /// of the intrinsic; they are charged at the top frame). + int64_t intrinsic_regular_gas = 0; + /// The minimal amount of gas the transaction must use. int64_t min_gas_cost = 0; }; @@ -138,6 +142,13 @@ struct TransactionReceipt /// Amount of gas used by this and previous transactions in the block. int64_t cumulative_gas_used = 0; + + /// 2D per-tx block-gas components. The runner aggregates as + /// `block.gas_used = max(sum_regular, sum_state)` (EIP-7778). Pre-Amsterdam the block has a + /// single dimension: the regular component is `gas_used` and the state one is 0 (EIP-8037). + int64_t regular_block_gas = 0; ///< Regular gas component. + int64_t state_block_gas = 0; ///< State gas component. + std::vector logs; BloomFilter logs_bloom_filter; StateDiff state_diff; diff --git a/test/unittests/CMakeLists.txt b/test/unittests/CMakeLists.txt index 3bfc1410a4..7ab08ef1df 100644 --- a/test/unittests/CMakeLists.txt +++ b/test/unittests/CMakeLists.txt @@ -71,6 +71,7 @@ target_sources( state_transition_create_test.cpp state_transition_eip7702_test.cpp state_transition_eip7778_block_gas_test.cpp + state_transition_eip8037_test.cpp state_transition_extcode_test.cpp state_transition_selfdestruct_test.cpp state_transition_snippets_test.cpp diff --git a/test/unittests/state_transition.cpp b/test/unittests/state_transition.cpp index e80144986b..aaf025d206 100644 --- a/test/unittests/state_transition.cpp +++ b/test/unittests/state_transition.cpp @@ -61,7 +61,8 @@ void state_transition::TearDown() // After EVMC_PRAGUE, get_blob_params will not work like that without a blob schedule. // TODO: add a blob schedule to use with state_transition tests, should they be added. const auto res = test::transition(state, block, block_hashes, tx, rev, selected_vm, - block.gas_limit, static_cast(state::max_blob_gas_per_block(get_blob_params(rev)))); + block.gas_limit, static_cast(state::max_blob_gas_per_block(get_blob_params(rev))), + block.gas_limit); test::finalize(state, rev, block.coinbase, block_reward, block.ommers, block.withdrawals); const auto& post = state; @@ -101,6 +102,10 @@ void state_transition::TearDown() << "log " << i << " topics"; } } + if (expect.state_gas.has_value()) + { + EXPECT_EQ(receipt.state_block_gas, *expect.state_gas); + } // Update default expectations - valid transaction means coinbase exists unless explicitly // requested otherwise if (!expect.post.contains(Coinbase)) diff --git a/test/unittests/state_transition.hpp b/test/unittests/state_transition.hpp index 90a263ffeb..72d48c0cd2 100644 --- a/test/unittests/state_transition.hpp +++ b/test/unittests/state_transition.hpp @@ -71,6 +71,10 @@ class state_transition : public ExportableFixture /// exactly: count, address, data, topics, and order. std::optional> logs; + /// The expected EIP-8037 state-gas component of the receipt (`state_block_gas`), + /// e.g. a NEW_ACCOUNT_STATE_GAS charge that survives a light failure. + std::optional state_gas; + /// The expected post-execution state. std::unordered_map post; diff --git a/test/unittests/state_transition_create_test.cpp b/test/unittests/state_transition_create_test.cpp index e004e5ad9e..47d597f233 100644 --- a/test/unittests/state_transition_create_test.cpp +++ b/test/unittests/state_transition_create_test.cpp @@ -431,7 +431,8 @@ TEST_F(state_transition, eip7954_create_tx_at_max_code_size) // A create transaction deploying code of exactly the new limit succeeds. rev = EVMC_AMSTERDAM; static constexpr auto code_size = 0x10000; // MAX_CODE_SIZE_AMSTERDAM. - tx.gas_limit = 16'000'000; // Covers the ~13.1M code-deposit gas (200/byte). + // Covers the ~100M code-deposit state gas (COST_PER_STATE_BYTE per byte, EIP-8037). + tx.gas_limit = 110'000'000; block.gas_limit = tx.gas_limit; pre[Sender].balance = tx.gas_limit * tx.max_gas_price; tx.data = ret(0, code_size); // Init code returns `code_size` zero bytes as the deployed code. @@ -445,7 +446,8 @@ TEST_F(state_transition, eip7954_create_tx_above_max_code_size) // Code one byte above the new 0x10000 limit is still rejected on Amsterdam (EIP-7954). rev = EVMC_AMSTERDAM; static constexpr auto code_size = 0x10000 + 1; - tx.gas_limit = 16'000'000; // Enough to deposit the code, so only the limit can reject it. + // Enough to deposit the code, so only the limit can reject it. + tx.gas_limit = 110'000'000; block.gas_limit = tx.gas_limit; pre[Sender].balance = tx.gas_limit * tx.max_gas_price; tx.data = ret(0, code_size); // Init code returns code one byte over the limit. diff --git a/test/unittests/state_transition_eip8037_test.cpp b/test/unittests/state_transition_eip8037_test.cpp new file mode 100644 index 0000000000..cb0fadeffd --- /dev/null +++ b/test/unittests/state_transition_eip8037_test.cpp @@ -0,0 +1,109 @@ +// evmone: Fast Ethereum Virtual Machine implementation +// Copyright 2026 The evmone Authors. +// SPDX-License-Identifier: Apache-2.0 + +#include "state_transition.hpp" +#include +#include + +using namespace evmc::literals; +using namespace evmone::test; + +TEST_F(state_transition, eip8037_create_tx_collision_excess_reservoir_refunded) +{ + // Amsterdam lets tx.gas_limit exceed MAX_TX_GAS_LIMIT, placing the excess in the state-gas + // reservoir. A depth-0 CREATE that collides (EIP-7610) must return that reservoir rather + // than forfeit it, so the sender is billed at most MAX_TX_GAS_LIMIT. + rev = EVMC_AMSTERDAM; + + constexpr int64_t TX_GAS_LIMIT = 18'000'000; + static_assert(TX_GAS_LIMIT > state::MAX_TX_GAS_LIMIT); + + block.gas_limit = TX_GAS_LIMIT * 2; + tx.gas_limit = TX_GAS_LIMIT; + // tx.to defaults to nullopt → CREATE tx. + + // SetUp() pre-funded the sender based on the default tx.gas_limit; redo it + // now that we have bumped it. + pre[Sender].balance = intx::uint256{tx.gas_limit} * tx.max_gas_price + tx.value + 1; + + // Pre-deploy a contract at the address this CREATE tx would produce so + // is_create_collision() fires. Sender's default nonce is 1. + const auto create_address = compute_create_address(Sender, 1); + pre[create_address] = {.nonce = 1, .code = bytecode{OP_STOP}}; + + // The collision returns before the NEW_ACCOUNT charge, so no state gas is charged: + // reservoir = (gas_limit - intrinsic) - (MAX_TX_GAS_LIMIT - intrinsic) + // = 18'000'000 - 16'777'216 = 1'222'784 + // raw_gas_used = gas_limit - gas_left(0) - reservoir = MAX_TX_GAS_LIMIT + expect.status = EVMC_FAILURE; + expect.gas_used = state::MAX_TX_GAS_LIMIT; + expect.gas_refund = 0; // No EVM gas refund; identity gas_used + gas_refund == max(R, floor). + expect.post[create_address] = {.nonce = 1, .code = bytecode{OP_STOP}}; +} + +// A value-bearing CALL to a NON-EXISTENT account charges NEW_ACCOUNT_STATE_GAS +// (120 * 1530 = 183'600) to the state-gas dimension before the sender-balance +// check. When that check light-fails (caller balance < value), no account is +// created and the charge is refilled at the failure boundary (EIP-8037 +// source-based refunds), so the net state gas (state_block_gas) +// is 0 — the same as the existing-target baseline. The two tests pin this as +// a differential: the ONLY difference is whether the target pre-exists, so +// both regular gas_used and state gas must be identical. If the refill ever +// regresses, the new-account case grows by exactly 183'600 in state gas. +namespace +{ +// Gas pinned empirically: 21000 intrinsic + the CALL's regular cost, with the +// EIP-8037 NEW_ACCOUNT state charge refilled on the light failure. +constexpr int64_t CallLightfailRegularGas = 30'321; +} // namespace + +TEST_F(state_transition, eip8037_call_value_lightfail_new_account_charge_refilled) +{ + rev = EVMC_AMSTERDAM; + tx.to = To; + static constexpr auto Target = 0xbeef_address; // intentionally absent from `pre` + + // To has balance 0, so `CALL value=1` light-fails the sender-balance check — + // but only AFTER NEW_ACCOUNT_STATE_GAS is charged for the absent Target. + pre[To] = {.code = call(Target).value(1).gas(0xffff) + OP_STOP}; + + expect.status = EVMC_SUCCESS; // To STOPs after the failed CALL (light failure) + expect.post[To] = {}; // To survives + expect.post[Target].exists = false; // no account was created + expect.gas_used = CallLightfailRegularGas; + expect.state_gas = 0; // the NEW_ACCOUNT charge for the absent Target is refilled +} + +TEST_F(state_transition, eip8037_call_value_lightfail_existing_account_baseline) +{ + rev = EVMC_AMSTERDAM; + tx.to = To; + static constexpr auto Target = 0xbeef_address; + + pre[To] = {.code = call(Target).value(1).gas(0xffff) + OP_STOP}; + pre[Target] = {.nonce = 1, .code = bytecode{OP_STOP}}; // Target exists → NO new-account charge + + expect.status = EVMC_SUCCESS; + expect.post[To] = {}; + expect.post[Target] = {.nonce = 1}; // unchanged by the light-failed call + expect.gas_used = CallLightfailRegularGas; // same regular gas as the new-account case + expect.state_gas = 0; // Target exists -> no new-account state-gas charge +} + +TEST_F(state_transition, eip8037_value_to_zero_balance_precompile_pays_new_account) +{ + // Funding a zero-balance precompile at depth 0 materializes a state account, so it pays + // NEW_ACCOUNT_STATE_GAS (EIP-161). The reservoir is empty for a below-cap gas limit, so the + // whole charge spills into regular gas and the precompile must run on the post-charge gas. + rev = EVMC_AMSTERDAM; + tx.to = 0x04_address; // identity, intentionally absent from `pre` + tx.value = 1; + + static constexpr int64_t IdentityBaseCost = 15; + + expect.status = EVMC_SUCCESS; + expect.post[*tx.to].balance = 1; + expect.gas_used = 21'000 + IdentityBaseCost + evmone::NEW_ACCOUNT_STATE_GAS; + expect.state_gas = evmone::NEW_ACCOUNT_STATE_GAS; +} diff --git a/test/unittests/state_tx_test.cpp b/test/unittests/state_tx_test.cpp index 1db2b90934..42deb06f99 100644 --- a/test/unittests/state_tx_test.cpp +++ b/test/unittests/state_tx_test.cpp @@ -26,17 +26,17 @@ TEST(state_tx, validate_nonce) const TestState state{{tx.sender, {.nonce = 1, .balance = 1'000'000}}}; ASSERT_FALSE(holds_alternative( - validate_transaction(state, block, tx, EVMC_BERLIN, block.gas_limit, 0))); + validate_transaction(state, block, tx, EVMC_BERLIN, block.gas_limit, 0, 0))); tx.nonce = 0; EXPECT_EQ(std::get( - validate_transaction(state, block, tx, EVMC_BERLIN, block.gas_limit, 0)) + validate_transaction(state, block, tx, EVMC_BERLIN, block.gas_limit, 0, 0)) .message(), "TransactionException.NONCE_MISMATCH_TOO_LOW"); tx.nonce = 2; EXPECT_EQ(std::get( - validate_transaction(state, block, tx, EVMC_BERLIN, block.gas_limit, 0)) + validate_transaction(state, block, tx, EVMC_BERLIN, block.gas_limit, 0, 0)) .message(), "TransactionException.NONCE_MISMATCH_TOO_HIGH"); } @@ -54,19 +54,19 @@ TEST(state_tx, validate_sender) const TestState state{{tx.sender, {}}}; ASSERT_FALSE(holds_alternative( - validate_transaction(state, block, tx, EVMC_BERLIN, block.gas_limit, 0))); + validate_transaction(state, block, tx, EVMC_BERLIN, block.gas_limit, 0, 0))); block.base_fee = 1; EXPECT_EQ(std::get( - validate_transaction(state, block, tx, EVMC_LONDON, block.gas_limit, 0)) + validate_transaction(state, block, tx, EVMC_LONDON, block.gas_limit, 0, 0)) .message(), "TransactionException.INSUFFICIENT_MAX_FEE_PER_GAS"); tx.max_gas_price = block.base_fee; EXPECT_EQ(std::get( - validate_transaction(state, block, tx, EVMC_LONDON, block.gas_limit, 0)) + validate_transaction(state, block, tx, EVMC_LONDON, block.gas_limit, 0, 0)) .message(), "TransactionException.INSUFFICIENT_ACCOUNT_FUNDS"); } @@ -92,17 +92,17 @@ TEST(state_tx, validate_blob_tx) const auto blob_gas_limit = static_cast(max_blob_gas_per_block(get_blob_params(EVMC_CANCUN))); EXPECT_EQ(std::get(validate_transaction( - state, block, tx, EVMC_SHANGHAI, block.gas_limit, blob_gas_limit)), + state, block, tx, EVMC_SHANGHAI, block.gas_limit, blob_gas_limit, 0)), make_error_code(ErrorCode::TYPE_NOT_SUPPORTED)); EXPECT_EQ(std::get(validate_transaction(state, block, tx, EVMC_CANCUN, - block.gas_limit, blob_gas_limit)) + block.gas_limit, blob_gas_limit, 0)) .message(), make_error_code(ErrorCode::CREATE_BLOB_TX).message()); tx.to = 0x01_address; EXPECT_EQ(std::get(validate_transaction( - state, block, tx, EVMC_CANCUN, block.gas_limit, blob_gas_limit)), + state, block, tx, EVMC_CANCUN, block.gas_limit, blob_gas_limit, 0)), make_error_code(ErrorCode::EMPTY_BLOB_HASHES_LIST)); for (uint8_t i = 0; i < 6; ++i) @@ -114,7 +114,7 @@ TEST(state_tx, validate_blob_tx) const auto expect_error = [&](int64_t g) { return std::get( - validate_transaction(state, block, tx, EVMC_CANCUN, block.gas_limit, g)); + validate_transaction(state, block, tx, EVMC_CANCUN, block.gas_limit, g, 0)); }; EXPECT_EQ(expect_error(blob_gas_limit), @@ -130,7 +130,7 @@ TEST(state_tx, validate_blob_tx) expect_error(blob_gas_limit - 1), make_error_code(ErrorCode::BLOB_GAS_LIMIT_EXCEEDED)); EXPECT_EQ(std::get(validate_transaction(state, block, tx, EVMC_CANCUN, - block.gas_limit, blob_gas_limit)) + block.gas_limit, blob_gas_limit, 0)) .execution_gas_limit, 39000); @@ -157,7 +157,8 @@ TEST(state_tx, validate_eof_create_transaction) for (int r = EVMC_CANCUN; r <= EVMC_MAX_REVISION; ++r) { const auto rev = static_cast(r); - const auto res = validate_transaction(state, block, tx, rev, block.gas_limit, 0); + const auto res = + validate_transaction(state, block, tx, rev, block.gas_limit, 0, block.gas_limit); EXPECT_FALSE(holds_alternative(res)); } } @@ -179,7 +180,7 @@ TEST(state_tx, validate_tx_data_cost) const TestState state{{tx.sender, {.balance = 1'000'000}}}; const auto get_props = [&](evmc_revision rev) { - const auto res = validate_transaction(state, block, tx, rev, block.gas_limit, 0); + const auto res = validate_transaction(state, block, tx, rev, block.gas_limit, 0, 0); EXPECT_TRUE(holds_alternative(res)); if (holds_alternative(res)) return get(res); @@ -233,14 +234,14 @@ TEST(state_tx, max_blob_count) // Should be valid EXPECT_FALSE(holds_alternative( - validate_transaction(state, block, tx, EVMC_CANCUN, block.gas_limit, blob_gas_limit))); + validate_transaction(state, block, tx, EVMC_CANCUN, block.gas_limit, blob_gas_limit, 0))); // Add one more blob to exceed the limit tx.blob_hashes.emplace_back( 0x01000000000000000000000000000000000000000000000000000000000000FF_bytes32); EXPECT_EQ(std::get(validate_transaction( - state, block, tx, EVMC_CANCUN, block.gas_limit, blob_gas_limit)), + state, block, tx, EVMC_CANCUN, block.gas_limit, blob_gas_limit, 0)), make_error_code(ErrorCode::BLOB_GAS_LIMIT_EXCEEDED)); } @@ -251,6 +252,6 @@ TEST(state_tx, max_gas_limit_exceeded) const TestState state; EXPECT_EQ(std::get( - validate_transaction(state, block, tx, EVMC_OSAKA, block.gas_limit, 0)), + validate_transaction(state, block, tx, EVMC_OSAKA, block.gas_limit, 0, 0)), make_error_code(ErrorCode::GAS_LIMIT_EXCEEDS_MAXIMUM)); } diff --git a/test/utils/block_transition.cpp b/test/utils/block_transition.cpp index c8a36a733b..c364a6d9b3 100644 --- a/test/utils/block_transition.cpp +++ b/test/utils/block_transition.cpp @@ -49,8 +49,12 @@ TransitionResult apply_block(const TestState& state, evmc::VM& vm, const state:: std::vector receipts; int64_t block_gas_left = block.gas_limit; + // The block's state-gas budget, consulted by validate_transaction on Amsterdam+ (EIP-8037). + int64_t state_block_gas_left = block.gas_limit; int64_t cumulative_gas_used = 0; - int64_t block_gas_used = 0; + // The two dimensions of the block-level max(sum_regular, sum_state) formula (EIP-7778). + int64_t sum_regular_gas = 0; + int64_t sum_state_gas = 0; auto blob_gas_left = blob_gas_limit; for (size_t i = 0; i < txs.size(); ++i) @@ -62,8 +66,8 @@ TransitionResult apply_block(const TestState& state, evmc::VM& vm, const state:: if (trace_enabled) trace_guard.emplace(std::clog, opts.open_trace(i, computed_tx_hash).rdbuf()); - auto res = transition( - block_state, block, block_hashes, tx, rev, vm, block_gas_left, blob_gas_left); + auto res = transition(block_state, block, block_hashes, tx, rev, vm, block_gas_left, + blob_gas_left, state_block_gas_left); if (holds_alternative(res)) { @@ -78,11 +82,12 @@ TransitionResult apply_block(const TestState& state, evmc::VM& vm, const state:: if (rev < EVMC_BYZANTIUM) receipt.post_state = state::mpt_hash(block_state); - // Block gas accounting, refunds excluded (EIP-7778). - const auto block_tx_gas = - (rev >= EVMC_AMSTERDAM) ? receipt.gas_used + receipt.gas_refund : receipt.gas_used; - block_gas_used += block_tx_gas; - block_gas_left -= block_tx_gas; + // Accumulate the 2D components for the block-level max(sum_regular, sum_state) + // formula, which pre-Amsterdam is the single gas dimension (EIP-8037). + sum_regular_gas += receipt.regular_block_gas; + sum_state_gas += receipt.state_block_gas; + block_gas_left -= receipt.regular_block_gas; + state_block_gas_left -= receipt.state_block_gas; blob_gas_left -= static_cast(tx.blob_gas_used()); receipts.emplace_back(std::move(receipt)); } @@ -114,6 +119,8 @@ TransitionResult apply_block(const TestState& state, evmc::VM& vm, const state:: const auto bloom = compute_bloom_filter(receipts); + // The block's 2D gas formula (EIP-7778). + const auto block_gas_used = std::max(sum_regular_gas, sum_state_gas); return {std::move(receipts), std::move(rejected_txs), std::move(requests), requests_error, block_gas_used, bloom, blob_gas_left, std::move(block_state)}; } diff --git a/test/utils/error_matching.cpp b/test/utils/error_matching.cpp index b239ad0fa3..2d1e526205 100644 --- a/test/utils/error_matching.cpp +++ b/test/utils/error_matching.cpp @@ -43,6 +43,10 @@ constexpr AlternativeExceptions ALTERNATIVE_TX_EXCEPTIONS[]{ // decode_transaction() reports one code for every malformed encoding, so this accepts more // than the v rule; narrowing it needs the decoder to report the v domain separately. {state::INVALID_ENCODING, "TransactionException.INVALID_SIGNATURE_VRS"}, + + // A transaction whose gas limit exceeds the block's remaining gas is a transaction rule to + // evmone and a block rule to the specs, which count it into the header's gas used. + {state::GAS_ALLOWANCE_EXCEEDED, "BlockException.GAS_USED_OVERFLOW"}, }; /// The same, for the rules evmone checks on the block rather than the transaction. diff --git a/test/utils/statetest_runner.cpp b/test/utils/statetest_runner.cpp index 19e64e178e..3d8e4a2db5 100644 --- a/test/utils/statetest_runner.cpp +++ b/test/utils/statetest_runner.cpp @@ -61,7 +61,8 @@ void run_state_test( const auto res = error ? error : transition(state, block, test.block_hashes, *tx, rev, vm, block.gas_limit, - static_cast(state::max_blob_gas_per_block(blob_params))); + static_cast(state::max_blob_gas_per_block(blob_params)), + block.gas_limit); if (holds_alternative(res)) { diff --git a/test/utils/test_state.cpp b/test/utils/test_state.cpp index 0d3ac7a4ab..6afe61d29a 100644 --- a/test/utils/test_state.cpp +++ b/test/utils/test_state.cpp @@ -73,10 +73,10 @@ bytes32 TestBlockHashes::get_block_hash(int64_t block_number) const noexcept [[nodiscard]] std::variant transition(TestState& state, const state::BlockInfo& block, const state::BlockHashes& block_hashes, const state::Transaction& tx, evmc_revision rev, evmc::VM& vm, int64_t block_gas_left, - int64_t blob_gas_left) + int64_t blob_gas_left, int64_t state_block_gas_left) { - const auto tx_props_or_error = - state::validate_transaction(state, block, tx, rev, block_gas_left, blob_gas_left); + const auto tx_props_or_error = state::validate_transaction( + state, block, tx, rev, block_gas_left, blob_gas_left, state_block_gas_left); if (const auto err = get_if(&tx_props_or_error)) return *err; diff --git a/test/utils/test_state.hpp b/test/utils/test_state.hpp index 54330c756c..45ec1047fe 100644 --- a/test/utils/test_state.hpp +++ b/test/utils/test_state.hpp @@ -69,10 +69,15 @@ class TestBlockHashes : public state::BlockHashes, public std::unordered_map transition(TestState& state, const state::BlockInfo& block, const state::BlockHashes& block_hashes, const state::Transaction& tx, evmc_revision rev, evmc::VM& vm, int64_t block_gas_left, - int64_t blob_gas_left); + int64_t blob_gas_left, int64_t state_block_gas_left); /// Wrapping of state::finalize() which operates on TestState. void finalize(TestState& state, evmc_revision rev, const address& coinbase, From f7befb53161043b84c58bbd2ae44c5c6295629ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 9 Sep 2026 09:45:02 +0200 Subject: [PATCH 02/20] EIP-8037: round 1 --- evmc/include/evmc/evmc.h | 10 +--- evmc/include/evmc/mocked_host.hpp | 7 +-- lib/evmone/constants.hpp | 20 +++---- lib/evmone/instructions.hpp | 15 ----- lib/evmone/instructions_calls.cpp | 28 ++++++++- lib/evmone/instructions_storage.cpp | 3 +- lib/evmone/state_gas.hpp | 58 ++++++------------- test/state/host.cpp | 1 + test/unittests/CMakeLists.txt | 2 +- ...ate_transition_eip8037_state_gas_test.cpp} | 36 ++++++++++++ 10 files changed, 94 insertions(+), 86 deletions(-) rename test/unittests/{state_transition_eip8037_test.cpp => state_transition_eip8037_state_gas_test.cpp} (76%) diff --git a/evmc/include/evmc/evmc.h b/evmc/include/evmc/evmc.h index cc8d2f1093..e52e47e407 100644 --- a/evmc/include/evmc/evmc.h +++ b/evmc/include/evmc/evmc.h @@ -465,18 +465,12 @@ struct evmc_result /** * The amount of state gas left after execution (EIP-8037). - * - * Returned to the caller so it can restore its own state_gas tracking. */ + // FIXME: Move after gas_refund. int64_t state_gas_left; /** - * The portion of consumed state gas that spilled into gas_left (EIP-8037). - * - * Tracked so refunds and frame rollback restore gas in LIFO order: the - * spilled portion returns to gas_left, the rest to the reservoir - * (state_gas_left). On a successful child this accumulates into the - * caller; on revert/halt the frame refills itself before returning. + * The portion of consumed state gas taken from gas_left (EIP-8037). */ int64_t state_gas_spilled; }; diff --git a/evmc/include/evmc/mocked_host.hpp b/evmc/include/evmc/mocked_host.hpp index 1876856fff..7e785e0a7b 100644 --- a/evmc/include/evmc/mocked_host.hpp +++ b/evmc/include/evmc/mocked_host.hpp @@ -409,12 +409,7 @@ class MockedHost : public Host call_msg.input_data = input_copy.data(); } } - auto result = Result{call_result}; - // A zero state_gas_left means "the callee consumed the caller's whole reservoir". - // The mock runs no code, so echo the reservoir it was handed unless a test set one. - if (result.state_gas_left == 0) - result.state_gas_left = msg.state_gas; - return result; + return Result{call_result}; } /// Get transaction context (EVMC host method). diff --git a/lib/evmone/constants.hpp b/lib/evmone/constants.hpp index dd4d207c42..ceaf24287e 100644 --- a/lib/evmone/constants.hpp +++ b/lib/evmone/constants.hpp @@ -3,8 +3,6 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once -#include - namespace evmone { /// The limit of the size of created contract @@ -31,21 +29,19 @@ constexpr auto MAX_NONCE = 0xffff'ffff'ffff'ffff; constexpr auto CALL_STIPEND = 2300; /// The fixed cost per state byte (EIP-8037). -constexpr int64_t COST_PER_STATE_BYTE = 1530; +constexpr auto COST_PER_STATE_BYTE = 1530; /// State bytes charged for creating a new account (EIP-8037). -constexpr int64_t STATE_BYTES_PER_NEW_ACCOUNT = 120; +constexpr auto STATE_BYTES_PER_NEW_ACCOUNT = 120; -/// State bytes charged when a storage slot is newly allocated, i.e. SSTORE 0 -> non-zero -/// (EIP-8037). -constexpr int64_t STATE_BYTES_PER_STORAGE_SET = 64; +/// State bytes charged when a storage slot is newly allocated (EIP-8037). +constexpr auto STATE_BYTES_PER_STORAGE_SET = 64; -/// State-gas cost of creating a new account: CREATE/CREATE2, CALL with value to a -/// nonexistent account, a new SELFDESTRUCT beneficiary (EIP-8037). -constexpr int64_t NEW_ACCOUNT_STATE_GAS = STATE_BYTES_PER_NEW_ACCOUNT * COST_PER_STATE_BYTE; +/// State-gas cost of creating a new account (EIP-8037). +constexpr auto NEW_ACCOUNT_STATE_GAS = STATE_BYTES_PER_NEW_ACCOUNT * COST_PER_STATE_BYTE; -/// State-gas cost of allocating a storage slot, i.e. SSTORE 0 -> non-zero (EIP-8037). -constexpr int64_t STORAGE_SET_STATE_GAS = STATE_BYTES_PER_STORAGE_SET * COST_PER_STATE_BYTE; +/// State-gas cost of allocating a storage slot (EIP-8037). +constexpr auto STORAGE_SET_STATE_GAS = STATE_BYTES_PER_STORAGE_SET * COST_PER_STATE_BYTE; // State-gas charging and refills live on the StateGas type (state_gas.hpp). } // namespace evmone diff --git a/lib/evmone/instructions.hpp b/lib/evmone/instructions.hpp index 1dccd6c9d6..e2bfefe146 100644 --- a/lib/evmone/instructions.hpp +++ b/lib/evmone/instructions.hpp @@ -115,21 +115,6 @@ constexpr int64_t copy_cost(uint64_t size_in_bytes) noexcept return num_words(size_in_bytes) * WordCopyCost; } - -/// Threads a child frame's state gas back to the parent: take its leftover reservoir and -/// accumulate its spill. A failed child already rolled itself back at its boundary, so success -/// and failure are handled identically. With the child's reservoir merged in, a successful child -/// also repays the frame's outstanding spill from it, so a refill the child credited to the -/// reservoir reaches the `gas_left` that funded the matching charge (EIP-8037). -inline void accumulate_child_state_gas( - int64_t& gas_left, ExecutionState& state, const evmc::Result& result) noexcept -{ - state.state_gas.left = result.state_gas_left; - state.state_gas.spilled += result.state_gas_spilled; - if (result.status_code == EVMC_SUCCESS) - state.state_gas.repay_spill(gas_left); -} - /// Grows EVM memory and checks its cost. /// /// This function should not be inlined because this may affect other inlining decisions: diff --git a/lib/evmone/instructions_calls.cpp b/lib/evmone/instructions_calls.cpp index c7dc1350c8..e370ca09d5 100644 --- a/lib/evmone/instructions_calls.cpp +++ b/lib/evmone/instructions_calls.cpp @@ -39,6 +39,30 @@ inline std::variant get_target_address( return *delegate_addr; } + +/// Absorbs a child's state-gas back to the parent (EIP-8037). +inline void absorb_child_state_gas( + int64_t& gas_left, ExecutionState& state, const evmc::Result& result) noexcept +{ + assert(result.state_gas_left >= 0); + assert(result.state_gas_spilled >= 0); + + // At most one of the two pools is ever non-empty. + assert(state.state_gas.left == 0 || state.state_gas.spilled == 0); + assert(result.state_gas_left == 0 || result.state_gas_spilled == 0); + + // In a non-successful result, all is returned back. + assert(result.status_code == EVMC_SUCCESS || + (result.state_gas_left == state.state_gas.left && result.state_gas_spilled == 0)); + + // Accumulate the spilled state-gas. + state.state_gas.spilled += result.state_gas_spilled; + + // Rebalance the state-gas refills: the caller must move callee's refills to gas_left up to the + // caller's spilled counter. Do this by refilling all returned state-gas to zeroed `left`. + state.state_gas.left = 0; + state.state_gas.refill(gas_left, result.state_gas_left); +} } // namespace /// Converts an opcode to matching EVMC call kind. @@ -218,7 +242,7 @@ Result call_impl(StackTop stack, int64_t gas_left, ExecutionState& state) noexce state.gas_refund += result.gas_refund; // Thread the child's state gas back. A failed child rolls the created account back, so its // NEW_ACCOUNT charge is refilled (EIP-8037). - accumulate_child_state_gas(gas_left, state, result); + absorb_child_state_gas(gas_left, state, result); if (result.status_code != EVMC_SUCCESS) refund_new_account_state_gas(); return {EVMC_SUCCESS, gas_left}; @@ -330,7 +354,7 @@ Result create_impl(StackTop stack, int64_t gas_left, ExecutionState& state) noex // Thread the child's state gas back. A non-success result — a rolled-back initcode or an // address collision — creates no account, so its NEW_ACCOUNT charge is refilled; a create // onto an already-alive account was never charged (EIP-8037). - accumulate_child_state_gas(gas_left, state, result); + absorb_child_state_gas(gas_left, state, result); if (create_state_gas_charged != 0 && result.status_code != EVMC_SUCCESS) state.state_gas.refill(gas_left, create_state_gas_charged); diff --git a/lib/evmone/instructions_storage.cpp b/lib/evmone/instructions_storage.cpp index 9648dcb714..63353ae09d 100644 --- a/lib/evmone/instructions_storage.cpp +++ b/lib/evmone/instructions_storage.cpp @@ -151,6 +151,7 @@ Result sstore(StackTop stack, int64_t gas_left, ExecutionState& state) noexcept // A refill (0 -> Y -> 0) is applied BEFORE the regular charge, as in EELS, so gas returned // to gas_left from a prior spill can fund that charge (EIP-8037). + // FIXME: .refill(c) looks like .charge(-c). Can we combine these? if (state_gas < 0) state.state_gas.refill(gas_left, -state_gas); @@ -159,7 +160,7 @@ Result sstore(StackTop stack, int64_t gas_left, ExecutionState& state) noexcept if ((gas_left -= gas_cost) < 0) return {EVMC_OUT_OF_GAS, gas_left}; - if (!state.state_gas.charge(gas_left, state_gas)) + if (state_gas > 0 && !state.state_gas.charge(gas_left, state_gas)) return {EVMC_OUT_OF_GAS, gas_left}; state.gas_refund += gas_refund; return {EVMC_SUCCESS, gas_left}; diff --git a/lib/evmone/state_gas.hpp b/lib/evmone/state_gas.hpp index c2567cf4d6..f76f69b94f 100644 --- a/lib/evmone/state_gas.hpp +++ b/lib/evmone/state_gas.hpp @@ -4,34 +4,25 @@ #pragma once #include +#include #include namespace evmone { -/// A frame's state gas as a (reservoir-left, spilled) pair, metered independently -/// from the regular `gas_left` (EIP-8037). -/// -/// `left` is the remaining reservoir a frame draws state-gas charges from; -/// `spilled` is the portion of those charges that had to draw from `gas_left` -/// because the reservoir was insufficient. `spilled` is tracked so refunds and -/// frame rollback restore the exact pools the charge drew from, in LIFO order. -/// -/// The net state gas a frame (and its children) consumed is not stored — it is -/// derived from the frame's initial reservoir: `used = initial - left + spilled`. -/// This holds across nested calls because a child's initial reservoir is the -/// parent's `left` at call time. +/// A frame's state-gas as a (left, spilled) pair, independent from the regular gas (EIP-8037). struct StateGas { - int64_t left = 0; ///< Remaining state-gas reservoir (`state_gas_reservoir`). - int64_t spilled = 0; ///< Consumed state gas that drew from `gas_left`. + /// Remaining state-gas reservoir. + /// TODO: Try changing type to uint32_t. + int64_t left = 0; - /// Charges `cost`, drawing from the reservoir first and spilling any remainder into the - /// regular `gas_left`. Atomic: returns false without mutating any field when neither pool - /// can cover the cost. + /// Consumed state-gas taken from `gas_left` (happens when `left` is empty). + int64_t spilled = 0; + + /// Charges `cost`, first from `left`, then from `gas_left` (recorded in `spilled`). [[nodiscard]] bool charge(int64_t& gas_left, int64_t cost) noexcept { - if (cost <= 0) - return true; + assert(cost >= 0); if (left >= cost) { left -= cost; @@ -46,30 +37,15 @@ struct StateGas return true; } - /// Credits a `cost` refund in LIFO order: the pool charged last is refilled first — - /// `gas_left` up to `spilled`, then the reservoir — so the refund restores the exact - /// pools the matching charge drew from. - void refill(int64_t& gas_left, int64_t cost) noexcept - { - const auto from_gas_left = std::min(cost, spilled); - gas_left += from_gas_left; - spilled -= from_gas_left; - left += cost - from_gas_left; - } - - /// Returns reservoir gas to `gas_left`, up to the spill still outstanding. + /// Refund state-gas. /// - /// A refill need not land in the frame whose charge spilled: a slot's original value is the - /// value at transaction start, so a frame may clear a slot an earlier frame allocated. The - /// credit then sits in the reservoir while the `gas_left` that funded the charge stays - /// reduced. Applied when a child merges, this moves the credit up to the first frame with an - /// outstanding spill. It undoes no state creation, so the net state gas used is unchanged. - void repay_spill(int64_t& gas_left) noexcept + /// Give the `cost` to `gas_left` (up to `spilled`) and `left` (whatever remains). + void refill(int64_t& gas_left, int64_t cost) noexcept { - const auto amount = std::min(left, spilled); - gas_left += amount; - left -= amount; - spilled -= amount; + const auto to_gas_left = std::min(cost, spilled); + gas_left += to_gas_left; + spilled -= to_gas_left; + left += cost - to_gas_left; } }; } // namespace evmone diff --git a/test/state/host.cpp b/test/state/host.cpp index f4c86063a9..ad5ee5a47a 100644 --- a/test/state/host.cpp +++ b/test/state/host.cpp @@ -284,6 +284,7 @@ evmc::Result Host::create(const evmc_message& msg) noexcept const auto regular_cost = 6 * ((std::ssize(code) + 31) / 32); const auto state_cost = std::ssize(code) * COST_PER_STATE_BYTE; gas_left -= regular_cost; + // FIXME: Can .charge() handle negative gas_left? Is this covered by tests? if (gas_left < 0 || !state_gas.charge(gas_left, state_cost)) return evmc::Result{EVMC_FAILURE}; } diff --git a/test/unittests/CMakeLists.txt b/test/unittests/CMakeLists.txt index 7ab08ef1df..a1c9fa5e50 100644 --- a/test/unittests/CMakeLists.txt +++ b/test/unittests/CMakeLists.txt @@ -71,7 +71,7 @@ target_sources( state_transition_create_test.cpp state_transition_eip7702_test.cpp state_transition_eip7778_block_gas_test.cpp - state_transition_eip8037_test.cpp + state_transition_eip8037_state_gas_test.cpp state_transition_extcode_test.cpp state_transition_selfdestruct_test.cpp state_transition_snippets_test.cpp diff --git a/test/unittests/state_transition_eip8037_test.cpp b/test/unittests/state_transition_eip8037_state_gas_test.cpp similarity index 76% rename from test/unittests/state_transition_eip8037_test.cpp rename to test/unittests/state_transition_eip8037_state_gas_test.cpp index cb0fadeffd..9b7c9cfc5f 100644 --- a/test/unittests/state_transition_eip8037_test.cpp +++ b/test/unittests/state_transition_eip8037_state_gas_test.cpp @@ -107,3 +107,39 @@ TEST_F(state_transition, eip8037_value_to_zero_balance_precompile_pays_new_accou expect.gas_used = 21'000 + IdentityBaseCost + evmone::NEW_ACCOUNT_STATE_GAS; expect.state_gas = evmone::NEW_ACCOUNT_STATE_GAS; } + +TEST_F(state_transition, eip8037_sstore_slot_allocated_and_cleared_in_one_tx) +{ + // Allocating a storage slot and clearing it in the same transaction (0 -> 1 -> 0) refills + // the STORAGE_SET_STATE_GAS charge, leaving the net state gas at zero. + rev = EVMC_AMSTERDAM; + tx.to = To; + pre[To] = {.code = sstore(1, 1) + sstore(1, 0)}; + + // Pre-refund: 21000 intrinsic + 12 (four PUSHes) + 5000 (cold slot allocation) + // + 100 (warm clear) = 26112. The clear refunds set - warm_access = 2800. + expect.gas_used = 26112 - 2800; + expect.gas_refund = 2800; + expect.state_gas = 0; + expect.post[To].exists = true; +} + +TEST_F(state_transition, eip8037_sstore_slot_cleared_in_a_child_frame) +{ + // A slot allocated in one frame and cleared in a deeper one refills more state gas than the + // child was given, so the child returns a bigger reservoir than it received. The credit must + // reach the `gas_left` that funded the spilled allocation charge. + rev = EVMC_AMSTERDAM; + tx.to = To; + static constexpr auto Clearer = 0xdead_address; + pre[Clearer] = {.code = sstore(1, 0)}; + pre[To] = {.code = sstore(1, 1) + delegatecall(Clearer).gas(0xffff) + OP_STOP}; + + // Pre-refund: 21000 intrinsic + 30 (ten PUSHes) + 5000 (cold slot allocation) + // + 2600 (cold DELEGATECALL) + 100 (warm clear) = 28730. The clear refunds 2800. + expect.gas_used = 28730 - 2800; + expect.gas_refund = 2800; + expect.state_gas = 0; + expect.post[To].exists = true; + expect.post[Clearer].exists = true; +} From a3d0f88fc653b15c7e853d0c89eba1d0d07c6363 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 9 Sep 2026 13:00:42 +0200 Subject: [PATCH 03/20] system contract probe --- test/state/system_contracts.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/state/system_contracts.cpp b/test/state/system_contracts.cpp index 7b41d7dece..34623ccc36 100644 --- a/test/state/system_contracts.cpp +++ b/test/state/system_contracts.cpp @@ -77,7 +77,7 @@ static_assert(std::ranges::is_sorted(REQUESTS_SYSTEM_CONTRACTS, by_rev), /// Cap on the number of SSTOREs a system contract may fund out of its state-gas budget. The value /// is observable: `system_contract_reaches_gas_limit` sizes a contract to exactly /// `30M + SYSTEM_MAX_SSTORES_PER_CALL × STORAGE_SET_STATE_GAS` (EIP-8037). -constexpr int64_t SYSTEM_MAX_SSTORES_PER_CALL = 16; +// constexpr int64_t SYSTEM_MAX_SSTORES_PER_CALL = 16; evmc::Result execute_system_call(State& state, const BlockInfo& block, const BlockHashes& block_hashes, evmc_revision rev, evmc::VM& vm, const address& addr, @@ -94,8 +94,8 @@ evmc::Result execute_system_call(State& state, const BlockInfo& block, .sender = SYSTEM_ADDRESS, .input_data = input.data(), .input_size = input.size(), - .state_gas = - (rev >= EVMC_AMSTERDAM) ? SYSTEM_MAX_SSTORES_PER_CALL * STORAGE_SET_STATE_GAS : 0, + // REVIEW: Check if this is fine globally. + .state_gas = 16 * STORAGE_SET_STATE_GAS, }; const Transaction empty_tx{}; From 30094ff7e62ac84ab07b593baba171912362819f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 9 Sep 2026 13:09:55 +0200 Subject: [PATCH 04/20] test utils cleanups --- test/state/state.hpp | 4 ++-- test/utils/block_transition.cpp | 1 - test/utils/test_state.hpp | 5 ----- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/test/state/state.hpp b/test/state/state.hpp index 50aa811dad..c543110b58 100644 --- a/test/state/state.hpp +++ b/test/state/state.hpp @@ -143,8 +143,8 @@ TransactionReceipt transition(const StateView& state, const BlockInfo& block, /// Validate a transaction. /// -/// @param state_block_gas_left Pre-Amsterdam: ignored. Amsterdam+: remaining -/// block state-gas budget (EIP-8037). +/// @param state_block_gas_left Remaining block state-gas (EIP-8037). FIXME: rename to +/// block_state_gas_left and place after block_gas_left. /// @return Computed execution gas limit or validation error. [[nodiscard]] std::variant validate_transaction( const StateView& state_view, const BlockInfo& block, const Transaction& tx, evmc_revision rev, diff --git a/test/utils/block_transition.cpp b/test/utils/block_transition.cpp index c364a6d9b3..044fba4037 100644 --- a/test/utils/block_transition.cpp +++ b/test/utils/block_transition.cpp @@ -49,7 +49,6 @@ TransitionResult apply_block(const TestState& state, evmc::VM& vm, const state:: std::vector receipts; int64_t block_gas_left = block.gas_limit; - // The block's state-gas budget, consulted by validate_transaction on Amsterdam+ (EIP-8037). int64_t state_block_gas_left = block.gas_limit; int64_t cumulative_gas_used = 0; // The two dimensions of the block-level max(sum_regular, sum_state) formula (EIP-7778). diff --git a/test/utils/test_state.hpp b/test/utils/test_state.hpp index 45ec1047fe..5308d778f4 100644 --- a/test/utils/test_state.hpp +++ b/test/utils/test_state.hpp @@ -69,11 +69,6 @@ class TestBlockHashes : public state::BlockHashes, public std::unordered_map transition(TestState& state, const state::BlockInfo& block, const state::BlockHashes& block_hashes, const state::Transaction& tx, evmc_revision rev, evmc::VM& vm, int64_t block_gas_left, From 7ca85ebf8a8795ae87ab05aabb341accb3eb694f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 9 Sep 2026 13:23:45 +0200 Subject: [PATCH 05/20] test: Cover the code deposit running out of regular gas The deposit splits into a regular and a state component. When the reservoir covers the whole state component, dropping the regular check lets charge() take its reservoir-only branch and report success on a negative gas_left, deploying code that was never paid for. Reaching that needs a gas limit above MAX_TX_GAS_LIMIT, which no existing deposit test uses, so the check was unverified: the mutation passed the unit tests and the execution-spec-tests. --- ...tate_transition_eip8037_state_gas_test.cpp | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/unittests/state_transition_eip8037_state_gas_test.cpp b/test/unittests/state_transition_eip8037_state_gas_test.cpp index 9b7c9cfc5f..c5f9eb66f8 100644 --- a/test/unittests/state_transition_eip8037_state_gas_test.cpp +++ b/test/unittests/state_transition_eip8037_state_gas_test.cpp @@ -143,3 +143,35 @@ TEST_F(state_transition, eip8037_sstore_slot_cleared_in_a_child_frame) expect.post[To].exists = true; expect.post[Clearer].exists = true; } + +TEST_F(state_transition, eip8037_code_deposit_out_of_regular_gas_with_a_full_reservoir) +{ + // The code deposit splits into a regular and a state component. A reservoir that covers the + // state component must not let the deposit through when the regular component is unaffordable. + rev = EVMC_AMSTERDAM; + static constexpr auto CODE_SIZE = 0x10000; // MAX_CODE_SIZE_AMSTERDAM. + static constexpr auto CREATOR = 0xbbbb_address; + + // Above MAX_TX_GAS_LIMIT, so the excess forms a reservoir bigger than the deposit's + // CODE_SIZE * COST_PER_STATE_BYTE = 100'270'080 state component. + tx.gas_limit = 130'000'000; + block.gas_limit = tx.gas_limit; + pre[Sender].balance = intx::uint256{tx.gas_limit} * tx.max_gas_price + 1; + tx.to = To; + + // The reservoir reaches the initcode frame in full, but the gas cap leaves it short of the + // deposit's 6 * CODE_SIZE / 32 = 12'288 regular component. Pinned empirically: the cap has to + // land the initcode frame's leftover inside that 12'288-wide window, so a reprice of CREATE + // or of the CALL moves it. gas_used guards the tuning — without it a drifted cap would fail + // the CREATE earlier and still satisfy the expectations below. + const auto initcode = ret(0, CODE_SIZE); + pre[CREATOR] = { + .code = mstore(0, push(initcode)) + create().input(32 - initcode.size(), initcode.size())}; + pre[To] = {.code = call(CREATOR).gas(55000) + OP_STOP}; + + expect.status = EVMC_SUCCESS; + expect.gas_used = 78262; + expect.post[To].exists = true; + expect.post[CREATOR].nonce = pre[CREATOR].nonce + 1; // bumped by the CREATE + expect.post[compute_create_address(CREATOR, pre[CREATOR].nonce)].exists = false; +} From 5331c2f2c727d2d0fb3257e7af3cb741f73f8aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 9 Sep 2026 13:23:45 +0200 Subject: [PATCH 06/20] Attribute the 2D block gas formula to EIP-8037 max(block_execution_gas_used, block_state_gas_used) is EIP-8037's rule. The EIP-7778 max is max(tx_gas_used, calldata_floor_gas_cost), per transaction and one-dimensional; EIP-8037 preserves it inside the execution dimension, which is the max already cited correctly in transition(). --- test/utils/block_transition.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/utils/block_transition.cpp b/test/utils/block_transition.cpp index 044fba4037..5c34f4aa1a 100644 --- a/test/utils/block_transition.cpp +++ b/test/utils/block_transition.cpp @@ -51,7 +51,7 @@ TransitionResult apply_block(const TestState& state, evmc::VM& vm, const state:: int64_t block_gas_left = block.gas_limit; int64_t state_block_gas_left = block.gas_limit; int64_t cumulative_gas_used = 0; - // The two dimensions of the block-level max(sum_regular, sum_state) formula (EIP-7778). + // The two dimensions of the block-level max(sum_regular, sum_state) formula (EIP-8037). int64_t sum_regular_gas = 0; int64_t sum_state_gas = 0; auto blob_gas_left = blob_gas_limit; @@ -118,7 +118,7 @@ TransitionResult apply_block(const TestState& state, evmc::VM& vm, const state:: const auto bloom = compute_bloom_filter(receipts); - // The block's 2D gas formula (EIP-7778). + // The block's 2D gas formula (EIP-8037). const auto block_gas_used = std::max(sum_regular_gas, sum_state_gas); return {std::move(receipts), std::move(rejected_txs), std::move(requests), requests_error, block_gas_used, bloom, blob_gas_left, std::move(block_state)}; From 6c438bd1d4b4169543e7a1c33b53ba0e2b8717f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 9 Sep 2026 15:11:16 +0200 Subject: [PATCH 07/20] Drop incidental churn from the diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the collision return and the pre-Amsterdam deployment cost to their original shapes: neither is touched by this change. Setting the state gas on the Frontier path was the only reason to unroll the ternary, and it is a no-op there — both pools are zero for every pre-Amsterdam frame and the result is zero-initialized. --- test/state/host.cpp | 16 +++++----------- test/unittests/state_transition.cpp | 2 -- test/unittests/state_transition_create_test.cpp | 6 ++---- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/test/state/host.cpp b/test/state/host.cpp index ad5ee5a47a..b0ea245cf9 100644 --- a/test/state/host.cpp +++ b/test/state/host.cpp @@ -206,10 +206,7 @@ evmc::Result Host::create(const evmc_message& msg) noexcept else { if (is_create_collision(*new_acc)) - { - // TODO: Add EVMC errors for creation failures. - return evmc::Result{EVMC_FAILURE}; - } + return evmc::Result{EVMC_FAILURE}; // TODO: Add EVMC errors for creation failures. m_state.journal_create(msg.recipient); } @@ -290,17 +287,14 @@ evmc::Result Host::create(const evmc_message& msg) noexcept } else { + // Code deployment cost. const auto cost = std::ssize(code) * 200; gas_left -= cost; if (gas_left < 0) { - if (m_rev == EVMC_FRONTIER) - { - auto r = evmc::Result{EVMC_SUCCESS, result.gas_left, result.gas_refund}; - set_state_gas(r, state_gas.left, state_gas.spilled); - return r; - } - return evmc::Result{EVMC_FAILURE}; + return (m_rev == EVMC_FRONTIER) ? + evmc::Result{EVMC_SUCCESS, result.gas_left, result.gas_refund} : + evmc::Result{EVMC_FAILURE}; } } diff --git a/test/unittests/state_transition.cpp b/test/unittests/state_transition.cpp index aaf025d206..bab589dbe6 100644 --- a/test/unittests/state_transition.cpp +++ b/test/unittests/state_transition.cpp @@ -103,9 +103,7 @@ void state_transition::TearDown() } } if (expect.state_gas.has_value()) - { EXPECT_EQ(receipt.state_block_gas, *expect.state_gas); - } // Update default expectations - valid transaction means coinbase exists unless explicitly // requested otherwise if (!expect.post.contains(Coinbase)) diff --git a/test/unittests/state_transition_create_test.cpp b/test/unittests/state_transition_create_test.cpp index 47d597f233..f587f18049 100644 --- a/test/unittests/state_transition_create_test.cpp +++ b/test/unittests/state_transition_create_test.cpp @@ -431,8 +431,7 @@ TEST_F(state_transition, eip7954_create_tx_at_max_code_size) // A create transaction deploying code of exactly the new limit succeeds. rev = EVMC_AMSTERDAM; static constexpr auto code_size = 0x10000; // MAX_CODE_SIZE_AMSTERDAM. - // Covers the ~100M code-deposit state gas (COST_PER_STATE_BYTE per byte, EIP-8037). - tx.gas_limit = 110'000'000; + tx.gas_limit = 110'000'000; // Covers the ~100M code-deposit state gas (EIP-8037). block.gas_limit = tx.gas_limit; pre[Sender].balance = tx.gas_limit * tx.max_gas_price; tx.data = ret(0, code_size); // Init code returns `code_size` zero bytes as the deployed code. @@ -446,8 +445,7 @@ TEST_F(state_transition, eip7954_create_tx_above_max_code_size) // Code one byte above the new 0x10000 limit is still rejected on Amsterdam (EIP-7954). rev = EVMC_AMSTERDAM; static constexpr auto code_size = 0x10000 + 1; - // Enough to deposit the code, so only the limit can reject it. - tx.gas_limit = 110'000'000; + tx.gas_limit = 110'000'000; // Enough to deposit the code, so only the limit can reject it. block.gas_limit = tx.gas_limit; pre[Sender].balance = tx.gas_limit * tx.max_gas_price; tx.data = ret(0, code_size); // Init code returns code one byte over the limit. From bde389c24d64bdf724b02ea9ebb8fd6661fa5cc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 9 Sep 2026 16:14:18 +0200 Subject: [PATCH 08/20] Shorten the state-gas comments Keep the rule and the reason it is not obvious; drop the restatements of what the code below does and the EELS function names, which date faster than the code. The two host.cpp blocks lead with the rule and leave the TODO as a separate sentence. --- lib/evmone/execution_state.hpp | 5 ++--- test/state/host.cpp | 38 ++++++++++++---------------------- test/state/state.cpp | 19 ++++++----------- 3 files changed, 21 insertions(+), 41 deletions(-) diff --git a/lib/evmone/execution_state.hpp b/lib/evmone/execution_state.hpp index 1bbb693ad3..687baeec63 100644 --- a/lib/evmone/execution_state.hpp +++ b/lib/evmone/execution_state.hpp @@ -157,9 +157,8 @@ class ExecutionState /// The frame's state-gas reservoir + spill; used is derived (EIP-8037). /// - /// Declared in the cold tail: inserting it earlier shifts `status` and `host` past the - /// x86-64 disp8 window, which costs 3 bytes of encoding on every one of the ~195 `status` - /// accesses in each dispatch loop. + /// Kept in the cold tail: earlier placement pushes `status` and `host` out of the x86-64 + /// disp8 window, costing 3 bytes on every `status` access in the dispatch loop. StateGas state_gas; /// Stack space allocation. diff --git a/test/state/host.cpp b/test/state/host.cpp index b0ea245cf9..7897049c32 100644 --- a/test/state/host.cpp +++ b/test/state/host.cpp @@ -233,10 +233,8 @@ evmc::Result Host::create(const evmc_message& msg) noexcept create_msg.input_data = nullptr; create_msg.input_size = 0; - // The create frame's state gas, held across the initcode execution: the depth-0 tx-level - // create charges the created account's NEW_ACCOUNT here (the opcode CREATE charges it in - // create_impl), the initcode frame's pools merge back in below, and the code deposit draws - // from the total (charge-at-access, EELS #3126) (EIP-8037). + // The create frame's state gas, held across the initcode execution. Only the depth-0 create + // charges NEW_ACCOUNT here; the opcode charges it in create_impl (EIP-8037). StateGas state_gas{.left = create_msg.state_gas}; if (m_rev >= EVMC_AMSTERDAM && msg.depth == 0 && !target_alive) { @@ -249,10 +247,8 @@ evmc::Result Host::create(const evmc_message& msg) noexcept auto result = m_vm.execute(*this, m_rev, create_msg, initcode.data(), initcode.size()); if (result.status_code != EVMC_SUCCESS) { - // No account created, so the NEW_ACCOUNT charge is refunded: Host::call restores the - // reservoir portion, while the spilled portion returns to gas only on a revert — an - // exceptional halt consumes it as regular gas (matches EELS refill_frame_state_gas then - // gas_left = 0). + // No account created, so the charge is refunded. Host::call restores the reservoir; the + // spill returns to gas on a revert and is consumed by a halt (EIP-8037). if (result.status_code == EVMC_REVERT) result.gas_left += state_gas.spilled; return result; @@ -319,14 +315,11 @@ evmc::Result Host::execute_message(const evmc_message& msg) noexcept // `msg.gas` for the rest of the function. auto gas = msg.gas; - // TODO: This depth-0 charge belongs to the transaction pre-execution phase in transition(), - // beside the EIP-7702 authorizations it follows, not in the per-frame dispatcher. Moving it - // drops the `msg.depth == 0` special cases here and the gas plumbed around them. // A top-level value transfer pays NEW_ACCOUNT for the recipient it materializes, evaluated - // against the pre-transfer state, after the authorizations and before any opcode. Charged - // here rather than in the interpreter because such a transfer runs no code (EIP-8037). - // `msg.state_gas` stays the entry reservoir while `top_level_sg` holds the post-charge pools, - // which a consuming path commits on success; on failure Host::call restores them. + // against the pre-transfer state. Charged here because such a transfer runs no code + // (EIP-8037). + // TODO: This belongs in transition(), beside the EIP-7702 authorizations it follows. Moving + // it drops the `msg.depth == 0` special cases here and the gas plumbed around them. StateGas top_level_sg{.left = msg.state_gas}; if (m_rev >= EVMC_AMSTERDAM && msg.depth == 0) { @@ -401,12 +394,9 @@ evmc::Result Host::execute_message(const evmc_message& msg) noexcept } // The depth-0 charge cannot reach here: it implies a not-alive recipient, which has empty - // code and returned above. Asserted rather than carried out, because it must be refilled on - // failure while the authorization charges beside it must survive one. - // TODO: The premise couples two addresses that coincide only by convention: liveness is read - // from `msg.recipient`, code from `msg.code_address`, and they differ under EVMC_DELEGATED. - // Should they ever part, NDEBUG turns this into a silently dropped charge and an under-paid - // transaction. Moving the charge to transition() (TODO above) removes the coupling. + // code and returned above. + // TODO: The premise holds only while `msg.recipient` and `msg.code_address` agree, which + // EVMC_DELEGATED breaks. Moving the charge to transition() (TODO above) removes the coupling. assert(gas == msg.gas && top_level_sg.left == msg.state_gas && top_level_sg.spilled == 0); return m_vm.execute(*this, m_rev, msg, code.data(), code.size()); } @@ -429,10 +419,8 @@ evmc::Result Host::call(const evmc_message& msg) noexcept if (result.status_code != EVMC_SUCCESS) { - // A rolled-back frame created no state, so it carries no state gas out: restore the entry - // reservoir and drop the spill, which the frame either returned to its own gas_left - // (revert) or consumed with it (halt). Enforced here for every failure path, including - // the ones this Host builds itself (EIP-8037). + // A rolled-back frame created no state, so it carries none out. Enforced here for every + // failure path, including the ones this Host builds itself (EIP-8037). result.state_gas_left = msg.state_gas; result.state_gas_spilled = 0; diff --git a/test/state/state.cpp b/test/state/state.cpp index 441f26643b..6afdf0711b 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -503,10 +503,8 @@ std::variant validate_transaction( if (rev >= EVMC_OSAKA && rev < EVMC_AMSTERDAM && tx.gas_limit > MAX_TX_GAS_LIMIT) return make_error_code(GAS_LIMIT_EXCEEDS_MAXIMUM); - // The tx must fit in the block's remaining gas. Checked ahead of the sender's nonce and - // balance, matching the pre-existing order. Note EELS check_transaction runs the whole of - // validate_transaction (including the intrinsic checks below) before this, so a transaction - // invalid in several ways can report a different one of them here. + // The tx must fit in the block's remaining gas. Checked before the nonce and balance, as + // before, so a transaction invalid in several ways can report a different one than EELS. if (rev < EVMC_AMSTERDAM) { if (tx.gas_limit > block_gas_left) @@ -680,11 +678,8 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc } } - // Split execution gas into a regular budget and a state-gas reservoir. The intrinsic — regular - // only, the state-dependent charges being applied at the top frame — is already subtracted - // from gas_limit (EIP-8037). - // regular = min(MAX_TX_GAS_LIMIT - intrinsic_regular, exec_gas); reservoir = exec_gas - - // regular. + // Split execution gas into a regular budget and a state-gas reservoir (EIP-8037): + // regular = min(MAX_TX_GAS_LIMIT - intrinsic_regular, exec_gas), reservoir = the rest. if (rev >= EVMC_AMSTERDAM) { const auto exec_gas = tx_props.execution_gas_limit; @@ -721,10 +716,8 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc int64_t state_block_gas = 0; if (rev >= EVMC_AMSTERDAM) { - // `exec_state_gas` captures all state gas and the intrinsic state gas is zero, so the - // remainder — including any CREATE-collision burned gas — is the regular component, - // floored at the calldata floor so state-gas spending cannot discount it (EELS: - // max(before_refund - state, floor)) (EIP-7778, EIP-8037). + // The intrinsic state gas is zero, so whatever `exec_state_gas` does not cover is the + // regular component, floored so state-gas spending cannot discount it (EIP-7778). state_block_gas = exec_state_gas; regular_block_gas = std::max(gas_used_b4_refund - exec_state_gas, tx_props.min_gas_cost); } From 8ab6500d3f6ee0d1f9e8813570f27dac33826095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 9 Sep 2026 16:15:32 +0200 Subject: [PATCH 09/20] test: Name the state-gas test constants in upper case --- ...tate_transition_eip8037_state_gas_test.cpp | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/test/unittests/state_transition_eip8037_state_gas_test.cpp b/test/unittests/state_transition_eip8037_state_gas_test.cpp index c5f9eb66f8..c4af72c98d 100644 --- a/test/unittests/state_transition_eip8037_state_gas_test.cpp +++ b/test/unittests/state_transition_eip8037_state_gas_test.cpp @@ -55,40 +55,40 @@ namespace { // Gas pinned empirically: 21000 intrinsic + the CALL's regular cost, with the // EIP-8037 NEW_ACCOUNT state charge refilled on the light failure. -constexpr int64_t CallLightfailRegularGas = 30'321; +constexpr int64_t CALL_LIGHTFAIL_REGULAR_GAS = 30'321; } // namespace TEST_F(state_transition, eip8037_call_value_lightfail_new_account_charge_refilled) { rev = EVMC_AMSTERDAM; tx.to = To; - static constexpr auto Target = 0xbeef_address; // intentionally absent from `pre` + static constexpr auto TARGET = 0xbeef_address; // intentionally absent from `pre` // To has balance 0, so `CALL value=1` light-fails the sender-balance check — - // but only AFTER NEW_ACCOUNT_STATE_GAS is charged for the absent Target. - pre[To] = {.code = call(Target).value(1).gas(0xffff) + OP_STOP}; + // but only AFTER NEW_ACCOUNT_STATE_GAS is charged for the absent TARGET. + pre[To] = {.code = call(TARGET).value(1).gas(0xffff) + OP_STOP}; expect.status = EVMC_SUCCESS; // To STOPs after the failed CALL (light failure) expect.post[To] = {}; // To survives - expect.post[Target].exists = false; // no account was created - expect.gas_used = CallLightfailRegularGas; - expect.state_gas = 0; // the NEW_ACCOUNT charge for the absent Target is refilled + expect.post[TARGET].exists = false; // no account was created + expect.gas_used = CALL_LIGHTFAIL_REGULAR_GAS; + expect.state_gas = 0; // the NEW_ACCOUNT charge for the absent TARGET is refilled } TEST_F(state_transition, eip8037_call_value_lightfail_existing_account_baseline) { rev = EVMC_AMSTERDAM; tx.to = To; - static constexpr auto Target = 0xbeef_address; + static constexpr auto TARGET = 0xbeef_address; - pre[To] = {.code = call(Target).value(1).gas(0xffff) + OP_STOP}; - pre[Target] = {.nonce = 1, .code = bytecode{OP_STOP}}; // Target exists → NO new-account charge + pre[To] = {.code = call(TARGET).value(1).gas(0xffff) + OP_STOP}; + pre[TARGET] = {.nonce = 1, .code = bytecode{OP_STOP}}; // TARGET exists → NO new-account charge expect.status = EVMC_SUCCESS; expect.post[To] = {}; - expect.post[Target] = {.nonce = 1}; // unchanged by the light-failed call - expect.gas_used = CallLightfailRegularGas; // same regular gas as the new-account case - expect.state_gas = 0; // Target exists -> no new-account state-gas charge + expect.post[TARGET] = {.nonce = 1}; // unchanged by the light-failed call + expect.gas_used = CALL_LIGHTFAIL_REGULAR_GAS; // same regular gas as the new-account case + expect.state_gas = 0; // TARGET exists -> no new-account state-gas charge } TEST_F(state_transition, eip8037_value_to_zero_balance_precompile_pays_new_account) @@ -100,11 +100,11 @@ TEST_F(state_transition, eip8037_value_to_zero_balance_precompile_pays_new_accou tx.to = 0x04_address; // identity, intentionally absent from `pre` tx.value = 1; - static constexpr int64_t IdentityBaseCost = 15; + static constexpr int64_t IDENTITY_BASE_COST = 15; expect.status = EVMC_SUCCESS; expect.post[*tx.to].balance = 1; - expect.gas_used = 21'000 + IdentityBaseCost + evmone::NEW_ACCOUNT_STATE_GAS; + expect.gas_used = 21'000 + IDENTITY_BASE_COST + evmone::NEW_ACCOUNT_STATE_GAS; expect.state_gas = evmone::NEW_ACCOUNT_STATE_GAS; } @@ -131,9 +131,9 @@ TEST_F(state_transition, eip8037_sstore_slot_cleared_in_a_child_frame) // reach the `gas_left` that funded the spilled allocation charge. rev = EVMC_AMSTERDAM; tx.to = To; - static constexpr auto Clearer = 0xdead_address; - pre[Clearer] = {.code = sstore(1, 0)}; - pre[To] = {.code = sstore(1, 1) + delegatecall(Clearer).gas(0xffff) + OP_STOP}; + static constexpr auto CLEARER = 0xdead_address; + pre[CLEARER] = {.code = sstore(1, 0)}; + pre[To] = {.code = sstore(1, 1) + delegatecall(CLEARER).gas(0xffff) + OP_STOP}; // Pre-refund: 21000 intrinsic + 30 (ten PUSHes) + 5000 (cold slot allocation) // + 2600 (cold DELEGATECALL) + 100 (warm clear) = 28730. The clear refunds 2800. @@ -141,7 +141,7 @@ TEST_F(state_transition, eip8037_sstore_slot_cleared_in_a_child_frame) expect.gas_refund = 2800; expect.state_gas = 0; expect.post[To].exists = true; - expect.post[Clearer].exists = true; + expect.post[CLEARER].exists = true; } TEST_F(state_transition, eip8037_code_deposit_out_of_regular_gas_with_a_full_reservoir) From 8d28f605a5a406587c4ed3a7648fe644c0aad036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 9 Sep 2026 17:20:57 +0200 Subject: [PATCH 10/20] Move is_alive() to its only user Two call sites, both in host.cpp; it does not need to be in the Account header. --- test/state/account.hpp | 6 ------ test/state/host.cpp | 7 +++++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/test/state/account.hpp b/test/state/account.hpp index 4da03edc47..ef42a5273d 100644 --- a/test/state/account.hpp +++ b/test/state/account.hpp @@ -98,10 +98,4 @@ struct Account } }; -/// Whether a looked-up account is alive, i.e. has a state leaf: it exists and is not empty -/// (EIP-161). A null pointer is a non-existent account. -[[nodiscard]] inline bool is_alive(const Account* account) noexcept -{ - return account != nullptr && !account->is_empty(); -} } // namespace evmone::state diff --git a/test/state/host.cpp b/test/state/host.cpp index 7897049c32..f5499be23e 100644 --- a/test/state/host.cpp +++ b/test/state/host.cpp @@ -12,6 +12,13 @@ namespace evmone::state { namespace { +/// Whether a looked-up account is alive, i.e. has a state leaf: it exists and is not empty +/// (EIP-161). A null pointer is a non-existent account. +[[nodiscard]] bool is_alive(const Account* account) noexcept +{ + return account != nullptr && !account->is_empty(); +} + /// Sets the state-gas fields on a returned Result. `used` is not stored; the caller derives it /// as `initial - left + spilled` (EIP-8037). void set_state_gas(evmc::Result& r, int64_t left, int64_t spilled) noexcept From a9ccccc4750b357230878de23bfc8519f1b5f3be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 9 Sep 2026 17:21:14 +0200 Subject: [PATCH 11/20] Flag the "regular gas" naming for review EIP-8037 calls it execution gas and never says "regular", but execution_gas_limit already names something else here. --- lib/evmone/state_gas.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/evmone/state_gas.hpp b/lib/evmone/state_gas.hpp index f76f69b94f..48352fd077 100644 --- a/lib/evmone/state_gas.hpp +++ b/lib/evmone/state_gas.hpp @@ -10,6 +10,10 @@ namespace evmone { /// A frame's state-gas as a (left, spilled) pair, independent from the regular gas (EIP-8037). +/// +/// REVIEW: "regular gas" is our term; EIP-8037 says execution gas throughout and never uses +/// "regular". The spec's word is already taken here: TransactionProperties::execution_gas_limit +/// is gas_limit - intrinsic, i.e. the regular budget and the reservoir together. struct StateGas { /// Remaining state-gas reservoir. From 4c8b180c1e9cb7d319d3552625bdd47cf1fb4a22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 9 Sep 2026 17:30:47 +0200 Subject: [PATCH 12/20] Assert the transaction's state gas is non-negative The clamp cannot trigger: a refill needs a matching allocation, and the top frame has no ancestor to have made one. Asserting says so, and catches the case the clamp would have hidden. --- test/state/state.cpp | 7 +++++-- test/unittests/state_transition_eip8037_state_gas_test.cpp | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/test/state/state.cpp b/test/state/state.cpp index 6afdf0711b..fa4e191ba2 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include using namespace intx; @@ -694,9 +695,11 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc // Net state gas consumed by the execution, derived from the reservoir the top frame was // handed: initial - left + spilled. Zero on a top-level failure, the frame having refilled - // itself. Clamped at 0 defensively (EIP-8037). + // itself. Never negative: a refill needs a matching allocation, and the top frame has no + // ancestor to have made one (EIP-8037). const auto exec_state_gas = - std::max(0, message.state_gas - result.state_gas_left + result.state_gas_spilled); + message.state_gas - result.state_gas_left + result.state_gas_spilled; + assert(exec_state_gas >= 0); // Gas consumed = gas_limit - regular_unspent - reservoir_unspent, pre-refund and pre-floor. // Kept immutable: the receipt's gas_refund is derived from it (EIP-8037). diff --git a/test/unittests/state_transition_eip8037_state_gas_test.cpp b/test/unittests/state_transition_eip8037_state_gas_test.cpp index c4af72c98d..7f1d8da0e5 100644 --- a/test/unittests/state_transition_eip8037_state_gas_test.cpp +++ b/test/unittests/state_transition_eip8037_state_gas_test.cpp @@ -175,3 +175,4 @@ TEST_F(state_transition, eip8037_code_deposit_out_of_regular_gas_with_a_full_res expect.post[CREATOR].nonce = pre[CREATOR].nonce + 1; // bumped by the CREATE expect.post[compute_create_address(CREATOR, pre[CREATOR].nonce)].exists = false; } + From 7ba0f2934ec72f9548849ab75f5218c04da25193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 9 Sep 2026 17:32:41 +0200 Subject: [PATCH 13/20] Name the block-level gas counters consistently Prefix them by the level they measure, as block_gas_left already is, and put the state dimension next to it in the parameter lists rather than after the blob gas. --- test/state/state.cpp | 16 ++++++++-------- test/state/state.hpp | 5 ++--- test/state/transaction.hpp | 4 ++-- test/unittests/state_transition.cpp | 6 +++--- test/unittests/state_transition.hpp | 2 +- .../state_transition_eip8037_state_gas_test.cpp | 2 +- test/unittests/state_tx_test.cpp | 16 ++++++++-------- test/utils/block_transition.cpp | 12 ++++++------ test/utils/statetest_runner.cpp | 4 ++-- test/utils/test_state.cpp | 4 ++-- test/utils/test_state.hpp | 2 +- 11 files changed, 36 insertions(+), 37 deletions(-) diff --git a/test/state/state.cpp b/test/state/state.cpp index fa4e191ba2..913bf89b39 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -438,7 +438,7 @@ void State::rollback(size_t checkpoint) /// @return Execution gas limit or transaction validation error. std::variant validate_transaction( const StateView& state_view, const BlockInfo& block, const Transaction& tx, evmc_revision rev, - int64_t block_gas_left, int64_t blob_gas_left, int64_t state_block_gas_left) noexcept + int64_t block_gas_left, int64_t block_state_gas_left, int64_t blob_gas_left) noexcept { if (tx.chain_id_protected() && tx.chain_id != block.chain_id) return make_error_code(INVALID_CHAIN_ID); @@ -517,7 +517,7 @@ std::variant validate_transaction( // (EIP-8037 inclusion rule 2). if (std::min(MAX_TX_GAS_LIMIT, tx.gas_limit) > block_gas_left) return make_error_code(GAS_ALLOWANCE_EXCEEDED); - if (tx.gas_limit > state_block_gas_left) + if (tx.gas_limit > block_state_gas_left) return make_error_code(GAS_ALLOWANCE_EXCEEDED); } @@ -715,14 +715,14 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc // The block's 2D gas components (EIP-7778): pre-Amsterdam the block tracks a single // dimension, so all of the gas the sender paid for is regular. - auto regular_block_gas = sender_gas_cost; - int64_t state_block_gas = 0; + auto block_regular_gas = sender_gas_cost; + int64_t block_state_gas = 0; if (rev >= EVMC_AMSTERDAM) { // The intrinsic state gas is zero, so whatever `exec_state_gas` does not cover is the // regular component, floored so state-gas spending cannot discount it (EIP-7778). - state_block_gas = exec_state_gas; - regular_block_gas = std::max(gas_used_b4_refund - exec_state_gas, tx_props.min_gas_cost); + block_state_gas = exec_state_gas; + block_regular_gas = std::max(gas_used_b4_refund - exec_state_gas, tx_props.min_gas_cost); } sender_acc.balance += tx_max_cost - sender_gas_cost * effective_gas_price; state.touch(block.coinbase).balance += sender_gas_cost * priority_gas_price; @@ -734,8 +734,8 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc // Receipt gas_used = what the sender paid for: post-refund, floored at the // EIP-7623 calldata floor. receipt.gas_used = sender_gas_cost; - receipt.regular_block_gas = regular_block_gas; - receipt.state_block_gas = state_block_gas; + receipt.block_regular_gas = block_regular_gas; + receipt.block_state_gas = block_state_gas; // Per-tx refund applied to receipt.gas_used: the floored pre-refund gas minus what the // sender paid, so gas_used + gas_refund is the pre-refund gas the block accumulates // (EIP-7778) and never goes negative when the calldata floor binds. diff --git a/test/state/state.hpp b/test/state/state.hpp index c543110b58..af44bd9c29 100644 --- a/test/state/state.hpp +++ b/test/state/state.hpp @@ -143,10 +143,9 @@ TransactionReceipt transition(const StateView& state, const BlockInfo& block, /// Validate a transaction. /// -/// @param state_block_gas_left Remaining block state-gas (EIP-8037). FIXME: rename to -/// block_state_gas_left and place after block_gas_left. +/// @param block_state_gas_left Remaining block state-gas (EIP-8037). /// @return Computed execution gas limit or validation error. [[nodiscard]] std::variant validate_transaction( const StateView& state_view, const BlockInfo& block, const Transaction& tx, evmc_revision rev, - int64_t block_gas_left, int64_t blob_gas_left, int64_t state_block_gas_left) noexcept; + int64_t block_gas_left, int64_t block_state_gas_left, int64_t blob_gas_left) noexcept; } // namespace evmone::state diff --git a/test/state/transaction.hpp b/test/state/transaction.hpp index 524f545db6..da7afc8206 100644 --- a/test/state/transaction.hpp +++ b/test/state/transaction.hpp @@ -146,8 +146,8 @@ struct TransactionReceipt /// 2D per-tx block-gas components. The runner aggregates as /// `block.gas_used = max(sum_regular, sum_state)` (EIP-7778). Pre-Amsterdam the block has a /// single dimension: the regular component is `gas_used` and the state one is 0 (EIP-8037). - int64_t regular_block_gas = 0; ///< Regular gas component. - int64_t state_block_gas = 0; ///< State gas component. + int64_t block_regular_gas = 0; ///< Regular gas component. + int64_t block_state_gas = 0; ///< State gas component. std::vector logs; BloomFilter logs_bloom_filter; diff --git a/test/unittests/state_transition.cpp b/test/unittests/state_transition.cpp index bab589dbe6..f4a7d713d4 100644 --- a/test/unittests/state_transition.cpp +++ b/test/unittests/state_transition.cpp @@ -61,8 +61,8 @@ void state_transition::TearDown() // After EVMC_PRAGUE, get_blob_params will not work like that without a blob schedule. // TODO: add a blob schedule to use with state_transition tests, should they be added. const auto res = test::transition(state, block, block_hashes, tx, rev, selected_vm, - block.gas_limit, static_cast(state::max_blob_gas_per_block(get_blob_params(rev))), - block.gas_limit); + block.gas_limit, block.gas_limit, + static_cast(state::max_blob_gas_per_block(get_blob_params(rev)))); test::finalize(state, rev, block.coinbase, block_reward, block.ommers, block.withdrawals); const auto& post = state; @@ -103,7 +103,7 @@ void state_transition::TearDown() } } if (expect.state_gas.has_value()) - EXPECT_EQ(receipt.state_block_gas, *expect.state_gas); + EXPECT_EQ(receipt.block_state_gas, *expect.state_gas); // Update default expectations - valid transaction means coinbase exists unless explicitly // requested otherwise if (!expect.post.contains(Coinbase)) diff --git a/test/unittests/state_transition.hpp b/test/unittests/state_transition.hpp index 72d48c0cd2..8c47433913 100644 --- a/test/unittests/state_transition.hpp +++ b/test/unittests/state_transition.hpp @@ -71,7 +71,7 @@ class state_transition : public ExportableFixture /// exactly: count, address, data, topics, and order. std::optional> logs; - /// The expected EIP-8037 state-gas component of the receipt (`state_block_gas`), + /// The expected EIP-8037 state-gas component of the receipt (`block_state_gas`), /// e.g. a NEW_ACCOUNT_STATE_GAS charge that survives a light failure. std::optional state_gas; diff --git a/test/unittests/state_transition_eip8037_state_gas_test.cpp b/test/unittests/state_transition_eip8037_state_gas_test.cpp index 7f1d8da0e5..96f51c0bbe 100644 --- a/test/unittests/state_transition_eip8037_state_gas_test.cpp +++ b/test/unittests/state_transition_eip8037_state_gas_test.cpp @@ -46,7 +46,7 @@ TEST_F(state_transition, eip8037_create_tx_collision_excess_reservoir_refunded) // (120 * 1530 = 183'600) to the state-gas dimension before the sender-balance // check. When that check light-fails (caller balance < value), no account is // created and the charge is refilled at the failure boundary (EIP-8037 -// source-based refunds), so the net state gas (state_block_gas) +// source-based refunds), so the net state gas (block_state_gas) // is 0 — the same as the existing-target baseline. The two tests pin this as // a differential: the ONLY difference is whether the target pre-exists, so // both regular gas_used and state gas must be identical. If the refill ever diff --git a/test/unittests/state_tx_test.cpp b/test/unittests/state_tx_test.cpp index 42deb06f99..72e0c1630a 100644 --- a/test/unittests/state_tx_test.cpp +++ b/test/unittests/state_tx_test.cpp @@ -92,17 +92,17 @@ TEST(state_tx, validate_blob_tx) const auto blob_gas_limit = static_cast(max_blob_gas_per_block(get_blob_params(EVMC_CANCUN))); EXPECT_EQ(std::get(validate_transaction( - state, block, tx, EVMC_SHANGHAI, block.gas_limit, blob_gas_limit, 0)), + state, block, tx, EVMC_SHANGHAI, block.gas_limit, 0, blob_gas_limit)), make_error_code(ErrorCode::TYPE_NOT_SUPPORTED)); EXPECT_EQ(std::get(validate_transaction(state, block, tx, EVMC_CANCUN, - block.gas_limit, blob_gas_limit, 0)) + block.gas_limit, 0, blob_gas_limit)) .message(), make_error_code(ErrorCode::CREATE_BLOB_TX).message()); tx.to = 0x01_address; EXPECT_EQ(std::get(validate_transaction( - state, block, tx, EVMC_CANCUN, block.gas_limit, blob_gas_limit, 0)), + state, block, tx, EVMC_CANCUN, block.gas_limit, 0, blob_gas_limit)), make_error_code(ErrorCode::EMPTY_BLOB_HASHES_LIST)); for (uint8_t i = 0; i < 6; ++i) @@ -114,7 +114,7 @@ TEST(state_tx, validate_blob_tx) const auto expect_error = [&](int64_t g) { return std::get( - validate_transaction(state, block, tx, EVMC_CANCUN, block.gas_limit, g, 0)); + validate_transaction(state, block, tx, EVMC_CANCUN, block.gas_limit, 0, g)); }; EXPECT_EQ(expect_error(blob_gas_limit), @@ -130,7 +130,7 @@ TEST(state_tx, validate_blob_tx) expect_error(blob_gas_limit - 1), make_error_code(ErrorCode::BLOB_GAS_LIMIT_EXCEEDED)); EXPECT_EQ(std::get(validate_transaction(state, block, tx, EVMC_CANCUN, - block.gas_limit, blob_gas_limit, 0)) + block.gas_limit, 0, blob_gas_limit)) .execution_gas_limit, 39000); @@ -158,7 +158,7 @@ TEST(state_tx, validate_eof_create_transaction) { const auto rev = static_cast(r); const auto res = - validate_transaction(state, block, tx, rev, block.gas_limit, 0, block.gas_limit); + validate_transaction(state, block, tx, rev, block.gas_limit, block.gas_limit, 0); EXPECT_FALSE(holds_alternative(res)); } } @@ -234,14 +234,14 @@ TEST(state_tx, max_blob_count) // Should be valid EXPECT_FALSE(holds_alternative( - validate_transaction(state, block, tx, EVMC_CANCUN, block.gas_limit, blob_gas_limit, 0))); + validate_transaction(state, block, tx, EVMC_CANCUN, block.gas_limit, 0, blob_gas_limit))); // Add one more blob to exceed the limit tx.blob_hashes.emplace_back( 0x01000000000000000000000000000000000000000000000000000000000000FF_bytes32); EXPECT_EQ(std::get(validate_transaction( - state, block, tx, EVMC_CANCUN, block.gas_limit, blob_gas_limit, 0)), + state, block, tx, EVMC_CANCUN, block.gas_limit, 0, blob_gas_limit)), make_error_code(ErrorCode::BLOB_GAS_LIMIT_EXCEEDED)); } diff --git a/test/utils/block_transition.cpp b/test/utils/block_transition.cpp index 5c34f4aa1a..dbfea86324 100644 --- a/test/utils/block_transition.cpp +++ b/test/utils/block_transition.cpp @@ -49,7 +49,7 @@ TransitionResult apply_block(const TestState& state, evmc::VM& vm, const state:: std::vector receipts; int64_t block_gas_left = block.gas_limit; - int64_t state_block_gas_left = block.gas_limit; + int64_t block_state_gas_left = block.gas_limit; int64_t cumulative_gas_used = 0; // The two dimensions of the block-level max(sum_regular, sum_state) formula (EIP-8037). int64_t sum_regular_gas = 0; @@ -66,7 +66,7 @@ TransitionResult apply_block(const TestState& state, evmc::VM& vm, const state:: trace_guard.emplace(std::clog, opts.open_trace(i, computed_tx_hash).rdbuf()); auto res = transition(block_state, block, block_hashes, tx, rev, vm, block_gas_left, - blob_gas_left, state_block_gas_left); + block_state_gas_left, blob_gas_left); if (holds_alternative(res)) { @@ -83,10 +83,10 @@ TransitionResult apply_block(const TestState& state, evmc::VM& vm, const state:: // Accumulate the 2D components for the block-level max(sum_regular, sum_state) // formula, which pre-Amsterdam is the single gas dimension (EIP-8037). - sum_regular_gas += receipt.regular_block_gas; - sum_state_gas += receipt.state_block_gas; - block_gas_left -= receipt.regular_block_gas; - state_block_gas_left -= receipt.state_block_gas; + sum_regular_gas += receipt.block_regular_gas; + sum_state_gas += receipt.block_state_gas; + block_gas_left -= receipt.block_regular_gas; + block_state_gas_left -= receipt.block_state_gas; blob_gas_left -= static_cast(tx.blob_gas_used()); receipts.emplace_back(std::move(receipt)); } diff --git a/test/utils/statetest_runner.cpp b/test/utils/statetest_runner.cpp index 3d8e4a2db5..de0c228468 100644 --- a/test/utils/statetest_runner.cpp +++ b/test/utils/statetest_runner.cpp @@ -61,8 +61,8 @@ void run_state_test( const auto res = error ? error : transition(state, block, test.block_hashes, *tx, rev, vm, block.gas_limit, - static_cast(state::max_blob_gas_per_block(blob_params)), - block.gas_limit); + block.gas_limit, + static_cast(state::max_blob_gas_per_block(blob_params))); if (holds_alternative(res)) { diff --git a/test/utils/test_state.cpp b/test/utils/test_state.cpp index 6afe61d29a..23dafea8c7 100644 --- a/test/utils/test_state.cpp +++ b/test/utils/test_state.cpp @@ -73,10 +73,10 @@ bytes32 TestBlockHashes::get_block_hash(int64_t block_number) const noexcept [[nodiscard]] std::variant transition(TestState& state, const state::BlockInfo& block, const state::BlockHashes& block_hashes, const state::Transaction& tx, evmc_revision rev, evmc::VM& vm, int64_t block_gas_left, - int64_t blob_gas_left, int64_t state_block_gas_left) + int64_t block_state_gas_left, int64_t blob_gas_left) { const auto tx_props_or_error = state::validate_transaction( - state, block, tx, rev, block_gas_left, blob_gas_left, state_block_gas_left); + state, block, tx, rev, block_gas_left, block_state_gas_left, blob_gas_left); if (const auto err = get_if(&tx_props_or_error)) return *err; diff --git a/test/utils/test_state.hpp b/test/utils/test_state.hpp index 5308d778f4..0dba2f20ea 100644 --- a/test/utils/test_state.hpp +++ b/test/utils/test_state.hpp @@ -72,7 +72,7 @@ class TestBlockHashes : public state::BlockHashes, public std::unordered_map transition(TestState& state, const state::BlockInfo& block, const state::BlockHashes& block_hashes, const state::Transaction& tx, evmc_revision rev, evmc::VM& vm, int64_t block_gas_left, - int64_t blob_gas_left, int64_t state_block_gas_left); + int64_t block_state_gas_left, int64_t blob_gas_left); /// Wrapping of state::finalize() which operates on TestState. void finalize(TestState& state, evmc_revision rev, const address& coinbase, From b09bc1cf20c4fb885c571dc557042b978a4d4914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 9 Sep 2026 22:18:27 +0200 Subject: [PATCH 14/20] Cut the system call reservoir down to the constant The value is the spec's SYSTEM_MAX_SSTORES_PER_CALL times the slot cost; the prose around it restated the EIP. --- test/state/system_contracts.cpp | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/test/state/system_contracts.cpp b/test/state/system_contracts.cpp index 34623ccc36..82f42f8a89 100644 --- a/test/state/system_contracts.cpp +++ b/test/state/system_contracts.cpp @@ -74,19 +74,10 @@ static_assert(std::ranges::is_sorted(REQUESTS_SYSTEM_CONTRACTS, by_rev), "system contract entries must be ordered by revision"); -/// Cap on the number of SSTOREs a system contract may fund out of its state-gas budget. The value -/// is observable: `system_contract_reaches_gas_limit` sizes a contract to exactly -/// `30M + SYSTEM_MAX_SSTORES_PER_CALL × STORAGE_SET_STATE_GAS` (EIP-8037). -// constexpr int64_t SYSTEM_MAX_SSTORES_PER_CALL = 16; - evmc::Result execute_system_call(State& state, const BlockInfo& block, const BlockHashes& block_hashes, evmc_revision rev, evmc::VM& vm, const address& addr, bytes_view code, bytes_view input) { - // A system call gets a state reservoir covering `SYSTEM_MAX_SSTORES_PER_CALL` zero→non-zero - // SSTOREs, so state gas cannot OOG it. The reservoir is separate from the 30M regular - // gas_left, which the GAS opcode, the 63/64 forwarding base and a >30M regular-gas burn all - // observe (EIP-8037 §"System contracts and system transactions"). const evmc_message msg{ .kind = EVMC_CALL, .gas = 30'000'000, @@ -94,8 +85,7 @@ evmc::Result execute_system_call(State& state, const BlockInfo& block, .sender = SYSTEM_ADDRESS, .input_data = input.data(), .input_size = input.size(), - // REVIEW: Check if this is fine globally. - .state_gas = 16 * STORAGE_SET_STATE_GAS, + .state_gas = 16 * STORAGE_SET_STATE_GAS, // Additional state-gas (EIP-8037). }; const Transaction empty_tx{}; From 23ed6c9c189f0baee8408a373a99be749fcb04af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 9 Sep 2026 22:32:14 +0200 Subject: [PATCH 15/20] test: Derive the code deposit gas from the cost tables The cap was pinned, and the EIP-8038 CREATE reprice moved the window out from under it. Take the CREATE price from the cost table and the rest from the deposit's own constants, so the frame lands mid-window on either schedule. Replace the pinned gas_used with a second case one regular component richer, which deploys. The pair fails if the window ever moves, where a single case would keep passing on the initcode running out of gas instead. --- ...tate_transition_eip8037_state_gas_test.cpp | 59 ++++++++++++++----- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/test/unittests/state_transition_eip8037_state_gas_test.cpp b/test/unittests/state_transition_eip8037_state_gas_test.cpp index 96f51c0bbe..ee151ad889 100644 --- a/test/unittests/state_transition_eip8037_state_gas_test.cpp +++ b/test/unittests/state_transition_eip8037_state_gas_test.cpp @@ -4,6 +4,7 @@ #include "state_transition.hpp" #include +#include #include using namespace evmc::literals; @@ -144,35 +145,63 @@ TEST_F(state_transition, eip8037_sstore_slot_cleared_in_a_child_frame) expect.post[CLEARER].exists = true; } +namespace +{ +/// The code deposit of a maximum-size contract, split into its two components (EIP-8037). +constexpr int64_t DEPOSIT_CODE_SIZE = MAX_CODE_SIZE_AMSTERDAM; +constexpr auto DEPOSIT_CODE_WORDS = DEPOSIT_CODE_SIZE / 32; +constexpr auto DEPOSIT_REGULAR = 6 * DEPOSIT_CODE_WORDS; +constexpr auto DEPOSIT_STATE = DEPOSIT_CODE_SIZE * COST_PER_STATE_BYTE; + +/// Gas cap leaving the initcode frame in the middle of the window where the deposit's state +/// component is affordable and its regular component is not: enough for CREATE and the memory the +/// returned code needs, plus half the regular component. The CREATE price is the only term a +/// reprice has moved, so it is taken from the cost table rather than pinned. +constexpr auto DEPOSIT_MEMORY = + 3 * DEPOSIT_CODE_WORDS + DEPOSIT_CODE_WORDS * DEPOSIT_CODE_WORDS / 512; +constexpr auto DEPOSIT_GAS_CAP = + instr::gas_costs[EVMC_AMSTERDAM][OP_CREATE] + DEPOSIT_MEMORY + DEPOSIT_REGULAR / 2; +} // namespace + TEST_F(state_transition, eip8037_code_deposit_out_of_regular_gas_with_a_full_reservoir) { // The code deposit splits into a regular and a state component. A reservoir that covers the // state component must not let the deposit through when the regular component is unaffordable. - rev = EVMC_AMSTERDAM; - static constexpr auto CODE_SIZE = 0x10000; // MAX_CODE_SIZE_AMSTERDAM. static constexpr auto CREATOR = 0xbbbb_address; - - // Above MAX_TX_GAS_LIMIT, so the excess forms a reservoir bigger than the deposit's - // CODE_SIZE * COST_PER_STATE_BYTE = 100'270'080 state component. - tx.gas_limit = 130'000'000; + rev = EVMC_AMSTERDAM; + tx.gas_limit = state::MAX_TX_GAS_LIMIT + DEPOSIT_STATE + 1'000'000; block.gas_limit = tx.gas_limit; pre[Sender].balance = intx::uint256{tx.gas_limit} * tx.max_gas_price + 1; tx.to = To; - // The reservoir reaches the initcode frame in full, but the gas cap leaves it short of the - // deposit's 6 * CODE_SIZE / 32 = 12'288 regular component. Pinned empirically: the cap has to - // land the initcode frame's leftover inside that 12'288-wide window, so a reprice of CREATE - // or of the CALL moves it. gas_used guards the tuning — without it a drifted cap would fail - // the CREATE earlier and still satisfy the expectations below. - const auto initcode = ret(0, CODE_SIZE); + const auto initcode = ret(0, DEPOSIT_CODE_SIZE); pre[CREATOR] = { .code = mstore(0, push(initcode)) + create().input(32 - initcode.size(), initcode.size())}; - pre[To] = {.code = call(CREATOR).gas(55000) + OP_STOP}; + pre[To] = {.code = call(CREATOR).gas(DEPOSIT_GAS_CAP) + OP_STOP}; - expect.status = EVMC_SUCCESS; - expect.gas_used = 78262; expect.post[To].exists = true; expect.post[CREATOR].nonce = pre[CREATOR].nonce + 1; // bumped by the CREATE expect.post[compute_create_address(CREATOR, pre[CREATOR].nonce)].exists = false; } +TEST_F(state_transition, eip8037_code_deposit_regular_gas_boundary) +{ + // The same deposit one regular component richer succeeds, pinning the case above to the + // regular gas rather than to anything else the CREATE pays for. + static constexpr auto CREATOR = 0xbbbb_address; + rev = EVMC_AMSTERDAM; + tx.gas_limit = state::MAX_TX_GAS_LIMIT + DEPOSIT_STATE + 1'000'000; + block.gas_limit = tx.gas_limit; + pre[Sender].balance = intx::uint256{tx.gas_limit} * tx.max_gas_price + 1; + tx.to = To; + + const auto initcode = ret(0, DEPOSIT_CODE_SIZE); + pre[CREATOR] = { + .code = mstore(0, push(initcode)) + create().input(32 - initcode.size(), initcode.size())}; + pre[To] = {.code = call(CREATOR).gas(DEPOSIT_GAS_CAP + DEPOSIT_REGULAR) + OP_STOP}; + + expect.post[To].exists = true; + expect.post[CREATOR].nonce = pre[CREATOR].nonce + 1; + expect.post[compute_create_address(CREATOR, pre[CREATOR].nonce)].code = + bytes(DEPOSIT_CODE_SIZE, 0x00); +} From 5ab7922fd9304548c429954d6836c30ab436a8be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Thu, 10 Sep 2026 21:13:24 +0200 Subject: [PATCH 16/20] test: Restyle the state-gas tests Give each test a short intro saying what it pins, and drop the commentary that restated the assertions below it. Say execution gas, as the EIP and the execution-specs do, and name the constants in upper case. Factor the code deposit's shared setup into named constants and one helper, so the two cases differ only in the gas cap. Let the gas expressions carry their own breakdown instead of repeating the operands in a comment. Assert the state gas in the three tests that left it unchecked, including the deposit pair, whose subject is which half of the deposit gets charged. --- ...tate_transition_eip8037_state_gas_test.cpp | 176 +++++++++--------- 1 file changed, 83 insertions(+), 93 deletions(-) diff --git a/test/unittests/state_transition_eip8037_state_gas_test.cpp b/test/unittests/state_transition_eip8037_state_gas_test.cpp index ee151ad889..7839fe1ec5 100644 --- a/test/unittests/state_transition_eip8037_state_gas_test.cpp +++ b/test/unittests/state_transition_eip8037_state_gas_test.cpp @@ -12,114 +12,98 @@ using namespace evmone::test; TEST_F(state_transition, eip8037_create_tx_collision_excess_reservoir_refunded) { - // Amsterdam lets tx.gas_limit exceed MAX_TX_GAS_LIMIT, placing the excess in the state-gas - // reservoir. A depth-0 CREATE that collides (EIP-7610) must return that reservoir rather - // than forfeit it, so the sender is billed at most MAX_TX_GAS_LIMIT. + // A create transaction colliding with an existing account (EIP-7610) returns its state-gas + // reservoir instead of forfeiting it, so the sender is billed at most MAX_TX_GAS_LIMIT. rev = EVMC_AMSTERDAM; constexpr int64_t TX_GAS_LIMIT = 18'000'000; - static_assert(TX_GAS_LIMIT > state::MAX_TX_GAS_LIMIT); + static_assert(TX_GAS_LIMIT > state::MAX_TX_GAS_LIMIT); // The excess forms the reservoir. block.gas_limit = TX_GAS_LIMIT * 2; - tx.gas_limit = TX_GAS_LIMIT; - // tx.to defaults to nullopt → CREATE tx. - - // SetUp() pre-funded the sender based on the default tx.gas_limit; redo it - // now that we have bumped it. + tx.gas_limit = TX_GAS_LIMIT; // tx.to stays nullopt: a create transaction. pre[Sender].balance = intx::uint256{tx.gas_limit} * tx.max_gas_price + tx.value + 1; - // Pre-deploy a contract at the address this CREATE tx would produce so - // is_create_collision() fires. Sender's default nonce is 1. - const auto create_address = compute_create_address(Sender, 1); + const auto create_address = compute_create_address(Sender, pre[Sender].nonce); pre[create_address] = {.nonce = 1, .code = bytecode{OP_STOP}}; - // The collision returns before the NEW_ACCOUNT charge, so no state gas is charged: - // reservoir = (gas_limit - intrinsic) - (MAX_TX_GAS_LIMIT - intrinsic) - // = 18'000'000 - 16'777'216 = 1'222'784 - // raw_gas_used = gas_limit - gas_left(0) - reservoir = MAX_TX_GAS_LIMIT + // The collision returns before the NEW_ACCOUNT charge, so no state-gas is charged. expect.status = EVMC_FAILURE; expect.gas_used = state::MAX_TX_GAS_LIMIT; - expect.gas_refund = 0; // No EVM gas refund; identity gas_used + gas_refund == max(R, floor). + expect.gas_refund = 0; + expect.state_gas = 0; expect.post[create_address] = {.nonce = 1, .code = bytecode{OP_STOP}}; } -// A value-bearing CALL to a NON-EXISTENT account charges NEW_ACCOUNT_STATE_GAS -// (120 * 1530 = 183'600) to the state-gas dimension before the sender-balance -// check. When that check light-fails (caller balance < value), no account is -// created and the charge is refilled at the failure boundary (EIP-8037 -// source-based refunds), so the net state gas (block_state_gas) -// is 0 — the same as the existing-target baseline. The two tests pin this as -// a differential: the ONLY difference is whether the target pre-exists, so -// both regular gas_used and state gas must be identical. If the refill ever -// regresses, the new-account case grows by exactly 183'600 in state gas. namespace { -// Gas pinned empirically: 21000 intrinsic + the CALL's regular cost, with the -// EIP-8037 NEW_ACCOUNT state charge refilled on the light failure. -constexpr int64_t CALL_LIGHTFAIL_REGULAR_GAS = 30'321; +/// Pinned: the intrinsic plus the CALL's execution gas, the NEW_ACCOUNT state charge having been +/// refilled on the light failure. +constexpr int64_t CALL_LIGHTFAIL_EXECUTION_GAS = 30'321; } // namespace TEST_F(state_transition, eip8037_call_value_lightfail_new_account_charge_refilled) { + // A value-CALL charges NEW_ACCOUNT for an absent target before the sender-balance check. + // The light failure creates no account, so the charge is refilled and the net state-gas is + // zero — matching the baseline below, which differs only in the target existing. rev = EVMC_AMSTERDAM; tx.to = To; - static constexpr auto TARGET = 0xbeef_address; // intentionally absent from `pre` + constexpr auto TARGET = 0xbeef_address; // Absent from `pre`. - // To has balance 0, so `CALL value=1` light-fails the sender-balance check — - // but only AFTER NEW_ACCOUNT_STATE_GAS is charged for the absent TARGET. - pre[To] = {.code = call(TARGET).value(1).gas(0xffff) + OP_STOP}; + pre[To] = {.code = call(TARGET).value(1).gas(0xffff) + OP_STOP}; // To cannot pay the value. - expect.status = EVMC_SUCCESS; // To STOPs after the failed CALL (light failure) - expect.post[To] = {}; // To survives - expect.post[TARGET].exists = false; // no account was created - expect.gas_used = CALL_LIGHTFAIL_REGULAR_GAS; - expect.state_gas = 0; // the NEW_ACCOUNT charge for the absent TARGET is refilled + expect.status = EVMC_SUCCESS; // To STOPs after the light failure. + expect.post[To].exists = true; + expect.post[TARGET].exists = false; + expect.gas_used = CALL_LIGHTFAIL_EXECUTION_GAS; + expect.state_gas = 0; } TEST_F(state_transition, eip8037_call_value_lightfail_existing_account_baseline) { + // The baseline for the case above: an existing target is never charged, so both the execution + // gas and the state-gas must come out identical. rev = EVMC_AMSTERDAM; tx.to = To; - static constexpr auto TARGET = 0xbeef_address; + constexpr auto TARGET = 0xbeef_address; pre[To] = {.code = call(TARGET).value(1).gas(0xffff) + OP_STOP}; - pre[TARGET] = {.nonce = 1, .code = bytecode{OP_STOP}}; // TARGET exists → NO new-account charge + pre[TARGET] = {.nonce = 1, .code = bytecode{OP_STOP}}; expect.status = EVMC_SUCCESS; - expect.post[To] = {}; - expect.post[TARGET] = {.nonce = 1}; // unchanged by the light-failed call - expect.gas_used = CALL_LIGHTFAIL_REGULAR_GAS; // same regular gas as the new-account case - expect.state_gas = 0; // TARGET exists -> no new-account state-gas charge + expect.post[To].exists = true; + expect.post[TARGET] = {.nonce = 1}; + expect.gas_used = CALL_LIGHTFAIL_EXECUTION_GAS; + expect.state_gas = 0; } TEST_F(state_transition, eip8037_value_to_zero_balance_precompile_pays_new_account) { - // Funding a zero-balance precompile at depth 0 materializes a state account, so it pays - // NEW_ACCOUNT_STATE_GAS (EIP-161). The reservoir is empty for a below-cap gas limit, so the - // whole charge spills into regular gas and the precompile must run on the post-charge gas. + // Funding a zero-balance precompile materializes a state account, so it pays NEW_ACCOUNT + // (EIP-161). The reservoir is empty below the cap, so the charge spills into execution gas + // and the precompile runs on what is left. rev = EVMC_AMSTERDAM; - tx.to = 0x04_address; // identity, intentionally absent from `pre` + tx.to = 0x04_address; // Identity, absent from `pre`. tx.value = 1; - static constexpr int64_t IDENTITY_BASE_COST = 15; + constexpr int64_t IDENTITY_BASE_COST = 15; expect.status = EVMC_SUCCESS; expect.post[*tx.to].balance = 1; - expect.gas_used = 21'000 + IDENTITY_BASE_COST + evmone::NEW_ACCOUNT_STATE_GAS; - expect.state_gas = evmone::NEW_ACCOUNT_STATE_GAS; + expect.gas_used = 21'000 + IDENTITY_BASE_COST + NEW_ACCOUNT_STATE_GAS; + expect.state_gas = NEW_ACCOUNT_STATE_GAS; } TEST_F(state_transition, eip8037_sstore_slot_allocated_and_cleared_in_one_tx) { - // Allocating a storage slot and clearing it in the same transaction (0 -> 1 -> 0) refills - // the STORAGE_SET_STATE_GAS charge, leaving the net state gas at zero. + // Allocating a slot and clearing it in the same transaction (0 -> 1 -> 0) refills the + // allocation charge, leaving the net state-gas at zero. rev = EVMC_AMSTERDAM; tx.to = To; pre[To] = {.code = sstore(1, 1) + sstore(1, 0)}; - // Pre-refund: 21000 intrinsic + 12 (four PUSHes) + 5000 (cold slot allocation) - // + 100 (warm clear) = 26112. The clear refunds set - warm_access = 2800. - expect.gas_used = 26112 - 2800; + // Intrinsic, four PUSHes, the cold allocation, the warm clear, less the clear's refund. + expect.gas_used = 21'000 + 12 + 5000 + 100 - 2800; expect.gas_refund = 2800; expect.state_gas = 0; expect.post[To].exists = true; @@ -127,18 +111,18 @@ TEST_F(state_transition, eip8037_sstore_slot_allocated_and_cleared_in_one_tx) TEST_F(state_transition, eip8037_sstore_slot_cleared_in_a_child_frame) { - // A slot allocated in one frame and cleared in a deeper one refills more state gas than the + // A slot allocated in one frame and cleared in a deeper one refills more state-gas than the // child was given, so the child returns a bigger reservoir than it received. The credit must // reach the `gas_left` that funded the spilled allocation charge. rev = EVMC_AMSTERDAM; tx.to = To; - static constexpr auto CLEARER = 0xdead_address; + constexpr auto CLEARER = 0xdead_address; pre[CLEARER] = {.code = sstore(1, 0)}; pre[To] = {.code = sstore(1, 1) + delegatecall(CLEARER).gas(0xffff) + OP_STOP}; - // Pre-refund: 21000 intrinsic + 30 (ten PUSHes) + 5000 (cold slot allocation) - // + 2600 (cold DELEGATECALL) + 100 (warm clear) = 28730. The clear refunds 2800. - expect.gas_used = 28730 - 2800; + // Intrinsic, ten PUSHes, the cold allocation, the cold DELEGATECALL, the warm clear, + // less the clear's refund. + expect.gas_used = 21'000 + 30 + 5000 + 2600 + 100 - 2800; expect.gas_refund = 2800; expect.state_gas = 0; expect.post[To].exists = true; @@ -150,58 +134,64 @@ namespace /// The code deposit of a maximum-size contract, split into its two components (EIP-8037). constexpr int64_t DEPOSIT_CODE_SIZE = MAX_CODE_SIZE_AMSTERDAM; constexpr auto DEPOSIT_CODE_WORDS = DEPOSIT_CODE_SIZE / 32; -constexpr auto DEPOSIT_REGULAR = 6 * DEPOSIT_CODE_WORDS; +constexpr auto DEPOSIT_EXECUTION = 6 * DEPOSIT_CODE_WORDS; constexpr auto DEPOSIT_STATE = DEPOSIT_CODE_SIZE * COST_PER_STATE_BYTE; -/// Gas cap leaving the initcode frame in the middle of the window where the deposit's state -/// component is affordable and its regular component is not: enough for CREATE and the memory the -/// returned code needs, plus half the regular component. The CREATE price is the only term a -/// reprice has moved, so it is taken from the cost table rather than pinned. +/// Gas limit whose excess over the cap covers the deposit's state component outright. +constexpr auto DEPOSIT_TX_GAS = state::MAX_TX_GAS_LIMIT + DEPOSIT_STATE + 1'000'000; + +/// Cap leaving the initcode frame mid-window: enough for CREATE and the memory the returned code +/// needs, plus half the execution component. The CREATE price is the only term a reprice has +/// moved, so it comes from the cost table rather than being pinned. constexpr auto DEPOSIT_MEMORY = 3 * DEPOSIT_CODE_WORDS + DEPOSIT_CODE_WORDS * DEPOSIT_CODE_WORDS / 512; constexpr auto DEPOSIT_GAS_CAP = - instr::gas_costs[EVMC_AMSTERDAM][OP_CREATE] + DEPOSIT_MEMORY + DEPOSIT_REGULAR / 2; + instr::gas_costs[EVMC_AMSTERDAM][OP_CREATE] + DEPOSIT_MEMORY + DEPOSIT_EXECUTION / 2; + +constexpr auto DEPOSIT_CREATOR = 0xbbbb_address; + +/// Code deploying DEPOSIT_CODE_SIZE zero bytes through a nested CREATE. +bytecode deposit_creator_code() +{ + const auto initcode = ret(0, DEPOSIT_CODE_SIZE); + return mstore(0, push(initcode)) + create().input(32 - initcode.size(), initcode.size()); +} } // namespace -TEST_F(state_transition, eip8037_code_deposit_out_of_regular_gas_with_a_full_reservoir) +TEST_F(state_transition, eip8037_code_deposit_out_of_execution_gas_with_a_full_reservoir) { - // The code deposit splits into a regular and a state component. A reservoir that covers the - // state component must not let the deposit through when the regular component is unaffordable. - static constexpr auto CREATOR = 0xbbbb_address; + // The code deposit splits into an execution and a state component. A reservoir covering the + // state component must not let the deposit through when the execution component is + // unaffordable. rev = EVMC_AMSTERDAM; - tx.gas_limit = state::MAX_TX_GAS_LIMIT + DEPOSIT_STATE + 1'000'000; + tx.gas_limit = DEPOSIT_TX_GAS; block.gas_limit = tx.gas_limit; - pre[Sender].balance = intx::uint256{tx.gas_limit} * tx.max_gas_price + 1; tx.to = To; + pre[Sender].balance = intx::uint256{tx.gas_limit} * tx.max_gas_price + 1; + pre[DEPOSIT_CREATOR] = {.code = deposit_creator_code()}; + pre[To] = {.code = call(DEPOSIT_CREATOR).gas(DEPOSIT_GAS_CAP) + OP_STOP}; - const auto initcode = ret(0, DEPOSIT_CODE_SIZE); - pre[CREATOR] = { - .code = mstore(0, push(initcode)) + create().input(32 - initcode.size(), initcode.size())}; - pre[To] = {.code = call(CREATOR).gas(DEPOSIT_GAS_CAP) + OP_STOP}; - + expect.state_gas = 0; // The refused deposit charges none, and the CREATE's is refilled. expect.post[To].exists = true; - expect.post[CREATOR].nonce = pre[CREATOR].nonce + 1; // bumped by the CREATE - expect.post[compute_create_address(CREATOR, pre[CREATOR].nonce)].exists = false; + expect.post[DEPOSIT_CREATOR].nonce = pre[DEPOSIT_CREATOR].nonce + 1; // Bumped by the CREATE. + expect.post[compute_create_address(DEPOSIT_CREATOR, pre[DEPOSIT_CREATOR].nonce)].exists = false; } -TEST_F(state_transition, eip8037_code_deposit_regular_gas_boundary) +TEST_F(state_transition, eip8037_code_deposit_execution_gas_boundary) { - // The same deposit one regular component richer succeeds, pinning the case above to the - // regular gas rather than to anything else the CREATE pays for. - static constexpr auto CREATOR = 0xbbbb_address; + // The same deposit one execution component richer succeeds, pinning the case above to the + // execution gas rather than to anything else the CREATE pays for. rev = EVMC_AMSTERDAM; - tx.gas_limit = state::MAX_TX_GAS_LIMIT + DEPOSIT_STATE + 1'000'000; + tx.gas_limit = DEPOSIT_TX_GAS; block.gas_limit = tx.gas_limit; - pre[Sender].balance = intx::uint256{tx.gas_limit} * tx.max_gas_price + 1; tx.to = To; + pre[Sender].balance = intx::uint256{tx.gas_limit} * tx.max_gas_price + 1; + pre[DEPOSIT_CREATOR] = {.code = deposit_creator_code()}; + pre[To] = {.code = call(DEPOSIT_CREATOR).gas(DEPOSIT_GAS_CAP + DEPOSIT_EXECUTION) + OP_STOP}; - const auto initcode = ret(0, DEPOSIT_CODE_SIZE); - pre[CREATOR] = { - .code = mstore(0, push(initcode)) + create().input(32 - initcode.size(), initcode.size())}; - pre[To] = {.code = call(CREATOR).gas(DEPOSIT_GAS_CAP + DEPOSIT_REGULAR) + OP_STOP}; - + expect.state_gas = DEPOSIT_STATE + NEW_ACCOUNT_STATE_GAS; // Deposit plus the new account. expect.post[To].exists = true; - expect.post[CREATOR].nonce = pre[CREATOR].nonce + 1; - expect.post[compute_create_address(CREATOR, pre[CREATOR].nonce)].code = + expect.post[DEPOSIT_CREATOR].nonce = pre[DEPOSIT_CREATOR].nonce + 1; + expect.post[compute_create_address(DEPOSIT_CREATOR, pre[DEPOSIT_CREATOR].nonce)].code = bytes(DEPOSIT_CODE_SIZE, 0x00); } From 7cbf05bb03ac320dd0c068fadff6758d42b6be35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Thu, 10 Sep 2026 21:15:44 +0200 Subject: [PATCH 17/20] instructions --- lib/evmone/instructions.hpp | 1 - lib/evmone/instructions_storage.cpp | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/evmone/instructions.hpp b/lib/evmone/instructions.hpp index e2bfefe146..2c38e5a9dc 100644 --- a/lib/evmone/instructions.hpp +++ b/lib/evmone/instructions.hpp @@ -1084,7 +1084,6 @@ inline TermResult selfdestruct(StackTop stack, int64_t gas_left, ExecutionState& { if (state.rev >= EVMC_AMSTERDAM) { - // The new account leaf is paid in state gas (EIP-8037). if (!state.state_gas.charge(gas_left, NEW_ACCOUNT_STATE_GAS)) return {EVMC_OUT_OF_GAS, gas_left}; } diff --git a/lib/evmone/instructions_storage.cpp b/lib/evmone/instructions_storage.cpp index 63353ae09d..bd7369e3ba 100644 --- a/lib/evmone/instructions_storage.cpp +++ b/lib/evmone/instructions_storage.cpp @@ -42,7 +42,8 @@ constexpr auto storage_cost_spec = []() noexcept { tbl[EVMC_PRAGUE] = tbl[EVMC_LONDON]; tbl[EVMC_OSAKA] = tbl[EVMC_LONDON]; tbl[EVMC_AMSTERDAM] = tbl[EVMC_LONDON]; - tbl[EVMC_AMSTERDAM].set = 2900; // EIP-8037: regular component only (was 20000). + tbl[EVMC_AMSTERDAM].set = 2900; // execution-gas only (EIP-8037). // REVIEW: is this constant + // a diff of some other constants? tbl[EVMC_EXPERIMENTAL] = tbl[EVMC_AMSTERDAM]; return tbl; }(); From 44e6b926a3350c57b4fb20a37e767bacde4c62b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Fri, 11 Sep 2026 13:07:41 +0200 Subject: [PATCH 18/20] evm: Derive the Amsterdam SSTORE set cost from reset EIP-8037 moves the new-slot state component to state gas and leaves the ordinary write cost, so the set and reset execution costs coincide. The table already holds that value as `reset`; assign it instead of repeating the 2900 literal. Claude-Session: https://claude.ai/code/session_01JsL9Rj8b2XMfvY8kMk2A5p --- lib/evmone/instructions_storage.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/evmone/instructions_storage.cpp b/lib/evmone/instructions_storage.cpp index bd7369e3ba..4df955ebb2 100644 --- a/lib/evmone/instructions_storage.cpp +++ b/lib/evmone/instructions_storage.cpp @@ -42,8 +42,9 @@ constexpr auto storage_cost_spec = []() noexcept { tbl[EVMC_PRAGUE] = tbl[EVMC_LONDON]; tbl[EVMC_OSAKA] = tbl[EVMC_LONDON]; tbl[EVMC_AMSTERDAM] = tbl[EVMC_LONDON]; - tbl[EVMC_AMSTERDAM].set = 2900; // execution-gas only (EIP-8037). // REVIEW: is this constant - // a diff of some other constants? + // A new slot's execution gas drops to the cost of updating one; the rest is paid in + // state gas (EIP-8037). + tbl[EVMC_AMSTERDAM].set = tbl[EVMC_AMSTERDAM].reset; tbl[EVMC_EXPERIMENTAL] = tbl[EVMC_AMSTERDAM]; return tbl; }(); From 498664d30513e9bb5943b51c62ff0d92eed4116e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Fri, 11 Sep 2026 13:09:33 +0200 Subject: [PATCH 19/20] Use the spec's gas-dimension terminology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EIP-8037 names the two dimensions execution-gas and state-gas and never says "regular". The spec's word was taken by TransactionProperties::execution_gas_limit, which holds `gas_limit - intrinsic` — the spec calls that `evm_gas`. Rename the field to evm_gas and its siblings to intrinsic_execution_gas and block_execution_gas, then use "execution gas" for the dimension throughout. Claude-Session: https://claude.ai/code/session_01JsL9Rj8b2XMfvY8kMk2A5p --- lib/evmone/instructions_calls.cpp | 6 ++-- lib/evmone/instructions_storage.cpp | 8 ++--- lib/evmone/state_gas.hpp | 6 +--- test/state/host.cpp | 8 ++--- test/state/state.cpp | 55 ++++++++++++++--------------- test/state/transaction.hpp | 20 ++++++----- test/unittests/state_tx_test.cpp | 10 +++--- test/utils/block_transition.cpp | 10 +++--- 8 files changed, 60 insertions(+), 63 deletions(-) diff --git a/lib/evmone/instructions_calls.cpp b/lib/evmone/instructions_calls.cpp index e370ca09d5..ff51fc5c50 100644 --- a/lib/evmone/instructions_calls.cpp +++ b/lib/evmone/instructions_calls.cpp @@ -158,9 +158,9 @@ Result call_impl(StackTop stack, int64_t gas_left, ExecutionState& state) noexce { if (state.rev >= EVMC_AMSTERDAM) { - // The state charge comes after every regular cost of this instruction is - // committed (reservoir model), so a regular OOG cannot leave committed - // state growth behind. + // The state charge comes after every execution-gas cost of this instruction + // is committed (reservoir model), so an execution-gas OOG cannot leave + // committed state growth behind. new_account_state_gas = NEW_ACCOUNT_STATE_GAS; if (!state.state_gas.charge(gas_left, new_account_state_gas)) return {EVMC_OUT_OF_GAS, gas_left}; diff --git a/lib/evmone/instructions_storage.cpp b/lib/evmone/instructions_storage.cpp index 4df955ebb2..01bbbe7543 100644 --- a/lib/evmone/instructions_storage.cpp +++ b/lib/evmone/instructions_storage.cpp @@ -151,14 +151,14 @@ Result sstore(StackTop stack, int64_t gas_left, ExecutionState& state) noexcept const auto [gas_cost_warm, gas_refund, state_gas] = sstore_costs[state.rev][status]; const auto gas_cost = gas_cost_warm + gas_cost_cold; - // A refill (0 -> Y -> 0) is applied BEFORE the regular charge, as in EELS, so gas returned - // to gas_left from a prior spill can fund that charge (EIP-8037). + // A refill (0 -> Y -> 0) is applied BEFORE the execution-gas charge, as in EELS, so gas + // returned to gas_left from a prior spill can fund that charge (EIP-8037). // FIXME: .refill(c) looks like .charge(-c). Can we combine these? if (state_gas < 0) state.state_gas.refill(gas_left, -state_gas); - // Charge regular gas FIRST, then state gas: this order prevents a state-gas spill from - // counting committed state growth behind a subsequent regular OOG (EIP-8037). + // Charge execution gas FIRST, then state gas: this order prevents a state-gas spill from + // counting committed state growth behind a subsequent execution-gas OOG (EIP-8037). if ((gas_left -= gas_cost) < 0) return {EVMC_OUT_OF_GAS, gas_left}; diff --git a/lib/evmone/state_gas.hpp b/lib/evmone/state_gas.hpp index 48352fd077..fad456444c 100644 --- a/lib/evmone/state_gas.hpp +++ b/lib/evmone/state_gas.hpp @@ -9,11 +9,7 @@ namespace evmone { -/// A frame's state-gas as a (left, spilled) pair, independent from the regular gas (EIP-8037). -/// -/// REVIEW: "regular gas" is our term; EIP-8037 says execution gas throughout and never uses -/// "regular". The spec's word is already taken here: TransactionProperties::execution_gas_limit -/// is gas_limit - intrinsic, i.e. the regular budget and the reservoir together. +/// A frame's state-gas as a (left, spilled) pair, independent from the execution gas (EIP-8037). struct StateGas { /// Remaining state-gas reservoir. diff --git a/test/state/host.cpp b/test/state/host.cpp index f5499be23e..31def58c64 100644 --- a/test/state/host.cpp +++ b/test/state/host.cpp @@ -280,10 +280,10 @@ evmc::Result Host::create(const evmc_message& msg) noexcept state_gas.spilled += result.state_gas_spilled; if (m_rev >= EVMC_AMSTERDAM) { - // The code deposit splits into a regular and a state component (EIP-8037). - const auto regular_cost = 6 * ((std::ssize(code) + 31) / 32); + // The code deposit splits into an execution-gas and a state-gas component (EIP-8037). + const auto execution_cost = 6 * ((std::ssize(code) + 31) / 32); const auto state_cost = std::ssize(code) * COST_PER_STATE_BYTE; - gas_left -= regular_cost; + gas_left -= execution_cost; // FIXME: Can .charge() handle negative gas_left? Is this covered by tests? if (gas_left < 0 || !state_gas.charge(gas_left, state_cost)) return evmc::Result{EVMC_FAILURE}; @@ -318,7 +318,7 @@ evmc::Result Host::execute_message(const evmc_message& msg) noexcept if (msg.kind == EVMC_CREATE || msg.kind == EVMC_CREATE2) return create(msg); - // The frame's regular gas: the depth-0 state charge below can spill into it, so it is not + // The frame's execution gas: the depth-0 state charge below can spill into it, so it is not // `msg.gas` for the rest of the function. auto gas = msg.gas; diff --git a/test/state/state.cpp b/test/state/state.cpp index 913bf89b39..ddede557d8 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -195,7 +195,7 @@ int64_t process_authorization_list( return delegation_refund; } -evmc_message build_message(const Transaction& tx, int64_t execution_gas_limit) noexcept +evmc_message build_message(const Transaction& tx, int64_t evm_gas) noexcept { const auto recipient = tx.to.has_value() ? *tx.to : compute_create_address(tx.sender, tx.nonce); @@ -203,7 +203,7 @@ evmc_message build_message(const Transaction& tx, int64_t execution_gas_limit) n .kind = tx.to.has_value() ? EVMC_CALL : EVMC_CREATE, .flags = 0, .depth = 0, - .gas = execution_gas_limit, + .gas = evm_gas, .recipient = recipient, .sender = tx.sender, .input_data = tx.data.data(), @@ -500,7 +500,7 @@ std::variant validate_transaction( assert(tx.max_priority_gas_price <= tx.max_gas_price); // The per-tx gas-limit cap is lifted again by EIP-8037; the reservoir model instead caps the - // regular-gas intrinsic and the per-dimension block inclusion below. + // execution-gas intrinsic and the per-dimension block inclusion below. if (rev >= EVMC_OSAKA && rev < EVMC_AMSTERDAM && tx.gas_limit > MAX_TX_GAS_LIMIT) return make_error_code(GAS_LIMIT_EXCEEDS_MAXIMUM); @@ -565,11 +565,11 @@ std::variant validate_transaction( const auto [intrinsic_cost, min_cost] = compute_tx_intrinsic_cost(rev, tx); - // max(intrinsic_regular_gas, calldata_floor_gas_cost) <= TX_MAX_GAS_LIMIT + // max(intrinsic_execution_gas, calldata_floor_gas_cost) <= TX_MAX_GAS_LIMIT // (EIP-8037 §"Transaction validation" condition 1). // Amsterdam lifts the per-tx cap on tx.gas_limit (above) but keeps this - // cap on the regular-gas intrinsic so that the reservoir-model invariant - // regular_gas_budget = TX_MAX_GAS_LIMIT - intrinsic_regular_gas + // cap on the execution-gas intrinsic so that the reservoir-model invariant + // execution_gas_budget = TX_MAX_GAS_LIMIT - intrinsic_execution_gas // stays non-negative. EELS validate_transaction bounds `intrinsic.execution` and // `intrinsic.calldata_floor` against TX_MAX_GAS_LIMIT separately; `max()` of the two is // the same condition. @@ -582,8 +582,8 @@ std::variant validate_transaction( if (tx.gas_limit < std::max(intrinsic_cost, min_cost)) return make_error_code(INTRINSIC_GAS_TOO_LOW); - const auto execution_gas_limit = tx.gas_limit - intrinsic_cost; - return TransactionProperties{execution_gas_limit, intrinsic_cost, min_cost}; + const auto evm_gas = tx.gas_limit - intrinsic_cost; + return TransactionProperties{evm_gas, intrinsic_cost, min_cost}; } StateDiff finalize(const StateView& state_view, evmc_revision rev, const address& coinbase, @@ -651,7 +651,7 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc Host host{rev, vm, state, block, block_hashes, tx}; - auto message = build_message(tx, tx_props.execution_gas_limit); + auto message = build_message(tx, tx_props.evm_gas); sender_acc.access_status = EVMC_ACCESS_WARM; // Sender is always warm. host.access_account(message.recipient); // Recipient (incl. create address) is always warm. @@ -679,16 +679,16 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc } } - // Split execution gas into a regular budget and a state-gas reservoir (EIP-8037): - // regular = min(MAX_TX_GAS_LIMIT - intrinsic_regular, exec_gas), reservoir = the rest. + // Split the EVM gas into an execution-gas budget and a state-gas reservoir (EIP-8037): + // execution = min(MAX_TX_GAS_LIMIT - intrinsic_execution, evm_gas), reservoir = the rest. if (rev >= EVMC_AMSTERDAM) { - const auto exec_gas = tx_props.execution_gas_limit; - const auto regular_cap = std::max( - int64_t{0}, static_cast(MAX_TX_GAS_LIMIT) - tx_props.intrinsic_regular_gas); - const auto regular_exec = std::min(exec_gas, regular_cap); - message.gas = regular_exec; - message.state_gas = exec_gas - regular_exec; + const auto evm_gas = tx_props.evm_gas; + const auto execution_cap = std::max( + int64_t{0}, static_cast(MAX_TX_GAS_LIMIT) - tx_props.intrinsic_execution_gas); + const auto execution_gas = std::min(evm_gas, execution_cap); + message.gas = execution_gas; + message.state_gas = evm_gas - execution_gas; } const auto result = host.call(message); @@ -697,11 +697,10 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc // handed: initial - left + spilled. Zero on a top-level failure, the frame having refilled // itself. Never negative: a refill needs a matching allocation, and the top frame has no // ancestor to have made one (EIP-8037). - const auto exec_state_gas = - message.state_gas - result.state_gas_left + result.state_gas_spilled; - assert(exec_state_gas >= 0); + const auto tx_state_gas = message.state_gas - result.state_gas_left + result.state_gas_spilled; + assert(tx_state_gas >= 0); - // Gas consumed = gas_limit - regular_unspent - reservoir_unspent, pre-refund and pre-floor. + // Gas consumed = gas_limit - execution_unspent - reservoir_unspent, pre-refund and pre-floor. // Kept immutable: the receipt's gas_refund is derived from it (EIP-8037). const auto gas_used_b4_refund = tx.gas_limit - result.gas_left - result.state_gas_left; @@ -714,15 +713,15 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc const auto sender_gas_cost = std::max(gas_used_b4_refund - refund, tx_props.min_gas_cost); // The block's 2D gas components (EIP-7778): pre-Amsterdam the block tracks a single - // dimension, so all of the gas the sender paid for is regular. - auto block_regular_gas = sender_gas_cost; + // dimension, so all of the gas the sender paid for is execution gas. + auto block_execution_gas = sender_gas_cost; int64_t block_state_gas = 0; if (rev >= EVMC_AMSTERDAM) { - // The intrinsic state gas is zero, so whatever `exec_state_gas` does not cover is the - // regular component, floored so state-gas spending cannot discount it (EIP-7778). - block_state_gas = exec_state_gas; - block_regular_gas = std::max(gas_used_b4_refund - exec_state_gas, tx_props.min_gas_cost); + // The intrinsic state gas is zero, so whatever `tx_state_gas` does not cover is the + // execution-gas component, floored so state-gas spending cannot discount it (EIP-7778). + block_state_gas = tx_state_gas; + block_execution_gas = std::max(gas_used_b4_refund - tx_state_gas, tx_props.min_gas_cost); } sender_acc.balance += tx_max_cost - sender_gas_cost * effective_gas_price; state.touch(block.coinbase).balance += sender_gas_cost * priority_gas_price; @@ -734,7 +733,7 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc // Receipt gas_used = what the sender paid for: post-refund, floored at the // EIP-7623 calldata floor. receipt.gas_used = sender_gas_cost; - receipt.block_regular_gas = block_regular_gas; + receipt.block_execution_gas = block_execution_gas; receipt.block_state_gas = block_state_gas; // Per-tx refund applied to receipt.gas_used: the floored pre-refund gas minus what the // sender paid, so gas_used + gas_refund is the pre-refund gas the block accumulates diff --git a/test/state/transaction.hpp b/test/state/transaction.hpp index da7afc8206..397c54ecab 100644 --- a/test/state/transaction.hpp +++ b/test/state/transaction.hpp @@ -101,12 +101,13 @@ struct Transaction /// Transaction properties computed during the validation needed for the execution. struct TransactionProperties { - /// The amount of gas provided to the EVM for the transaction execution. - int64_t execution_gas_limit = 0; + /// The amount of gas provided to the EVM for the transaction execution, the spec's + /// `evm_gas`. Under EIP-8037 it is split into the execution gas and the state-gas reservoir. + int64_t evm_gas = 0; - /// The regular portion of the intrinsic cost (EIP-8037 keeps the state-dependent charges out - /// of the intrinsic; they are charged at the top frame). - int64_t intrinsic_regular_gas = 0; + /// The execution-gas portion of the intrinsic cost (EIP-8037 keeps the state-dependent + /// charges out of the intrinsic; they are charged at the top frame). + int64_t intrinsic_execution_gas = 0; /// The minimal amount of gas the transaction must use. int64_t min_gas_cost = 0; @@ -144,10 +145,11 @@ struct TransactionReceipt int64_t cumulative_gas_used = 0; /// 2D per-tx block-gas components. The runner aggregates as - /// `block.gas_used = max(sum_regular, sum_state)` (EIP-7778). Pre-Amsterdam the block has a - /// single dimension: the regular component is `gas_used` and the state one is 0 (EIP-8037). - int64_t block_regular_gas = 0; ///< Regular gas component. - int64_t block_state_gas = 0; ///< State gas component. + /// `block.gas_used = max(sum_execution, sum_state)` (EIP-7778). Pre-Amsterdam the block has + /// a single dimension: the execution-gas component is `gas_used` and the state one is 0 + /// (EIP-8037). + int64_t block_execution_gas = 0; ///< Execution gas component. + int64_t block_state_gas = 0; ///< State gas component. std::vector logs; BloomFilter logs_bloom_filter; diff --git a/test/unittests/state_tx_test.cpp b/test/unittests/state_tx_test.cpp index 72e0c1630a..0049424888 100644 --- a/test/unittests/state_tx_test.cpp +++ b/test/unittests/state_tx_test.cpp @@ -131,7 +131,7 @@ TEST(state_tx, validate_blob_tx) EXPECT_EQ(std::get(validate_transaction(state, block, tx, EVMC_CANCUN, block.gas_limit, 0, blob_gas_limit)) - .execution_gas_limit, + .evm_gas, 39000); tx.blob_hashes[0] = 0x0200000000000000000000000000000000000000000000000000000000000001_bytes32; @@ -191,10 +191,10 @@ TEST(state_tx, validate_tx_data_cost) return tx.gas_limit - (21000 + 3 * nonzero_cost + 2 * zero_cost); }; - EXPECT_EQ(get_props(EVMC_PETERSBURG).execution_gas_limit, from_data_cost(68, 4)); - EXPECT_EQ(get_props(EVMC_ISTANBUL).execution_gas_limit, from_data_cost(16, 4)); - EXPECT_EQ(get_props(EVMC_CANCUN).execution_gas_limit, from_data_cost(16, 4)); - EXPECT_EQ(get_props(EVMC_PRAGUE).execution_gas_limit, from_data_cost(16, 4)); + EXPECT_EQ(get_props(EVMC_PETERSBURG).evm_gas, from_data_cost(68, 4)); + EXPECT_EQ(get_props(EVMC_ISTANBUL).evm_gas, from_data_cost(16, 4)); + EXPECT_EQ(get_props(EVMC_CANCUN).evm_gas, from_data_cost(16, 4)); + EXPECT_EQ(get_props(EVMC_PRAGUE).evm_gas, from_data_cost(16, 4)); EXPECT_EQ(get_props(EVMC_PETERSBURG).min_gas_cost, 0); EXPECT_EQ(get_props(EVMC_ISTANBUL).min_gas_cost, 0); diff --git a/test/utils/block_transition.cpp b/test/utils/block_transition.cpp index dbfea86324..6b05663e9e 100644 --- a/test/utils/block_transition.cpp +++ b/test/utils/block_transition.cpp @@ -52,7 +52,7 @@ TransitionResult apply_block(const TestState& state, evmc::VM& vm, const state:: int64_t block_state_gas_left = block.gas_limit; int64_t cumulative_gas_used = 0; // The two dimensions of the block-level max(sum_regular, sum_state) formula (EIP-8037). - int64_t sum_regular_gas = 0; + int64_t sum_execution_gas = 0; int64_t sum_state_gas = 0; auto blob_gas_left = blob_gas_limit; @@ -81,11 +81,11 @@ TransitionResult apply_block(const TestState& state, evmc::VM& vm, const state:: if (rev < EVMC_BYZANTIUM) receipt.post_state = state::mpt_hash(block_state); - // Accumulate the 2D components for the block-level max(sum_regular, sum_state) + // Accumulate the 2D components for the block-level max(sum_execution, sum_state) // formula, which pre-Amsterdam is the single gas dimension (EIP-8037). - sum_regular_gas += receipt.block_regular_gas; + sum_execution_gas += receipt.block_execution_gas; sum_state_gas += receipt.block_state_gas; - block_gas_left -= receipt.block_regular_gas; + block_gas_left -= receipt.block_execution_gas; block_state_gas_left -= receipt.block_state_gas; blob_gas_left -= static_cast(tx.blob_gas_used()); receipts.emplace_back(std::move(receipt)); @@ -119,7 +119,7 @@ TransitionResult apply_block(const TestState& state, evmc::VM& vm, const state:: const auto bloom = compute_bloom_filter(receipts); // The block's 2D gas formula (EIP-8037). - const auto block_gas_used = std::max(sum_regular_gas, sum_state_gas); + const auto block_gas_used = std::max(sum_execution_gas, sum_state_gas); return {std::move(receipts), std::move(rejected_txs), std::move(requests), requests_error, block_gas_used, bloom, blob_gas_left, std::move(block_state)}; } From fe3e5526a730be867633048118d6f3a7033d81b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Fri, 11 Sep 2026 13:11:23 +0200 Subject: [PATCH 20/20] evmc: Group the state gas fields with the other gas ones state_gas_left and state_gas_spilled were appended after the release function pointer, splitting evmc_result's gas fields across the struct. Move them next to gas_refund. Precompile results were built with a positional initializer, which the reorder would have silently repurposed, so name the fields there. Claude-Session: https://claude.ai/code/session_01JsL9Rj8b2XMfvY8kMk2A5p --- evmc/include/evmc/evmc.h | 21 ++++++++++----------- test/state/precompiles.cpp | 10 +++++++--- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/evmc/include/evmc/evmc.h b/evmc/include/evmc/evmc.h index e52e47e407..3b16fbc2be 100644 --- a/evmc/include/evmc/evmc.h +++ b/evmc/include/evmc/evmc.h @@ -421,6 +421,16 @@ struct evmc_result */ int64_t gas_refund; + /** + * The amount of state gas left after execution (EIP-8037). + */ + int64_t state_gas_left; + + /** + * The portion of consumed state gas taken from gas_left (EIP-8037). + */ + int64_t state_gas_spilled; + /** * The reference to output data. * @@ -462,17 +472,6 @@ struct evmc_result * function to the result itself allows VM composition. */ evmc_release_result_fn release; - - /** - * The amount of state gas left after execution (EIP-8037). - */ - // FIXME: Move after gas_refund. - int64_t state_gas_left; - - /** - * The portion of consumed state gas taken from gas_left (EIP-8037). - */ - int64_t state_gas_spilled; }; diff --git a/test/state/precompiles.cpp b/test/state/precompiles.cpp index 3f5cc5ccb7..1649da6c33 100644 --- a/test/state/precompiles.cpp +++ b/test/state/precompiles.cpp @@ -871,9 +871,13 @@ evmc::Result call_precompile(evmc_revision rev, const evmc_message& msg) noexcep const auto output_data = new (std::nothrow) uint8_t[max_output_size]; // TODO: handle nullptr. const auto [status_code, output_size] = execute(msg.input_data, msg.input_size, output_data, max_output_size); - const evmc_result result{status_code, status_code == EVMC_SUCCESS ? gas_left : 0, 0, - output_data, output_size, - [](const evmc_result* res) noexcept { delete[] res->output_data; }}; + const evmc_result result{ + .status_code = status_code, + .gas_left = status_code == EVMC_SUCCESS ? gas_left : 0, + .output_data = output_data, + .output_size = output_size, + .release = [](const evmc_result* res) noexcept { delete[] res->output_data; }, + }; return evmc::Result{result}; } } // namespace evmone::state