From 8409b862144c87b745a1cebc0b2993fc1e0865b7 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/17] 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 | 70 ++++++++- 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 | 109 +++++++++++--- 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, 699 insertions(+), 77 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 4ec0e33b27..82b0ff7247 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,13 +189,20 @@ 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.rev < EVMC_OSAKA && state.msg->depth >= 1024) 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); stack.top() = result.status_code == EVMC_SUCCESS; @@ -188,6 +213,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 +279,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 +317,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 a959a852f1..1135bda86e 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 851a8c0a8c..412f034551 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); @@ -542,11 +561,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, @@ -642,31 +676,66 @@ 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. + 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 result = host.call(message); - const auto gas_used_b4_refund = tx.gas_limit - result.gas_left; + // 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); + // 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; + + // 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 = rev >= EVMC_LONDON ? gas_used_b4_refund / 5 : gas_used_b4_refund / 2; const auto refund = std::min(delegation_refund + result.gas_refund, refund_limit); - auto gas_used = gas_used_b4_refund - refund; - assert(gas_used > 0); - - // 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); - - // 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; - - sender_acc.balance += tx_max_cost - gas_used * effective_gas_price; - state.touch(block.coinbase).balance += gas_used * priority_gas_price; + 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{ .type = tx.type, .status = result.status_code, - .gas_used = gas_used, - .gas_refund = gas_refund, + .gas_used = sender_gas_cost, + .gas_refund = + std::max(gas_used_b4_refund, tx_props.min_gas_cost) - sender_gas_cost, + .regular_block_gas = regular_block_gas, + .state_block_gas = state_block_gas, .logs = host.take_logs(), .state_diff = state.build_diff(rev), }; 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 43700503a0..b38e7ce4f5 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); + } const auto& diff = receipt.state_diff; for (const auto& [addr, expected_acc] : expect.post) diff --git a/test/unittests/state_transition.hpp b/test/unittests/state_transition.hpp index 71e65ad919..a4e49e834d 100644 --- a/test/unittests/state_transition.hpp +++ b/test/unittests/state_transition.hpp @@ -75,6 +75,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 5b2edadb35a3ca47fffb0fb90b0d9cba664eebc9 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/17] 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 82b0ff7247..9cff774ede 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. @@ -215,7 +239,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}; @@ -327,7 +351,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 1135bda86e..73a1e2f04b 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 57d4c9cef938bc5acf29cbe314c7de27882836ea 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 03/17] Review round 2 --- evmc/include/evmc/evmc.h | 21 +- lib/evmone/execution_state.hpp | 5 +- lib/evmone/instructions.hpp | 1 - lib/evmone/instructions_calls.cpp | 6 +- lib/evmone/instructions_storage.cpp | 12 +- lib/evmone/state_gas.hpp | 2 +- test/state/account.hpp | 6 - test/state/host.cpp | 69 +++---- test/state/state.cpp | 77 ++++---- test/state/state.hpp | 5 +- test/state/system_contracts.cpp | 12 +- test/state/transaction.hpp | 20 +- test/unittests/state_transition.cpp | 6 +- test/unittests/state_transition.hpp | 2 +- .../state_transition_create_test.cpp | 6 +- ...tate_transition_eip8037_state_gas_test.cpp | 182 +++++++++++------- test/unittests/state_tx_test.cpp | 26 +-- test/utils/block_transition.cpp | 23 ++- test/utils/statetest_runner.cpp | 4 +- test/utils/test_state.cpp | 4 +- test/utils/test_state.hpp | 7 +- 21 files changed, 254 insertions(+), 242 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/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/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_calls.cpp b/lib/evmone/instructions_calls.cpp index 9cff774ede..8b88cb5995 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 63353ae09d..01bbbe7543 100644 --- a/lib/evmone/instructions_storage.cpp +++ b/lib/evmone/instructions_storage.cpp @@ -42,7 +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; // EIP-8037: regular component only (was 20000). + // 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; }(); @@ -149,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 f76f69b94f..fad456444c 100644 --- a/lib/evmone/state_gas.hpp +++ b/lib/evmone/state_gas.hpp @@ -9,7 +9,7 @@ namespace evmone { -/// A frame's state-gas as a (left, spilled) pair, independent from the regular gas (EIP-8037). +/// 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/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 73a1e2f04b..a56c9e2328 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 @@ -206,10 +213,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); } @@ -236,10 +240,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) { @@ -252,10 +254,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; @@ -280,27 +280,24 @@ 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}; } 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}; } } @@ -321,18 +318,15 @@ 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; - // 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) { @@ -407,12 +401,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()); } @@ -435,10 +426,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 412f034551..216d0a6cf4 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include using namespace intx; @@ -194,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); @@ -202,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(), @@ -437,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); @@ -499,14 +500,12 @@ 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); - // 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) @@ -518,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); } @@ -562,11 +561,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. @@ -579,8 +578,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, @@ -648,7 +647,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. @@ -676,30 +675,28 @@ 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 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); // 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); + // itself. Never negative: a refill needs a matching allocation, and the top frame has no + // ancestor to have made one (EIP-8037). + 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; @@ -712,17 +709,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 regular_block_gas = sender_gas_cost; - int64_t state_block_gas = 0; + // 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) { - // `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); + // 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,8 +729,8 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc .gas_used = sender_gas_cost, .gas_refund = std::max(gas_used_b4_refund, tx_props.min_gas_cost) - sender_gas_cost, - .regular_block_gas = regular_block_gas, - .state_block_gas = state_block_gas, + .block_execution_gas = block_execution_gas, + .block_state_gas = block_state_gas, .logs = host.take_logs(), .state_diff = state.build_diff(rev), }; diff --git a/test/state/state.hpp b/test/state/state.hpp index 50aa811dad..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 Pre-Amsterdam: ignored. Amsterdam+: remaining -/// block state-gas budget (EIP-8037). +/// @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/system_contracts.cpp b/test/state/system_contracts.cpp index 7b41d7dece..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(), - .state_gas = - (rev >= EVMC_AMSTERDAM) ? SYSTEM_MAX_SSTORES_PER_CALL * STORAGE_SET_STATE_GAS : 0, + .state_gas = 16 * STORAGE_SET_STATE_GAS, // Additional state-gas (EIP-8037). }; const Transaction empty_tx{}; diff --git a/test/state/transaction.hpp b/test/state/transaction.hpp index 524f545db6..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 regular_block_gas = 0; ///< Regular gas component. - int64_t state_block_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_transition.cpp b/test/unittests/state_transition.cpp index b38e7ce4f5..db2cfd80aa 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; @@ -104,7 +104,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); } const auto& diff = receipt.state_diff; diff --git a/test/unittests/state_transition.hpp b/test/unittests/state_transition.hpp index a4e49e834d..e2f02b69a9 100644 --- a/test/unittests/state_transition.hpp +++ b/test/unittests/state_transition.hpp @@ -75,7 +75,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_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. diff --git a/test/unittests/state_transition_eip8037_state_gas_test.cpp b/test/unittests/state_transition_eip8037_state_gas_test.cpp index 9b7c9cfc5f..7839fe1ec5 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; @@ -11,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 (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; +/// 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 = CallLightfailRegularGas; - 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[To] = {.code = call(TARGET).value(1).gas(0xffff) + OP_STOP}; + 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 = CallLightfailRegularGas; // 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 IdentityBaseCost = 15; + 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.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; @@ -126,20 +111,87 @@ 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; - pre[Clearer] = {.code = sstore(1, 0)}; - pre[To] = {.code = sstore(1, 1) + delegatecall(Clearer).gas(0xffff) + OP_STOP}; + 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; - expect.post[Clearer].exists = true; + 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_EXECUTION = 6 * DEPOSIT_CODE_WORDS; +constexpr auto DEPOSIT_STATE = DEPOSIT_CODE_SIZE * COST_PER_STATE_BYTE; + +/// 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_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_execution_gas_with_a_full_reservoir) +{ + // 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 = DEPOSIT_TX_GAS; + block.gas_limit = tx.gas_limit; + 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}; + + expect.state_gas = 0; // The refused deposit charges none, and the CREATE's is refilled. + expect.post[To].exists = true; + 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_execution_gas_boundary) +{ + // 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 = DEPOSIT_TX_GAS; + block.gas_limit = tx.gas_limit; + 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}; + + expect.state_gas = DEPOSIT_STATE + NEW_ACCOUNT_STATE_GAS; // Deposit plus the new account. + expect.post[To].exists = true; + 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); } diff --git a/test/unittests/state_tx_test.cpp b/test/unittests/state_tx_test.cpp index 42deb06f99..0049424888 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,8 +130,8 @@ 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)) - .execution_gas_limit, + block.gas_limit, 0, blob_gas_limit)) + .evm_gas, 39000); tx.blob_hashes[0] = 0x0200000000000000000000000000000000000000000000000000000000000001_bytes32; @@ -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)); } } @@ -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); @@ -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 c364a6d9b3..6b05663e9e 100644 --- a/test/utils/block_transition.cpp +++ b/test/utils/block_transition.cpp @@ -49,11 +49,10 @@ 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 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-7778). - int64_t sum_regular_gas = 0; + // The two dimensions of the block-level max(sum_regular, sum_state) formula (EIP-8037). + int64_t sum_execution_gas = 0; int64_t sum_state_gas = 0; auto blob_gas_left = blob_gas_limit; @@ -67,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)) { @@ -82,12 +81,12 @@ 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.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_execution_gas += receipt.block_execution_gas; + sum_state_gas += receipt.block_state_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,8 +118,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); + // The block's 2D gas formula (EIP-8037). + 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)}; } 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 45ec1047fe..0dba2f20ea 100644 --- a/test/utils/test_state.hpp +++ b/test/utils/test_state.hpp @@ -69,15 +69,10 @@ 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 883d47b5dce513dc46b0a19e0471eba8c001b14c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 16 Sep 2026 12:54:20 +0200 Subject: [PATCH 04/17] review round 3 --- evmc/include/evmc/evmc.h | 12 ++--- lib/evmone/constants.hpp | 12 +---- lib/evmone/instructions_calls.cpp | 80 ++++++++++------------------- lib/evmone/instructions_storage.cpp | 33 +++--------- lib/evmone/state_gas.hpp | 3 +- test/state/account.hpp | 1 - test/state/host.cpp | 1 - test/state/state.cpp | 49 +++++------------- test/state/system_contracts.cpp | 2 +- test/state/transaction.hpp | 11 ++-- test/unittests/state_tx_test.cpp | 16 +++--- test/utils/block_transition.cpp | 2 +- 12 files changed, 72 insertions(+), 150 deletions(-) diff --git a/evmc/include/evmc/evmc.h b/evmc/include/evmc/evmc.h index 3b16fbc2be..1c507a5fcf 100644 --- a/evmc/include/evmc/evmc.h +++ b/evmc/include/evmc/evmc.h @@ -120,6 +120,11 @@ struct evmc_message */ int64_t gas; + /** + * The amount of state gas available (EIP-8037). + */ + int64_t state_gas; + /** * The recipient of the message. * @@ -189,13 +194,6 @@ 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. */ diff --git a/lib/evmone/constants.hpp b/lib/evmone/constants.hpp index ceaf24287e..5b1feb540e 100644 --- a/lib/evmone/constants.hpp +++ b/lib/evmone/constants.hpp @@ -31,17 +31,9 @@ constexpr auto CALL_STIPEND = 2300; /// The fixed cost per state byte (EIP-8037). constexpr auto COST_PER_STATE_BYTE = 1530; -/// State bytes charged for creating a new account (EIP-8037). -constexpr auto STATE_BYTES_PER_NEW_ACCOUNT = 120; - -/// 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 (EIP-8037). -constexpr auto NEW_ACCOUNT_STATE_GAS = STATE_BYTES_PER_NEW_ACCOUNT * COST_PER_STATE_BYTE; +constexpr auto NEW_ACCOUNT_STATE_GAS = 120 * 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). +constexpr auto STORAGE_SET_STATE_GAS = 64 * COST_PER_STATE_BYTE; } // namespace evmone diff --git a/lib/evmone/instructions_calls.cpp b/lib/evmone/instructions_calls.cpp index 8b88cb5995..1bf1257af2 100644 --- a/lib/evmone/instructions_calls.cpp +++ b/lib/evmone/instructions_calls.cpp @@ -143,30 +143,21 @@ 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); - }; - + bool new_account_charged = false; // NOLINT(*-const-correctness) if constexpr (Op == OP_CALL) { if ((has_value || state.rev < EVMC_SPURIOUS_DRAGON) && !state.host.account_exists(dst)) { if (state.rev >= EVMC_AMSTERDAM) { - // 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)) + if (!state.state_gas.charge(gas_left, NEW_ACCOUNT_STATE_GAS)) return {EVMC_OUT_OF_GAS, gas_left}; + new_account_charged = true; } else if ((gas_left -= ACCOUNT_CREATION_COST) < 0) + { return {EVMC_OUT_OF_GAS, gas_left}; + } } } @@ -177,6 +168,7 @@ Result call_impl(StackTop stack, int64_t gas_left, ExecutionState& state) noexce else msg.flags &= ~std::underlying_type_t{EVMC_DELEGATED}; msg.depth = state.msg->depth + 1; + msg.state_gas = state.state_gas.left; msg.recipient = (Op == OP_CALL || Op == OP_STATICCALL) ? dst : state.msg->recipient; msg.code_address = code_addr; msg.sender = (Op == OP_DELEGATECALL) ? state.msg->sender : state.msg->recipient; @@ -214,7 +206,8 @@ Result call_impl(StackTop stack, int64_t gas_left, ExecutionState& state) noexce 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. + if (new_account_charged) + state.state_gas.refill(gas_left, NEW_ACCOUNT_STATE_GAS); return {EVMC_SUCCESS, gas_left}; // "Light" failure. } } @@ -223,10 +216,6 @@ Result call_impl(StackTop stack, int64_t gas_left, ExecutionState& state) noexce if (state.rev < EVMC_OSAKA && state.msg->depth >= 1024) 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); stack.top() = result.status_code == EVMC_SUCCESS; @@ -237,11 +226,14 @@ 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). absorb_child_state_gas(gas_left, state, result); - if (result.status_code != EVMC_SUCCESS) - refund_new_account_state_gas(); + + if constexpr (Op == OP_CALL) + { + if (new_account_charged && result.status_code != EVMC_SUCCESS) + state.state_gas.refill(gas_left, NEW_ACCOUNT_STATE_GAS); + } + return {EVMC_SUCCESS, gas_left}; } @@ -303,57 +295,39 @@ 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}; - // 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); + 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); // Access to the new address is warmed and never reverted (EIP-2929). if (state.rev >= EVMC_BERLIN) - state.host.access_account(create_addr); + state.host.access_account(msg.recipient); - // 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) + bool new_account_charged = false; + if (state.rev >= EVMC_AMSTERDAM && !state.host.account_exists(msg.recipient)) { - // 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}; - } + if (!state.state_gas.charge(gas_left, NEW_ACCOUNT_STATE_GAS)) + return {EVMC_OUT_OF_GAS, gas_left}; + new_account_charged = true; } - evmc_message msg{.kind = to_call_kind(Op)}; - msg.recipient = create_addr; - msg.gas = gas_left; if (state.rev >= EVMC_TANGERINE_WHISTLE) msg.gas -= msg.gas / 64; + msg.state_gas = state.state_gas.left; msg.input_data = init_code.data(); msg.input_size = init_code.size(); msg.sender = sender; 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). 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); + if (new_account_charged && result.status_code != EVMC_SUCCESS) + state.state_gas.refill(gas_left, NEW_ACCOUNT_STATE_GAS); 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 01bbbe7543..794411a1b3 100644 --- a/lib/evmone/instructions_storage.cpp +++ b/lib/evmone/instructions_storage.cpp @@ -42,9 +42,7 @@ constexpr auto storage_cost_spec = []() noexcept { tbl[EVMC_PRAGUE] = tbl[EVMC_LONDON]; tbl[EVMC_OSAKA] = tbl[EVMC_LONDON]; tbl[EVMC_AMSTERDAM] = tbl[EVMC_LONDON]; - // 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_AMSTERDAM].set = tbl[EVMC_AMSTERDAM].reset; // Only execution cost (EIP-8037). tbl[EVMC_EXPERIMENTAL] = tbl[EVMC_AMSTERDAM]; return tbl; }(); @@ -54,9 +52,6 @@ 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. @@ -95,14 +90,6 @@ 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; @@ -148,22 +135,18 @@ 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, state_gas] = sstore_costs[state.rev][status]; - const auto gas_cost = gas_cost_warm + gas_cost_cold; + if (state.rev >= EVMC_AMSTERDAM && status == EVMC_STORAGE_ADDED_DELETED) + state.state_gas.refill(gas_left, STORAGE_SET_STATE_GAS); - // 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 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). + const auto [gas_cost_warm, gas_refund] = sstore_costs[state.rev][status]; + const auto gas_cost = gas_cost_warm + gas_cost_cold; if ((gas_left -= gas_cost) < 0) return {EVMC_OUT_OF_GAS, gas_left}; - if (state_gas > 0 && !state.state_gas.charge(gas_left, state_gas)) + if (state.rev >= EVMC_AMSTERDAM && status == EVMC_STORAGE_ADDED && + !state.state_gas.charge(gas_left, STORAGE_SET_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 fad456444c..0ba0e2ae9f 100644 --- a/lib/evmone/state_gas.hpp +++ b/lib/evmone/state_gas.hpp @@ -22,7 +22,7 @@ struct StateGas /// Charges `cost`, first from `left`, then from `gas_left` (recorded in `spilled`). [[nodiscard]] bool charge(int64_t& gas_left, int64_t cost) noexcept { - assert(cost >= 0); + assert(cost >= 0); // 0 charge happens in code deployment. if (left >= cost) { left -= cost; @@ -42,6 +42,7 @@ struct StateGas /// Give the `cost` to `gas_left` (up to `spilled`) and `left` (whatever remains). void refill(int64_t& gas_left, int64_t cost) noexcept { + assert(cost >= 0); // 0 refill happens in absorb. const auto to_gas_left = std::min(cost, spilled); gas_left += to_gas_left; spilled -= to_gas_left; diff --git a/test/state/account.hpp b/test/state/account.hpp index ef42a5273d..b135ec7c04 100644 --- a/test/state/account.hpp +++ b/test/state/account.hpp @@ -97,5 +97,4 @@ struct Account return nonce == 0 && balance == 0 && code_hash == EMPTY_CODE_HASH; } }; - } // namespace evmone::state diff --git a/test/state/host.cpp b/test/state/host.cpp index a56c9e2328..00a4924846 100644 --- a/test/state/host.cpp +++ b/test/state/host.cpp @@ -284,7 +284,6 @@ evmc::Result Host::create(const evmc_message& msg) noexcept const auto execution_cost = 6 * ((std::ssize(code) + 31) / 32); const auto state_cost = std::ssize(code) * COST_PER_STATE_BYTE; 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}; } diff --git a/test/state/state.cpp b/test/state/state.cpp index 216d0a6cf4..0a2de1fff9 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include using namespace intx; @@ -195,7 +194,7 @@ int64_t process_authorization_list( return delegation_refund; } -evmc_message build_message(const Transaction& tx, int64_t evm_gas) noexcept +evmc_message build_message(const Transaction& tx, const TransactionProperties& tx_props) noexcept { const auto recipient = tx.to.has_value() ? *tx.to : compute_create_address(tx.sender, tx.nonce); @@ -203,7 +202,8 @@ evmc_message build_message(const Transaction& tx, int64_t evm_gas) noexcept .kind = tx.to.has_value() ? EVMC_CALL : EVMC_CREATE, .flags = 0, .depth = 0, - .gas = evm_gas, + .gas = tx_props.execution_gas_limit, + .state_gas = tx_props.state_gas_limit, .recipient = recipient, .sender = tx.sender, .input_data = tx.data.data(), @@ -212,7 +212,6 @@ evmc_message build_message(const Transaction& tx, int64_t evm_gas) noexcept .code_address = recipient, .code = nullptr, .code_size = 0, - .state_gas = 0, // Set by the caller for Amsterdam+. }; } } // namespace @@ -499,9 +498,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 - // execution-gas intrinsic and the per-dimension block inclusion below. - if (rev >= EVMC_OSAKA && rev < EVMC_AMSTERDAM && tx.gas_limit > MAX_TX_GAS_LIMIT) + if (rev == EVMC_OSAKA && 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 before the nonce and balance, as @@ -517,6 +514,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); + // REVIEW: Is this correct? why not check after the state-gas split? if (tx.gas_limit > block_state_gas_left) return make_error_code(GAS_ALLOWANCE_EXCEEDED); } @@ -561,25 +559,16 @@ std::variant validate_transaction( const auto [intrinsic_cost, min_cost] = compute_tx_intrinsic_cost(rev, tx); - // 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 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. - // 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); + // The transaction state-gas limit is all above the cap constant (EIP-8037). + const auto state_gas_limit = + rev >= EVMC_AMSTERDAM ? std::max(tx.gas_limit - MAX_TX_GAS_LIMIT, int64_t{0}) : 0; - if (tx.gas_limit < std::max(intrinsic_cost, min_cost)) + // Transaction gas limit with state-gas limit excluded must cover intrinsic and min cost. + if (tx.gas_limit - state_gas_limit < std::max(intrinsic_cost, min_cost)) return make_error_code(INTRINSIC_GAS_TOO_LOW); - const auto evm_gas = tx.gas_limit - intrinsic_cost; - return TransactionProperties{evm_gas, intrinsic_cost, min_cost}; + const auto execution_gas_limit = tx.gas_limit - intrinsic_cost - state_gas_limit; + return TransactionProperties{execution_gas_limit, state_gas_limit, min_cost}; } StateDiff finalize(const StateView& state_view, evmc_revision rev, const address& coinbase, @@ -647,7 +636,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.evm_gas); + auto message = build_message(tx, tx_props); sender_acc.access_status = EVMC_ACCESS_WARM; // Sender is always warm. host.access_account(message.recipient); // Recipient (incl. create address) is always warm. @@ -675,18 +664,6 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc } } - // 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 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); // Net state gas consumed by the execution, derived from the reservoir the top frame was diff --git a/test/state/system_contracts.cpp b/test/state/system_contracts.cpp index 82f42f8a89..819ee50450 100644 --- a/test/state/system_contracts.cpp +++ b/test/state/system_contracts.cpp @@ -81,11 +81,11 @@ evmc::Result execute_system_call(State& state, const BlockInfo& block, const evmc_message msg{ .kind = EVMC_CALL, .gas = 30'000'000, + .state_gas = 16 * STORAGE_SET_STATE_GAS, // Additional state-gas (EIP-8037). .recipient = addr, .sender = SYSTEM_ADDRESS, .input_data = input.data(), .input_size = input.size(), - .state_gas = 16 * STORAGE_SET_STATE_GAS, // Additional state-gas (EIP-8037). }; const Transaction empty_tx{}; diff --git a/test/state/transaction.hpp b/test/state/transaction.hpp index 397c54ecab..aae97d22b7 100644 --- a/test/state/transaction.hpp +++ b/test/state/transaction.hpp @@ -101,13 +101,11 @@ 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, 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 amount of gas provided to the EVM for the transaction execution. + int64_t execution_gas_limit = 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 amount of state-gas spendable by EVM on state increase (since EIP-8037). + int64_t state_gas_limit = 0; /// The minimal amount of gas the transaction must use. int64_t min_gas_cost = 0; @@ -144,6 +142,7 @@ struct TransactionReceipt /// Amount of gas used by this and previous transactions in the block. int64_t cumulative_gas_used = 0; + // REVIEW: missing. /// 2D per-tx block-gas components. The runner aggregates as /// `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 diff --git a/test/unittests/state_tx_test.cpp b/test/unittests/state_tx_test.cpp index 0049424888..eec6aa4e98 100644 --- a/test/unittests/state_tx_test.cpp +++ b/test/unittests/state_tx_test.cpp @@ -129,10 +129,10 @@ TEST(state_tx, validate_blob_tx) EXPECT_EQ( 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, 0, blob_gas_limit)) - .evm_gas, - 39000); + const auto res = + validate_transaction(state, block, tx, EVMC_CANCUN, block.gas_limit, 0, blob_gas_limit); + const auto& tx_props = std::get(res); + EXPECT_EQ(tx_props.execution_gas_limit, 39000); tx.blob_hashes[0] = 0x0200000000000000000000000000000000000000000000000000000000000001_bytes32; EXPECT_EQ(expect_error(blob_gas_limit), make_error_code(ErrorCode::INVALID_BLOB_HASH_VERSION)); @@ -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).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).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).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 6b05663e9e..d8877a3a67 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 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). + // The two dimensions of the block-level max(sum_execution, sum_state) formula (EIP-8037). int64_t sum_execution_gas = 0; int64_t sum_state_gas = 0; auto blob_gas_left = blob_gas_limit; From 0de883384c570e773dd34a397606339d36a758ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 16 Sep 2026 16:32:41 +0200 Subject: [PATCH 05/17] review round 4 --- lib/evmone/execution_state.hpp | 14 ++---- lib/evmone/state_gas.hpp | 1 - test/state/state.cpp | 17 +------ test/state/transaction.hpp | 11 ++--- test/unittests/state_transition.cpp | 2 +- test/unittests/state_transition.hpp | 3 +- ...tate_transition_eip8037_state_gas_test.cpp | 46 +++++++++++++++---- test/utils/block_transition.cpp | 20 ++++---- 8 files changed, 57 insertions(+), 57 deletions(-) diff --git a/lib/evmone/execution_state.hpp b/lib/evmone/execution_state.hpp index 687baeec63..f586be75b9 100644 --- a/lib/evmone/execution_state.hpp +++ b/lib/evmone/execution_state.hpp @@ -155,10 +155,9 @@ class ExecutionState const advanced::AdvancedCodeAnalysis* advanced; } analysis{}; - /// The frame's state-gas reservoir + spill; used is derived (EIP-8037). + /// The frame's state-gas counters (EIP-8037). /// - /// 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. + /// Kept in the cold tail so `status` and `host` are accessed with shorted instructions. StateGas state_gas; /// Stack space allocation. @@ -214,11 +213,9 @@ 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) { + // Unsuccessful frame doesn't commit any state changes, roll-back all state-gas costs. gas_left += state.state_gas.spilled; state.state_gas.left = state.msg->state_gas; state.state_gas.spilled = 0; @@ -230,12 +227,9 @@ inline evmc_result make_execution_result(ExecutionState& state, int64_t gas_left const auto gas_refund = (state.status == EVMC_SUCCESS) ? state.gas_refund : 0; assert(state.output_size != 0 || state.output_offset == 0); + // TODO: Simplify result creation. 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; diff --git a/lib/evmone/state_gas.hpp b/lib/evmone/state_gas.hpp index 0ba0e2ae9f..fd0d5c6ec4 100644 --- a/lib/evmone/state_gas.hpp +++ b/lib/evmone/state_gas.hpp @@ -13,7 +13,6 @@ namespace evmone struct StateGas { /// Remaining state-gas reservoir. - /// TODO: Try changing type to uint32_t. int64_t left = 0; /// Consumed state-gas taken from `gas_left` (happens when `left` is empty). diff --git a/test/state/state.cpp b/test/state/state.cpp index 0a2de1fff9..eb7aa0ac42 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -514,7 +514,6 @@ 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); - // REVIEW: Is this correct? why not check after the state-gas split? if (tx.gas_limit > block_state_gas_left) return make_error_code(GAS_ALLOWANCE_EXCEEDED); } @@ -685,17 +684,6 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc // 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 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 `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; @@ -705,9 +693,8 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc .status = result.status_code, .gas_used = sender_gas_cost, .gas_refund = - std::max(gas_used_b4_refund, tx_props.min_gas_cost) - sender_gas_cost, - .block_execution_gas = block_execution_gas, - .block_state_gas = block_state_gas, + std::max(gas_used_b4_refund, tx_props.min_gas_cost + tx_state_gas) - sender_gas_cost, + .state_gas_used = tx_state_gas, .logs = host.take_logs(), .state_diff = state.build_diff(rev), }; diff --git a/test/state/transaction.hpp b/test/state/transaction.hpp index aae97d22b7..6a4cf5203d 100644 --- a/test/state/transaction.hpp +++ b/test/state/transaction.hpp @@ -139,17 +139,12 @@ struct TransactionReceipt /// Effectively, the difference between "block" and "user" gas. int64_t gas_refund = 0; + /// The amount of state-gas used by this transaction (since EIP-8037). + int64_t state_gas_used = 0; + /// Amount of gas used by this and previous transactions in the block. int64_t cumulative_gas_used = 0; - // REVIEW: missing. - /// 2D per-tx block-gas components. The runner aggregates as - /// `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; StateDiff state_diff; diff --git a/test/unittests/state_transition.cpp b/test/unittests/state_transition.cpp index db2cfd80aa..1ca2d974b0 100644 --- a/test/unittests/state_transition.cpp +++ b/test/unittests/state_transition.cpp @@ -104,7 +104,7 @@ void state_transition::TearDown() } if (expect.state_gas.has_value()) { - EXPECT_EQ(receipt.block_state_gas, *expect.state_gas); + EXPECT_EQ(receipt.state_gas_used, *expect.state_gas); } const auto& diff = receipt.state_diff; diff --git a/test/unittests/state_transition.hpp b/test/unittests/state_transition.hpp index e2f02b69a9..57b163df7c 100644 --- a/test/unittests/state_transition.hpp +++ b/test/unittests/state_transition.hpp @@ -75,8 +75,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 (`block_state_gas`), - /// e.g. a NEW_ACCOUNT_STATE_GAS charge that survives a light failure. + /// The expected state-gas component of the receipt (EIP-8037). std::optional state_gas; /// The expected post-execution state. diff --git a/test/unittests/state_transition_eip8037_state_gas_test.cpp b/test/unittests/state_transition_eip8037_state_gas_test.cpp index 7839fe1ec5..81bdc0409b 100644 --- a/test/unittests/state_transition_eip8037_state_gas_test.cpp +++ b/test/unittests/state_transition_eip8037_state_gas_test.cpp @@ -36,9 +36,15 @@ TEST_F(state_transition, eip8037_create_tx_collision_excess_reservoir_refunded) namespace { -/// 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; +constexpr int64_t CALL_VALUE_COST = 9000; // Not exported by the interpreter. + +/// The intrinsic plus the CALL's execution gas: its seven arguments, the warm call, the +/// cold-account surcharge and the value transfer, less the stipend a light failure never spends. +/// The NEW_ACCOUNT state charge is refilled, so it does not appear here. +constexpr int64_t CALL_LIGHTFAIL_EXECUTION_GAS = + 21'000 + 7 * instr::gas_costs[EVMC_AMSTERDAM][OP_PUSH1] + + instr::gas_costs[EVMC_AMSTERDAM][OP_CALL] + instr::additional_cold_account_access_cost + + CALL_VALUE_COST - CALL_STIPEND; } // namespace TEST_F(state_transition, eip8037_call_value_lightfail_new_account_charge_refilled) @@ -129,13 +135,33 @@ TEST_F(state_transition, eip8037_sstore_slot_cleared_in_a_child_frame) expect.post[CLEARER].exists = true; } +TEST_F(state_transition, eip8037_reverted_child_keeps_the_slot_allocation_charged) +{ + // A child clearing a slot its caller allocated refills more state-gas than it was given, + // leaving its reservoir above its own budget. Reverting must restore that budget rather than + // credit the refill, so the allocation stays charged. + rev = EVMC_AMSTERDAM; + tx.to = To; + constexpr auto CLEARER = 0xdead_address; + pre[CLEARER] = {.code = sstore(1, 0) + revert(0, 0)}; + pre[To] = {.code = sstore(1, 1) + delegatecall(CLEARER).gas(0xffff) + OP_STOP}; + + // Intrinsic, twelve PUSHes, the cold allocation and its state charge, the cold DELEGATECALL, + // the reverted warm clear. The clear's refund dies with the frame. + expect.gas_used = 21'000 + 36 + 5000 + STORAGE_SET_STATE_GAS + 2600 + 100; + expect.gas_refund = 0; + expect.state_gas = STORAGE_SET_STATE_GAS; + expect.post[To].exists = true; + expect.post[To].storage[0x01_bytes32] = 0x01_bytes32; // The child's clear is rolled back. + 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_CODE_WORDS = MAX_CODE_SIZE_AMSTERDAM / 32; constexpr auto DEPOSIT_EXECUTION = 6 * DEPOSIT_CODE_WORDS; -constexpr auto DEPOSIT_STATE = DEPOSIT_CODE_SIZE * COST_PER_STATE_BYTE; +constexpr int64_t DEPOSIT_STATE = int64_t{MAX_CODE_SIZE_AMSTERDAM} * COST_PER_STATE_BYTE; /// 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; @@ -143,6 +169,8 @@ constexpr auto DEPOSIT_TX_GAS = state::MAX_TX_GAS_LIMIT + DEPOSIT_STATE + 1'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. +/// DEPOSIT_MEMORY mirrors the expansion formula in check_memory(); a change there shifts the +/// window rather than failing here. constexpr auto DEPOSIT_MEMORY = 3 * DEPOSIT_CODE_WORDS + DEPOSIT_CODE_WORDS * DEPOSIT_CODE_WORDS / 512; constexpr auto DEPOSIT_GAS_CAP = @@ -150,10 +178,10 @@ constexpr auto DEPOSIT_GAS_CAP = constexpr auto DEPOSIT_CREATOR = 0xbbbb_address; -/// Code deploying DEPOSIT_CODE_SIZE zero bytes through a nested CREATE. +/// Code deploying MAX_CODE_SIZE_AMSTERDAM zero bytes through a nested CREATE. bytecode deposit_creator_code() { - const auto initcode = ret(0, DEPOSIT_CODE_SIZE); + const auto initcode = ret(0, MAX_CODE_SIZE_AMSTERDAM); return mstore(0, push(initcode)) + create().input(32 - initcode.size(), initcode.size()); } } // namespace @@ -193,5 +221,5 @@ TEST_F(state_transition, eip8037_code_deposit_execution_gas_boundary) expect.post[To].exists = true; 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); + bytes(MAX_CODE_SIZE_AMSTERDAM, 0x00); } diff --git a/test/utils/block_transition.cpp b/test/utils/block_transition.cpp index d8877a3a67..0a0e0227d3 100644 --- a/test/utils/block_transition.cpp +++ b/test/utils/block_transition.cpp @@ -51,9 +51,6 @@ TransitionResult apply_block(const TestState& state, evmc::VM& vm, const state:: int64_t 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_execution, sum_state) formula (EIP-8037). - int64_t sum_execution_gas = 0; - int64_t sum_state_gas = 0; auto blob_gas_left = blob_gas_limit; for (size_t i = 0; i < txs.size(); ++i) @@ -81,12 +78,12 @@ 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_execution, sum_state) - // formula, which pre-Amsterdam is the single gas dimension (EIP-8037). - sum_execution_gas += receipt.block_execution_gas; - sum_state_gas += receipt.block_state_gas; - block_gas_left -= receipt.block_execution_gas; - block_state_gas_left -= receipt.block_state_gas; + // The execution dimension is the block gas less the state one; transition() floors + // gas_refund above the state component to keep it at the EIP-7623 calldata floor. + const auto block_tx_gas = + (rev >= EVMC_AMSTERDAM) ? receipt.gas_used + receipt.gas_refund : receipt.gas_used; + block_gas_left -= block_tx_gas - receipt.state_gas_used; + block_state_gas_left -= receipt.state_gas_used; blob_gas_left -= static_cast(tx.blob_gas_used()); receipts.emplace_back(std::move(receipt)); } @@ -118,8 +115,9 @@ 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_execution_gas, sum_state_gas); + // Both counters start at block.gas_limit, so this is max(execution used, state used): + // the block's gas used is its bottleneck dimension (EIP-8037). + const auto block_gas_used = block.gas_limit - std::min(block_gas_left, block_state_gas_left); 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 c0f7e02cecabb7aec1a5c113e3edfaee06272b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 16 Sep 2026 19:11:14 +0200 Subject: [PATCH 06/17] review host.cpp --- test/state/host.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/state/host.cpp b/test/state/host.cpp index 00a4924846..088a0a4961 100644 --- a/test/state/host.cpp +++ b/test/state/host.cpp @@ -425,10 +425,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 none out. Enforced here for every - // failure path, including the ones this Host builds itself (EIP-8037). + // Patch returned state-gas for early exits (not reaching EVM). TODO: Refactor. 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. From 86fcfa870c5f217cabdca34f89621d102117a9cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 16 Sep 2026 19:11:23 +0200 Subject: [PATCH 07/17] review state.cpp --- test/state/state.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/state/state.cpp b/test/state/state.cpp index eb7aa0ac42..2e614a1807 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -510,9 +510,8 @@ std::variant validate_transaction( } 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) + // Check limits in both dimensions, any failure invalidates the transaction. + if (std::min(tx.gas_limit, int64_t{MAX_TX_GAS_LIMIT}) > block_gas_left) return make_error_code(GAS_ALLOWANCE_EXCEEDED); if (tx.gas_limit > block_state_gas_left) return make_error_code(GAS_ALLOWANCE_EXCEEDED); From 10938ce7a1fa95bb99b0c0c6654fc24481eb3f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 16 Sep 2026 19:13:35 +0200 Subject: [PATCH 08/17] minimize diff --- test/state/state.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/state/state.cpp b/test/state/state.cpp index 2e614a1807..21d2fe78b3 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -681,18 +681,18 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc 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); + const auto gas_used = std::max(gas_used_b4_refund - refund, 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; + sender_acc.balance += tx_max_cost - gas_used * effective_gas_price; + state.touch(block.coinbase).balance += gas_used * priority_gas_price; // Cumulative gas used is unknown in this scope. TransactionReceipt receipt{ .type = tx.type, .status = result.status_code, - .gas_used = sender_gas_cost, + .gas_used = gas_used, .gas_refund = - std::max(gas_used_b4_refund, tx_props.min_gas_cost + tx_state_gas) - sender_gas_cost, + std::max(gas_used_b4_refund, tx_props.min_gas_cost + tx_state_gas) - gas_used, .state_gas_used = tx_state_gas, .logs = host.take_logs(), .state_diff = state.build_diff(rev), From a8a532c5efa3e91392a14194e28c2b30d4d09a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 16 Sep 2026 21:25:32 +0200 Subject: [PATCH 09/17] minimize diff --- test/state/state.cpp | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/test/state/state.cpp b/test/state/state.cpp index 21d2fe78b3..ba383a57dc 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -664,23 +664,17 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc const auto result = host.call(message); - // 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. Never negative: a refill needs a matching allocation, and the top frame has no - // ancestor to have made one (EIP-8037). - const auto tx_state_gas = message.state_gas - result.state_gas_left + result.state_gas_spilled; - assert(tx_state_gas >= 0); + const auto state_gas_used = + message.state_gas - result.state_gas_left + result.state_gas_spilled; + assert(state_gas_used >= 0); // 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; - // 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 = rev >= EVMC_LONDON ? gas_used_b4_refund / 5 : gas_used_b4_refund / 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). + assert(gas_used_b4_refund > refund); const auto gas_used = std::max(gas_used_b4_refund - refund, tx_props.min_gas_cost); sender_acc.balance += tx_max_cost - gas_used * effective_gas_price; @@ -692,8 +686,8 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc .status = result.status_code, .gas_used = gas_used, .gas_refund = - std::max(gas_used_b4_refund, tx_props.min_gas_cost + tx_state_gas) - gas_used, - .state_gas_used = tx_state_gas, + std::max(gas_used_b4_refund, tx_props.min_gas_cost + state_gas_used) - gas_used, + .state_gas_used = state_gas_used, .logs = host.take_logs(), .state_diff = state.build_diff(rev), }; From ea0f36560bbc441142bc774f13b19b639a85129b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 16 Sep 2026 21:28:27 +0200 Subject: [PATCH 10/17] minimize diff --- test/state/state.cpp | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/test/state/state.cpp b/test/state/state.cpp index ba383a57dc..c702c79cd8 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -668,14 +668,20 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc message.state_gas - result.state_gas_left + result.state_gas_spilled; assert(state_gas_used >= 0); - // 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; const auto refund_limit = rev >= EVMC_LONDON ? gas_used_b4_refund / 5 : gas_used_b4_refund / 2; const auto refund = std::min(delegation_refund + result.gas_refund, refund_limit); - assert(gas_used_b4_refund > refund); - const auto gas_used = std::max(gas_used_b4_refund - refund, tx_props.min_gas_cost); + auto gas_used = gas_used_b4_refund - refund; + assert(gas_used > 0); + + // 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); + + // 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 + state_gas_used); + const auto gas_refund = block_gas_used - gas_used; sender_acc.balance += tx_max_cost - gas_used * effective_gas_price; state.touch(block.coinbase).balance += gas_used * priority_gas_price; @@ -685,8 +691,7 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc .type = tx.type, .status = result.status_code, .gas_used = gas_used, - .gas_refund = - std::max(gas_used_b4_refund, tx_props.min_gas_cost + state_gas_used) - gas_used, + .gas_refund = gas_refund, .state_gas_used = state_gas_used, .logs = host.take_logs(), .state_diff = state.build_diff(rev), From 585c9c53d91fd503428d865eb90a04dfa79ff9c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 16 Sep 2026 22:11:04 +0200 Subject: [PATCH 11/17] state: Normalize state gas on failed host calls --- test/state/host.cpp | 14 ++-- ...tate_transition_eip8037_state_gas_test.cpp | 66 +++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/test/state/host.cpp b/test/state/host.cpp index 088a0a4961..9afac26c87 100644 --- a/test/state/host.cpp +++ b/test/state/host.cpp @@ -254,10 +254,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 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; + // Report the host's charge so Host::call can apply the failed-frame rule. + result.state_gas_spilled += state_gas.spilled; return result; } @@ -425,8 +423,14 @@ evmc::Result Host::call(const evmc_message& msg) noexcept if (result.status_code != EVMC_SUCCESS) { - // Patch returned state-gas for early exits (not reaching EVM). TODO: Refactor. + // A failed frame commits none of its state-gas charges. On REVERT, return the part drawn + // from execution gas; an exceptional halt consumes it with the rest of the frame's gas. + if (result.status_code == EVMC_REVERT) + result.gas_left += result.state_gas_spilled; + // msg.state_gas is the frame's baseline. Top-level preparation charges which survive a + // failed frame must therefore be applied before this value is put in the message. 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. diff --git a/test/unittests/state_transition_eip8037_state_gas_test.cpp b/test/unittests/state_transition_eip8037_state_gas_test.cpp index 81bdc0409b..7cc7f82040 100644 --- a/test/unittests/state_transition_eip8037_state_gas_test.cpp +++ b/test/unittests/state_transition_eip8037_state_gas_test.cpp @@ -34,6 +34,72 @@ TEST_F(state_transition, eip8037_create_tx_collision_excess_reservoir_refunded) expect.post[create_address] = {.nonce = 1, .code = bytecode{OP_STOP}}; } +TEST_F(state_transition, eip8037_create_tx_revert_refunds_spilled_new_account_charge) +{ + rev = EVMC_AMSTERDAM; + tx.data = revert(0, 0); + + const auto create_address = compute_create_address(Sender, pre[Sender].nonce); + expect.status = EVMC_REVERT; + // Intrinsic create and initcode costs plus two PUSH1 instructions. NEW_ACCOUNT is refunded. + expect.gas_used = + 21'000 + 32'000 + 56 + 2 + 2 * instr::gas_costs[EVMC_AMSTERDAM][OP_PUSH1]; + expect.gas_refund = 0; + expect.state_gas = 0; + expect.post[create_address].exists = false; +} + +TEST_F(state_transition, eip8037_create_tx_halt_returns_excess_reservoir) +{ + 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.data = bytecode{OP_INVALID}; + pre[Sender].balance = intx::uint256{tx.gas_limit} * tx.max_gas_price + tx.value + 1; + + const auto create_address = compute_create_address(Sender, pre[Sender].nonce); + expect.status = EVMC_INVALID_INSTRUCTION; + // The halt consumes the execution-gas dimension, but the unused reservoir is returned. + expect.gas_used = state::MAX_TX_GAS_LIMIT; + expect.gas_refund = 0; + expect.state_gas = 0; + expect.post[create_address].exists = false; +} + +TEST_F(state_transition, eip8037_create_tx_charges_new_account_and_code_deposit) +{ + rev = EVMC_AMSTERDAM; + tx.data = ret(0, 1); // Deploy a single zero byte. + + expect.state_gas = NEW_ACCOUNT_STATE_GAS + COST_PER_STATE_BYTE; + expect.post[compute_create_address(Sender, pre[Sender].nonce)].code = bytes{0x00}; +} + +TEST_F(state_transition, eip8037_create_tx_out_of_gas_on_new_account_charge) +{ + rev = EVMC_AMSTERDAM; + tx.gas_limit = 60'000; // Intrinsic gas leaves less than NEW_ACCOUNT_STATE_GAS. + + expect.status = EVMC_OUT_OF_GAS; + expect.gas_used = tx.gas_limit; + expect.state_gas = 0; + expect.post[compute_create_address(Sender, pre[Sender].nonce)].exists = false; +} + +TEST_F(state_transition, eip8037_nested_create_revert_refills_new_account_charge) +{ + rev = EVMC_AMSTERDAM; + tx.to = To; + pre[To] = {.code = mstore(0, push(revert(0, 0))) + create().input(27, 5) + OP_STOP}; + + expect.state_gas = 0; + expect.post[To].nonce = 1; +} + namespace { constexpr int64_t CALL_VALUE_COST = 9000; // Not exported by the interpreter. From 78c0ca4350e3a123429101cf561bf1bafbe73846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 16 Sep 2026 22:48:52 +0200 Subject: [PATCH 12/17] evmc: Initialize state gas in Result constructor --- evmc/include/evmc/evmc.h | 7 +++++++ evmc/include/evmc/evmc.hpp | 25 +++++++++++++++++++++++++ test/state/host.cpp | 11 +++++------ test/unittests/evmone_test.cpp | 21 +++++++++++++++++++++ 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/evmc/include/evmc/evmc.h b/evmc/include/evmc/evmc.h index 1c507a5fcf..ab2c209062 100644 --- a/evmc/include/evmc/evmc.h +++ b/evmc/include/evmc/evmc.h @@ -421,11 +421,18 @@ struct evmc_result /** * The amount of state gas left after execution (EIP-8037). + * + * If evmc_result::status_code is a positive failure code, this MUST equal + * ::evmc_message::state_gas supplied to the execution. */ int64_t state_gas_left; /** * The portion of consumed state gas taken from gas_left (EIP-8037). + * + * If evmc_result::status_code is a positive failure code, this MUST be 0. + * State gas spilled during a failed execution is not committed: for ::EVMC_REVERT it MUST + * be returned to evmc_result::gas_left; other failures consume it with the rest of the gas. */ int64_t state_gas_spilled; diff --git a/evmc/include/evmc/evmc.hpp b/evmc/include/evmc/evmc.hpp index f464703078..4fe5d4069f 100644 --- a/evmc/include/evmc/evmc.hpp +++ b/evmc/include/evmc/evmc.hpp @@ -328,6 +328,13 @@ constexpr auto make_result = evmc_make_result; class Result : private evmc_result { public: + /// State-gas fields of an execution result. + struct StateGas + { + int64_t left = 0; + int64_t spilled = 0; + }; + using evmc_result::gas_left; using evmc_result::gas_refund; using evmc_result::output_data; @@ -346,6 +353,8 @@ class Result : private evmc_result /// @param _gas_refund The amount of refunded gas. /// @param _output_data The pointer to the output. /// @param _output_size The output size. + /// + /// The state-gas fields are initialized to 0. explicit Result(evmc_status_code _status_code, int64_t _gas_left, int64_t _gas_refund, @@ -365,6 +374,22 @@ class Result : private evmc_result : evmc_result{make_result(_status_code, _gas_left, _gas_refund, nullptr, 0)} {} + /// Creates the result without output. + /// + /// @param _status_code The status code. + /// @param _gas_left The amount of gas left. + /// @param _gas_refund The amount of refunded gas. + /// @param _state_gas The state-gas fields. + explicit Result(evmc_status_code _status_code, + int64_t _gas_left, + int64_t _gas_refund, + StateGas _state_gas) noexcept + : Result{_status_code, _gas_left, _gas_refund} + { + state_gas_left = _state_gas.left; + state_gas_spilled = _state_gas.spilled; + } + /// Converting constructor from raw evmc_result. /// /// This object takes ownership of the resources of @p res. diff --git a/test/state/host.cpp b/test/state/host.cpp index 9afac26c87..7ee1d681f2 100644 --- a/test/state/host.cpp +++ b/test/state/host.cpp @@ -305,9 +305,8 @@ evmc::Result Host::create(const evmc_message& msg) noexcept new_acc->code_changed = true; } - auto r = evmc::Result{result.status_code, gas_left, result.gas_refund}; - set_state_gas(r, state_gas.left, state_gas.spilled); - return r; + return evmc::Result{result.status_code, gas_left, result.gas_refund, + {.left = state_gas.left, .spilled = state_gas.spilled}}; } evmc::Result Host::execute_message(const evmc_message& msg) noexcept @@ -390,11 +389,11 @@ evmc::Result Host::execute_message(const evmc_message& msg) noexcept const auto code = m_state.get_code(msg.code_address); if (code.empty()) { - 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; + // Skip the trivial execution. + return evmc::Result{ + EVMC_SUCCESS, gas, 0, {.left = top_level_sg.left, .spilled = top_level_sg.spilled}}; } // The depth-0 charge cannot reach here: it implies a not-alive recipient, which has empty diff --git a/test/unittests/evmone_test.cpp b/test/unittests/evmone_test.cpp index ad2d35e58a..faa28e72dd 100644 --- a/test/unittests/evmone_test.cpp +++ b/test/unittests/evmone_test.cpp @@ -15,6 +15,27 @@ TEST(evmone, info) EXPECT_TRUE(vm.is_abi_compatible()); } +TEST(evmc, result_with_state_gas) +{ + const auto result = evmc::Result{EVMC_SUCCESS, 1, 2, {.left = 3, .spilled = 4}}; + EXPECT_EQ(result.status_code, EVMC_SUCCESS); + EXPECT_EQ(result.gas_left, 1); + EXPECT_EQ(result.gas_refund, 2); + EXPECT_EQ(result.state_gas_left, 3); + EXPECT_EQ(result.state_gas_spilled, 4); + EXPECT_EQ(result.output_data, nullptr); + EXPECT_EQ(result.output_size, 0); + + const auto default_result = evmc::Result{}; + EXPECT_EQ(default_result.state_gas_left, 0); + EXPECT_EQ(default_result.state_gas_spilled, 0); + + const uint8_t output[] = {0x01}; + const auto output_result = evmc::Result{EVMC_REVERT, 1, 0, output, std::size(output)}; + EXPECT_EQ(output_result.state_gas_left, 0); + EXPECT_EQ(output_result.state_gas_spilled, 0); +} + TEST(evmone, set_option_invalid) { auto vm = evmc_create_evmone(); From c34c8dd0fd144b5a423374f7a7bb0c9d05f80514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 16 Sep 2026 23:02:56 +0200 Subject: [PATCH 13/17] state: Charge top-level value transfer before host call --- test/state/host.cpp | 58 +------------- test/state/precompiles.cpp | 3 +- test/state/state.cpp | 38 ++++++++- ...tate_transition_eip8037_state_gas_test.cpp | 77 ++++++++++++++++++- 4 files changed, 116 insertions(+), 60 deletions(-) diff --git a/test/state/host.cpp b/test/state/host.cpp index 7ee1d681f2..62ae841a94 100644 --- a/test/state/host.cpp +++ b/test/state/host.cpp @@ -18,14 +18,6 @@ namespace { 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 -{ - r.state_gas_left = left; - r.state_gas_spilled = spilled; -} } // namespace bool Host::account_exists(const address& addr) const noexcept @@ -314,29 +306,6 @@ 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 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; - - // A top-level value transfer pays NEW_ACCOUNT for the recipient it materializes, evaluated - // 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) - { - 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); @@ -373,34 +342,16 @@ 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)) - { - 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; - } + return call_precompile(m_rev, msg); // 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()) { - // 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. - // Skip the trivial execution. - return evmc::Result{ - EVMC_SUCCESS, gas, 0, {.left = top_level_sg.left, .spilled = top_level_sg.spilled}}; + // Skip trivial execution. + return evmc::Result{EVMC_SUCCESS, msg.gas, 0, {.left = msg.state_gas}}; } - // The depth-0 charge cannot reach here: it implies a not-alive recipient, which has empty - // 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()); } @@ -426,8 +377,7 @@ evmc::Result Host::call(const evmc_message& msg) noexcept // from execution gas; an exceptional halt consumes it with the rest of the frame's gas. if (result.status_code == EVMC_REVERT) result.gas_left += result.state_gas_spilled; - // msg.state_gas is the frame's baseline. Top-level preparation charges which survive a - // failed frame must therefore be applied before this value is put in the message. + // msg.state_gas is the frame's baseline supplied by the caller. result.state_gas_left = msg.state_gas; result.state_gas_spilled = 0; diff --git a/test/state/precompiles.cpp b/test/state/precompiles.cpp index d24708d42e..5727c3f6bb 100644 --- a/test/state/precompiles.cpp +++ b/test/state/precompiles.cpp @@ -864,7 +864,7 @@ evmc::Result call_precompile(evmc_revision rev, const evmc_message& msg) noexcep const auto [gas_cost, max_output_size] = analyze(input, rev); const auto gas_left = msg.gas - gas_cost; if (gas_left < 0) - return evmc::Result{EVMC_OUT_OF_GAS}; + return evmc::Result{EVMC_OUT_OF_GAS, 0, 0, {.left = msg.state_gas}}; // Allocate buffer for the precompile's output and pass its ownership to evmc::Result. // TODO: This can be done more elegantly by providing constructor evmc::Result(std::unique_ptr). @@ -874,6 +874,7 @@ evmc::Result call_precompile(evmc_revision rev, const evmc_message& msg) noexcep return evmc::Result{{ .status_code = status_code, .gas_left = status_code == EVMC_SUCCESS ? gas_left : 0, + .state_gas_left = msg.state_gas, .output_data = output_data, .output_size = output_size, .release = [](const evmc_result* res) noexcept { delete[] res->output_data; }, diff --git a/test/state/state.cpp b/test/state/state.cpp index c702c79cd8..f4c3fc269b 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -9,6 +9,7 @@ #include "state_view.hpp" #include #include +#include #include #include @@ -662,10 +663,41 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc } } - const auto result = host.call(message); + const auto state_gas_limit = message.state_gas; + StateGas state_gas{.left = state_gas_limit}; + auto preparation_state_cost = int64_t{0}; + // A top-level value transfer materializing a new state leaf pays NEW_ACCOUNT here, after + // authorizations and before the transfer. There is no opcode execution to charge it instead. + if (rev >= EVMC_AMSTERDAM && tx.to.has_value() && tx.value != 0) + { + const auto* const recipient = state.find(message.recipient); + if (recipient == nullptr || recipient->is_empty()) + preparation_state_cost = NEW_ACCOUNT_STATE_GAS; + } + const auto charge_succeeded = state_gas.charge(message.gas, preparation_state_cost); + message.state_gas = state_gas.left; + + // A failed runtime preparation charge is an included out-of-gas transaction (EIP-2780). + // TODO(EIP-2780): Roll back authorization changes when this charge fails. + auto result = evmc::Result{EVMC_OUT_OF_GAS, 0, 0, {.left = state_gas_limit}}; + if (charge_succeeded) + { + result = host.call(message); + if (result.status_code == EVMC_SUCCESS) + { + result.state_gas_spilled += state_gas.spilled; + } + else + { + assert(result.state_gas_left == message.state_gas); + assert(result.state_gas_spilled == 0); + if (result.status_code == EVMC_REVERT) + result.gas_left += state_gas.spilled; + result.state_gas_left = state_gas_limit; + } + } - const auto state_gas_used = - message.state_gas - result.state_gas_left + result.state_gas_spilled; + const auto state_gas_used = state_gas_limit - result.state_gas_left + result.state_gas_spilled; assert(state_gas_used >= 0); const auto gas_used_b4_refund = tx.gas_limit - result.gas_left - result.state_gas_left; diff --git a/test/unittests/state_transition_eip8037_state_gas_test.cpp b/test/unittests/state_transition_eip8037_state_gas_test.cpp index 7cc7f82040..0af623d6bd 100644 --- a/test/unittests/state_transition_eip8037_state_gas_test.cpp +++ b/test/unittests/state_transition_eip8037_state_gas_test.cpp @@ -42,8 +42,7 @@ TEST_F(state_transition, eip8037_create_tx_revert_refunds_spilled_new_account_ch const auto create_address = compute_create_address(Sender, pre[Sender].nonce); expect.status = EVMC_REVERT; // Intrinsic create and initcode costs plus two PUSH1 instructions. NEW_ACCOUNT is refunded. - expect.gas_used = - 21'000 + 32'000 + 56 + 2 + 2 * instr::gas_costs[EVMC_AMSTERDAM][OP_PUSH1]; + expect.gas_used = 21'000 + 32'000 + 56 + 2 + 2 * instr::gas_costs[EVMC_AMSTERDAM][OP_PUSH1]; expect.gas_refund = 0; expect.state_gas = 0; expect.post[create_address].exists = false; @@ -79,6 +78,16 @@ TEST_F(state_transition, eip8037_create_tx_charges_new_account_and_code_deposit) expect.post[compute_create_address(Sender, pre[Sender].nonce)].code = bytes{0x00}; } +TEST_F(state_transition, eip8037_create_tx_with_value_pays_new_account_once) +{ + rev = EVMC_AMSTERDAM; + tx.value = 1; + + expect.gas_used = 53'000 + NEW_ACCOUNT_STATE_GAS; + expect.state_gas = NEW_ACCOUNT_STATE_GAS; + expect.post[compute_create_address(Sender, pre[Sender].nonce)] = {.nonce = 1, .balance = 1}; +} + TEST_F(state_transition, eip8037_create_tx_out_of_gas_on_new_account_charge) { rev = EVMC_AMSTERDAM; @@ -166,6 +175,70 @@ TEST_F(state_transition, eip8037_value_to_zero_balance_precompile_pays_new_accou expect.state_gas = NEW_ACCOUNT_STATE_GAS; } +TEST_F(state_transition, eip8037_value_to_new_account_pays_new_account) +{ + rev = EVMC_AMSTERDAM; + tx.to = To; // Absent from pre. + tx.value = 1; + tx.gas_limit = 21'000 + NEW_ACCOUNT_STATE_GAS; // Exact successful boundary. + + expect.post[To].balance = 1; + expect.gas_used = tx.gas_limit; + expect.state_gas = NEW_ACCOUNT_STATE_GAS; +} + +TEST_F(state_transition, eip8037_value_to_existing_empty_account_pays_new_account) +{ + rev = EVMC_AMSTERDAM; + tx.to = To; + tx.value = 1; + pre[To] = {}; + + expect.post[To].balance = 1; + expect.gas_used = 21'000 + NEW_ACCOUNT_STATE_GAS; + expect.state_gas = NEW_ACCOUNT_STATE_GAS; +} + +TEST_F(state_transition, eip8037_value_to_new_account_uses_reservoir_then_execution_gas) +{ + rev = EVMC_AMSTERDAM; + tx.to = To; // Absent from pre. + tx.value = 1; + tx.gas_limit = state::MAX_TX_GAS_LIMIT + NEW_ACCOUNT_STATE_GAS / 2; + block.gas_limit = tx.gas_limit; + pre[Sender].balance = intx::uint256{tx.gas_limit} * tx.max_gas_price + tx.value + 1; + + expect.post[To].balance = 1; + expect.gas_used = 21'000 + NEW_ACCOUNT_STATE_GAS; + expect.state_gas = NEW_ACCOUNT_STATE_GAS; +} + +TEST_F(state_transition, eip8037_value_to_new_account_out_of_gas) +{ + rev = EVMC_AMSTERDAM; + tx.to = To; // Absent from pre. + tx.value = 1; + tx.gas_limit = 21'000 + NEW_ACCOUNT_STATE_GAS - 1; + + expect.status = EVMC_OUT_OF_GAS; + expect.gas_used = tx.gas_limit; + expect.state_gas = 0; + expect.post[To].exists = false; +} + +TEST_F(state_transition, eip8037_value_to_new_precompile_failure_refunds_new_account) +{ + rev = EVMC_AMSTERDAM; + tx.to = 0x04_address; // Identity, absent from pre. + tx.value = 1; + tx.gas_limit = 21'000 + NEW_ACCOUNT_STATE_GAS + 14; // Identity requires 15 gas. + + expect.status = EVMC_OUT_OF_GAS; + expect.gas_used = tx.gas_limit; + expect.state_gas = 0; + expect.post[*tx.to].exists = false; +} + TEST_F(state_transition, eip8037_sstore_slot_allocated_and_cleared_in_one_tx) { // Allocating a slot and clearing it in the same transaction (0 -> 1 -> 0) refills the From 36035328104370a00a2cf40a2f225fb749e9e5cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 16 Sep 2026 23:39:55 +0200 Subject: [PATCH 14/17] state: Charge top-level create before host call --- test/state/host.cpp | 36 +++---------------- test/state/state.cpp | 6 ++-- ...tate_transition_eip8037_state_gas_test.cpp | 27 +++++++++++++- 3 files changed, 34 insertions(+), 35 deletions(-) diff --git a/test/state/host.cpp b/test/state/host.cpp index 62ae841a94..2f3f034f1b 100644 --- a/test/state/host.cpp +++ b/test/state/host.cpp @@ -10,16 +10,6 @@ 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(); -} -} // namespace - bool Host::account_exists(const address& addr) const noexcept { const auto* const acc = m_state.find(addr); @@ -194,9 +184,6 @@ 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); @@ -232,24 +219,10 @@ 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. 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) - { - 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) - { - // Report the host's charge so Host::call can apply the failed-frame rule. - result.state_gas_spilled += state_gas.spilled; return result; - } auto gas_left = result.gas_left; assert(gas_left >= 0); @@ -264,10 +237,11 @@ 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}; - // 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; + // The initcode frame's state-gas pools, carried into the code-deposit charge. + StateGas state_gas{ + .left = result.state_gas_left, + .spilled = result.state_gas_spilled, + }; if (m_rev >= EVMC_AMSTERDAM) { // The code deposit splits into an execution-gas and a state-gas component (EIP-8037). diff --git a/test/state/state.cpp b/test/state/state.cpp index f4c3fc269b..ec3f14e3f1 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -666,9 +666,9 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc const auto state_gas_limit = message.state_gas; StateGas state_gas{.left = state_gas_limit}; auto preparation_state_cost = int64_t{0}; - // A top-level value transfer materializing a new state leaf pays NEW_ACCOUNT here, after - // authorizations and before the transfer. There is no opcode execution to charge it instead. - if (rev >= EVMC_AMSTERDAM && tx.to.has_value() && tx.value != 0) + // A top-level create or value transfer materializing a new state leaf pays NEW_ACCOUNT here, + // after authorizations and before execution. There is no calling opcode to charge it instead. + if (rev >= EVMC_AMSTERDAM && (!tx.to.has_value() || tx.value != 0)) { const auto* const recipient = state.find(message.recipient); if (recipient == nullptr || recipient->is_empty()) diff --git a/test/unittests/state_transition_eip8037_state_gas_test.cpp b/test/unittests/state_transition_eip8037_state_gas_test.cpp index 0af623d6bd..3dbac873b6 100644 --- a/test/unittests/state_transition_eip8037_state_gas_test.cpp +++ b/test/unittests/state_transition_eip8037_state_gas_test.cpp @@ -26,7 +26,7 @@ TEST_F(state_transition, eip8037_create_tx_collision_excess_reservoir_refunded) 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. + // The colliding account is alive, so the preparation charge does not apply. expect.status = EVMC_FAILURE; expect.gas_used = state::MAX_TX_GAS_LIMIT; expect.gas_refund = 0; @@ -88,6 +88,31 @@ TEST_F(state_transition, eip8037_create_tx_with_value_pays_new_account_once) expect.post[compute_create_address(Sender, pre[Sender].nonce)] = {.nonce = 1, .balance = 1}; } +TEST_F(state_transition, eip8037_create_tx_uses_reservoir_then_execution_gas) +{ + rev = EVMC_AMSTERDAM; + tx.gas_limit = state::MAX_TX_GAS_LIMIT + NEW_ACCOUNT_STATE_GAS / 2; + block.gas_limit = tx.gas_limit; + pre[Sender].balance = intx::uint256{tx.gas_limit} * tx.max_gas_price + tx.value + 1; + + expect.gas_used = 53'000 + NEW_ACCOUNT_STATE_GAS; + expect.gas_refund = 0; + expect.state_gas = NEW_ACCOUNT_STATE_GAS; + expect.post[compute_create_address(Sender, pre[Sender].nonce)].nonce = 1; +} + +TEST_F(state_transition, eip8037_create_tx_to_prefunded_account_has_no_new_account_charge) +{ + rev = EVMC_AMSTERDAM; + + const auto create_address = compute_create_address(Sender, pre[Sender].nonce); + pre[create_address].balance = 1; + + expect.gas_used = 53'000; + expect.state_gas = 0; + expect.post[create_address] = {.nonce = 1, .balance = 1}; +} + TEST_F(state_transition, eip8037_create_tx_out_of_gas_on_new_account_charge) { rev = EVMC_AMSTERDAM; From 24611a291ea8455083a0ef385d62a9b68d5a8962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Thu, 17 Sep 2026 09:23:21 +0200 Subject: [PATCH 15/17] state: Build failed create results with the caller's baseline Host::call patched the state-gas fields of every failed result because Host::create returned bare failures with zeroed ones. Build them with the baseline instead, which leaves the patch asserting the contract evmc.h already states. The REVERT spill return it also carried was dead: a frame's spill is settled by make_execution_result before the result reaches here. --- test/state/host.cpp | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/test/state/host.cpp b/test/state/host.cpp index 2f3f034f1b..bc8f98edca 100644 --- a/test/state/host.cpp +++ b/test/state/host.cpp @@ -182,6 +182,11 @@ evmc::Result Host::create(const evmc_message& msg) noexcept assert(msg.kind == EVMC_CREATE || msg.kind == EVMC_CREATE2); assert(msg.recipient != address{}); // Must be computed already. + // A failed create commits no state gas, so it returns the caller's baseline (EIP-8037). + const auto fail = [&msg](evmc_status_code status) noexcept { + return evmc::Result{status, 0, 0, {.left = msg.state_gas}}; + }; + // TODO: find()+insert() probes m_modified twice for a new recipient. auto* new_acc = m_state.find(msg.recipient); if (new_acc == nullptr) @@ -192,7 +197,7 @@ 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. + return fail(EVMC_FAILURE); // TODO: Add EVMC errors for creation failures. m_state.journal_create(msg.recipient); } @@ -231,11 +236,11 @@ evmc::Result Host::create(const evmc_message& msg) noexcept const size_t max_code_size = m_rev >= EVMC_AMSTERDAM ? MAX_CODE_SIZE_AMSTERDAM : MAX_CODE_SIZE; if (m_rev >= EVMC_SPURIOUS_DRAGON && code.size() > max_code_size) - return evmc::Result{EVMC_FAILURE}; + return fail(EVMC_FAILURE); // Reject new contract code starting with the 0xEF byte (EIP-3541). if (m_rev >= EVMC_LONDON && code.starts_with(0xEF)) - return evmc::Result{EVMC_CONTRACT_VALIDATION_FAILURE}; + return fail(EVMC_CONTRACT_VALIDATION_FAILURE); // The initcode frame's state-gas pools, carried into the code-deposit charge. StateGas state_gas{ @@ -249,7 +254,7 @@ evmc::Result Host::create(const evmc_message& msg) noexcept const auto state_cost = std::ssize(code) * COST_PER_STATE_BYTE; gas_left -= execution_cost; if (gas_left < 0 || !state_gas.charge(gas_left, state_cost)) - return evmc::Result{EVMC_FAILURE}; + return fail(EVMC_FAILURE); } else { @@ -260,7 +265,7 @@ evmc::Result Host::create(const evmc_message& msg) noexcept { return (m_rev == EVMC_FRONTIER) ? evmc::Result{EVMC_SUCCESS, result.gas_left, result.gas_refund} : - evmc::Result{EVMC_FAILURE}; + fail(EVMC_FAILURE); } } @@ -347,13 +352,10 @@ evmc::Result Host::call(const evmc_message& msg) noexcept if (result.status_code != EVMC_SUCCESS) { - // A failed frame commits none of its state-gas charges. On REVERT, return the part drawn - // from execution gas; an exceptional halt consumes it with the rest of the frame's gas. - if (result.status_code == EVMC_REVERT) - result.gas_left += result.state_gas_spilled; - // msg.state_gas is the frame's baseline supplied by the caller. - result.state_gas_left = msg.state_gas; - result.state_gas_spilled = 0; + // A failed frame commits none of its state-gas charges: it returns the caller's baseline + // and carries no spill (EIP-8037). + assert(result.state_gas_left == msg.state_gas); + assert(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. From 7bd1188fc641863653606133af35a85efc1d4e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Thu, 17 Sep 2026 09:31:39 +0200 Subject: [PATCH 16/17] state: Update validate_transaction's return docs It returns the state-gas reservoir alongside the execution gas limit since EIP-8037, but both doc comments still described a single limit. --- test/state/state.cpp | 5 +++-- test/state/state.hpp | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/test/state/state.cpp b/test/state/state.cpp index ec3f14e3f1..e0b6c4bdb8 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -434,8 +434,9 @@ void State::rollback(size_t checkpoint) } } -/// Validates transaction and computes its execution gas limit (the amount of gas provided to EVM). -/// @return Execution gas limit or transaction validation error. +/// Validates transaction and computes the gas limits it provides to the EVM: the execution gas +/// and, since EIP-8037, the state-gas reservoir. +/// @return The transaction's computed gas properties or a 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 block_state_gas_left, int64_t blob_gas_left) noexcept diff --git a/test/state/state.hpp b/test/state/state.hpp index af44bd9c29..46f8b46ab6 100644 --- a/test/state/state.hpp +++ b/test/state/state.hpp @@ -144,7 +144,7 @@ TransactionReceipt transition(const StateView& state, const BlockInfo& block, /// Validate a transaction. /// /// @param block_state_gas_left Remaining block state-gas (EIP-8037). -/// @return Computed execution gas limit or validation error. +/// @return The transaction's computed gas properties or a 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 block_state_gas_left, int64_t blob_gas_left) noexcept; From 27db0659d890dc76d63f834cac6e12fac6472c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Thu, 17 Sep 2026 09:32:31 +0200 Subject: [PATCH 17/17] state: Build the preparation result once The out-of-gas result was constructed for every transaction and overwritten on the normal path. Select it with the host call instead, and drop the charge_succeeded branch around the settlement: a failed charge leaves both counters untouched, so the failure arm is already a no-op for it. --- test/state/state.cpp | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/test/state/state.cpp b/test/state/state.cpp index e0b6c4bdb8..e67d64d249 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -680,22 +680,23 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc // A failed runtime preparation charge is an included out-of-gas transaction (EIP-2780). // TODO(EIP-2780): Roll back authorization changes when this charge fails. - auto result = evmc::Result{EVMC_OUT_OF_GAS, 0, 0, {.left = state_gas_limit}}; - if (charge_succeeded) + auto result = charge_succeeded ? host.call(message) : + evmc::Result{EVMC_OUT_OF_GAS, 0, 0, {.left = state_gas_limit}}; + + // Settle the preparation charge like any frame charge: committed on success, and on failure + // returned whole, its spill going back to gas_left on a revert and consumed by a halt. + // A failed charge leaves both counters untouched, making this a no-op for it. + if (result.status_code == EVMC_SUCCESS) { - result = host.call(message); - if (result.status_code == EVMC_SUCCESS) - { - result.state_gas_spilled += state_gas.spilled; - } - else - { - assert(result.state_gas_left == message.state_gas); - assert(result.state_gas_spilled == 0); - if (result.status_code == EVMC_REVERT) - result.gas_left += state_gas.spilled; - result.state_gas_left = state_gas_limit; - } + result.state_gas_spilled += state_gas.spilled; + } + else + { + assert(result.state_gas_left == message.state_gas); + assert(result.state_gas_spilled == 0); + if (result.status_code == EVMC_REVERT) + result.gas_left += state_gas.spilled; + result.state_gas_left = state_gas_limit; } const auto state_gas_used = state_gas_limit - result.state_gas_left + result.state_gas_spilled;