diff --git a/evmc/include/evmc/evmc.h b/evmc/include/evmc/evmc.h index 2807838ab1..3b16fbc2be 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. */ @@ -414,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. * 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/lib/evmone/constants.hpp b/lib/evmone/constants.hpp index 6bbb9f9a6f..ceaf24287e 100644 --- a/lib/evmone/constants.hpp +++ b/lib/evmone/constants.hpp @@ -27,4 +27,21 @@ 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 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; + +/// 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/execution_state.hpp b/lib/evmone/execution_state.hpp index 9511612a7c..687baeec63 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,12 @@ class ExecutionState const advanced::AdvancedCodeAnalysis* advanced; } analysis{}; + /// The frame's state-gas reservoir + spill; used is derived (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. + StateGas state_gas; + /// Stack space allocation. /// /// This is the last field to make other fields' offsets of reasonable values. @@ -164,7 +171,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 +184,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 +214,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..2c38e5a9dc 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" @@ -1081,8 +1082,16 @@ 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) + { + if (!state.state_gas.charge(gas_left, NEW_ACCOUNT_STATE_GAS)) + return {EVMC_OUT_OF_GAS, gas_left}; + } + else + { + if ((gas_left -= 25000) < 0) + return {EVMC_OUT_OF_GAS, gas_left}; + } } } } diff --git a/lib/evmone/instructions_calls.cpp b/lib/evmone/instructions_calls.cpp index 97df42e064..ff51fc5c50 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. @@ -119,11 +143,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 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}; + } + else if ((gas_left -= ACCOUNT_CREATION_COST) < 0) return {EVMC_OUT_OF_GAS, gas_left}; } } @@ -171,12 +213,22 @@ Result call_impl(StackTop stack, int64_t gas_left, ExecutionState& state) noexce msg.gas += CALL_STIPEND; gas_left += CALL_STIPEND; if (intx::be::load(state.host.get_balance(state.msg->recipient)) < value) + { + refund_new_account_state_gas(); // No transfer, so no account created. return {EVMC_SUCCESS, gas_left}; // "Light" failure. + } } } if (state.msg->depth >= 1024) + { + refund_new_account_state_gas(); // Child never runs, so no account created. return {EVMC_SUCCESS, gas_left}; // "Light" failure. + } + + // The reservoir passes to the child in full; the 63/64 rule applies to gas_left only + // (EIP-8037). + msg.state_gas = state.state_gas.left; const auto result = state.host.call(msg); state.return_data.assign(result.output_data, result.output_size); @@ -188,6 +240,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). + absorb_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 +306,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 +344,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). + 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); 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..01bbbe7543 100644 --- a/lib/evmone/instructions_storage.cpp +++ b/lib/evmone/instructions_storage.cpp @@ -42,7 +42,10 @@ 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]; + // 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; }(); @@ -51,6 +54,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 +95,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 +148,22 @@ 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 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). 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)) + 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..fad456444c --- /dev/null +++ b/lib/evmone/state_gas.hpp @@ -0,0 +1,51 @@ +// evmone: Fast Ethereum Virtual Machine implementation +// Copyright 2026 The evmone Authors. +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +namespace evmone +{ +/// A frame's state-gas as a (left, spilled) pair, independent from the execution gas (EIP-8037). +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). + 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 + { + assert(cost >= 0); + 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; + } + + /// Refund state-gas. + /// + /// 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 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/account.hpp b/test/state/account.hpp index b135ec7c04..ef42a5273d 100644 --- a/test/state/account.hpp +++ b/test/state/account.hpp @@ -97,4 +97,5 @@ 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 bbefb81ea5..31def58c64 100644 --- a/test/state/host.cpp +++ b/test/state/host.cpp @@ -6,9 +6,28 @@ #include "precompiles.hpp" #include "system_contracts.hpp" #include +#include 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 +{ + 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 +202,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); @@ -217,10 +239,27 @@ 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. 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) + { + // 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; + } auto gas_left = result.gas_left; assert(gas_left >= 0); @@ -235,14 +274,31 @@ 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 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 -= 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) + { + return (m_rev == EVMC_FRONTIER) ? + evmc::Result{EVMC_SUCCESS, result.gas_left, result.gas_refund} : + evmc::Result{EVMC_FAILURE}; + } } if (!code.empty()) @@ -252,7 +308,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 +318,29 @@ 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); @@ -296,13 +377,34 @@ 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. + // 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()); } @@ -324,6 +426,11 @@ 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). + 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/precompiles.cpp b/test/state/precompiles.cpp index 3f5cc5ccb7..1649da6c33 100644 --- a/test/state/precompiles.cpp +++ b/test/state/precompiles.cpp @@ -871,9 +871,13 @@ evmc::Result call_precompile(evmc_revision rev, const evmc_message& msg) noexcep const auto output_data = new (std::nothrow) uint8_t[max_output_size]; // TODO: handle nullptr. const auto [status_code, output_size] = execute(msg.input_data, msg.input_size, output_data, max_output_size); - const evmc_result result{status_code, status_code == EVMC_SUCCESS ? gas_left : 0, 0, - output_data, output_size, - [](const evmc_result* res) noexcept { delete[] res->output_data; }}; + const evmc_result result{ + .status_code = status_code, + .gas_left = status_code == EVMC_SUCCESS ? gas_left : 0, + .output_data = output_data, + .output_size = output_size, + .release = [](const evmc_result* res) noexcept { delete[] res->output_data; }, + }; return evmc::Result{result}; } } // namespace evmone::state diff --git a/test/state/state.cpp b/test/state/state.cpp index 1046e295a5..ddede557d8 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(), @@ -211,6 +212,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 +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) 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); @@ -497,11 +499,27 @@ 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 + // 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); - 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 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) + 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 > block_state_gas_left) + return make_error_code(GAS_ALLOWANCE_EXCEEDED); + } if (tx.max_gas_price < block.base_fee) return make_error_code(INSUFFICIENT_MAX_FEE_PER_GAS); @@ -546,11 +564,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_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); + 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}; + 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, @@ -618,7 +651,7 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc Host host{rev, vm, state, block, block_hashes, tx}; - auto message = build_message(tx, tx_props.execution_gas_limit); + auto message = build_message(tx, tx_props.evm_gas); sender_acc.access_status = EVMC_ACCESS_WARM; // Sender is always warm. host.access_account(message.recipient); // Recipient (incl. create address) is always warm. @@ -646,32 +679,71 @@ TransactionReceipt transition(const StateView& state_view, const BlockInfo& bloc } } - const auto result = host.call(message); - - const auto gas_used_b4_refund = tx.gas_limit - result.gas_left; + // Split 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 max_refund_quotient = rev >= EVMC_LONDON ? 5 : 2; - const auto refund_limit = gas_used_b4_refund / max_refund_quotient; - const auto refund = std::min(delegation_refund + result.gas_refund, refund_limit); - auto gas_used = gas_used_b4_refund - refund; - assert(gas_used > 0); + const auto result = host.call(message); - // The gas used by the transaction must be at least the min_gas_cost (EIP-7623). - gas_used = std::max(gas_used, tx_props.min_gas_cost); + // Net state gas consumed by the execution, derived from the reservoir the top frame was + // handed: initial - left + spilled. Zero on a top-level failure, the frame having refilled + // itself. 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); - // For block gas accounting, compute the gas refund capped by the min gas cost (EIP-7778). - const auto block_gas_used = std::max(gas_used_b4_refund, tx_props.min_gas_cost); - const auto gas_refund = block_gas_used - gas_used; + // Gas consumed = gas_limit - 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; - sender_acc.balance += tx_max_cost - gas_used * effective_gas_price; - state.touch(block.coinbase).balance += gas_used * priority_gas_price; + // The refund is capped at 1/5 of the gas consumed (1/2 before EIP-3529). The sender pays the + // rest, floored at the EIP-7623 calldata floor (EELS: max(before_refund - refund, floor)). + const auto refund_limit = gas_used_b4_refund / (rev >= EVMC_LONDON ? 5 : 2); + const auto refund = std::min(delegation_refund + result.gas_refund, refund_limit); + assert(gas_used_b4_refund - refund > 0); + // The post-refund, post-floor gas the sender pays for (== receipt gas_used). + const auto sender_gas_cost = std::max(gas_used_b4_refund - refund, tx_props.min_gas_cost); + + // The block's 2D gas components (EIP-7778): pre-Amsterdam the block tracks a single + // dimension, so all of the gas the sender paid for is 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; // Cumulative gas used is unknown in this scope. - TransactionReceipt receipt{tx.type, result.status_code, gas_used, gas_refund, {}, - host.take_logs(), {}, state.build_diff(rev)}; + TransactionReceipt receipt{}; + receipt.type = tx.type; + receipt.status = result.status_code; + // Receipt gas_used = what the sender paid for: post-refund, floored at the + // EIP-7623 calldata floor. + receipt.gas_used = sender_gas_cost; + receipt.block_execution_gas = block_execution_gas; + receipt.block_state_gas = block_state_gas; + // Per-tx refund applied to receipt.gas_used: the floored pre-refund gas minus what the + // sender paid, so gas_used + gas_refund is the pre-refund gas the block accumulates + // (EIP-7778) and never goes negative when the calldata floor binds. + receipt.gas_refund = std::max(gas_used_b4_refund, tx_props.min_gas_cost) - receipt.gas_used; + receipt.logs = host.take_logs(); // Cannot put it into constructor call because logs are std::moved from host instance. receipt.logs_bloom_filter = compute_bloom_filter(receipt.logs); + receipt.state_diff = state.build_diff(rev); return receipt; } diff --git a/test/state/state.hpp b/test/state/state.hpp index 7240704fcc..af44bd9c29 100644 --- a/test/state/state.hpp +++ b/test/state/state.hpp @@ -143,8 +143,9 @@ 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. [[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 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 7a5cd431ae..82f42f8a89 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 { @@ -84,6 +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 = 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 70879aec1c..397c54ecab 100644 --- a/test/state/transaction.hpp +++ b/test/state/transaction.hpp @@ -101,8 +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 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; @@ -138,6 +143,14 @@ 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_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/CMakeLists.txt b/test/unittests/CMakeLists.txt index 3bfc1410a4..a1c9fa5e50 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_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.cpp b/test/unittests/state_transition.cpp index e80144986b..f4a7d713d4 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, 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; @@ -101,6 +102,8 @@ void state_transition::TearDown() << "log " << i << " topics"; } } + if (expect.state_gas.has_value()) + EXPECT_EQ(receipt.block_state_gas, *expect.state_gas); // Update default expectations - valid transaction means coinbase exists unless explicitly // requested otherwise if (!expect.post.contains(Coinbase)) diff --git a/test/unittests/state_transition.hpp b/test/unittests/state_transition.hpp index 90a263ffeb..8c47433913 100644 --- a/test/unittests/state_transition.hpp +++ b/test/unittests/state_transition.hpp @@ -71,6 +71,10 @@ class state_transition : public ExportableFixture /// exactly: count, address, data, topics, and order. std::optional> logs; + /// The expected EIP-8037 state-gas component of the receipt (`block_state_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..f587f18049 100644 --- a/test/unittests/state_transition_create_test.cpp +++ b/test/unittests/state_transition_create_test.cpp @@ -431,7 +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. - tx.gas_limit = 16'000'000; // Covers the ~13.1M code-deposit gas (200/byte). + 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. @@ -445,7 +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; - tx.gas_limit = 16'000'000; // Enough to deposit the code, so only the limit can reject it. + 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 new file mode 100644 index 0000000000..7839fe1ec5 --- /dev/null +++ b/test/unittests/state_transition_eip8037_state_gas_test.cpp @@ -0,0 +1,197 @@ +// evmone: Fast Ethereum Virtual Machine implementation +// Copyright 2026 The evmone Authors. +// SPDX-License-Identifier: Apache-2.0 + +#include "state_transition.hpp" +#include +#include +#include + +using namespace evmc::literals; +using namespace evmone::test; + +TEST_F(state_transition, eip8037_create_tx_collision_excess_reservoir_refunded) +{ + // 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); // The excess forms the reservoir. + + block.gas_limit = TX_GAS_LIMIT * 2; + 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; + + 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. + expect.status = EVMC_FAILURE; + expect.gas_used = state::MAX_TX_GAS_LIMIT; + expect.gas_refund = 0; + expect.state_gas = 0; + expect.post[create_address] = {.nonce = 1, .code = bytecode{OP_STOP}}; +} + +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; +} // 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; + constexpr auto TARGET = 0xbeef_address; // Absent from `pre`. + + pre[To] = {.code = call(TARGET).value(1).gas(0xffff) + OP_STOP}; // To cannot pay the value. + + 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; + constexpr auto TARGET = 0xbeef_address; + + 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].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 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, absent from `pre`. + tx.value = 1; + + constexpr int64_t IDENTITY_BASE_COST = 15; + + expect.status = EVMC_SUCCESS; + expect.post[*tx.to].balance = 1; + expect.gas_used = 21'000 + IDENTITY_BASE_COST + 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 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)}; + + // 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; +} + +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; + constexpr auto CLEARER = 0xdead_address; + pre[CLEARER] = {.code = sstore(1, 0)}; + pre[To] = {.code = sstore(1, 1) + delegatecall(CLEARER).gas(0xffff) + OP_STOP}; + + // 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; +} + +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 1db2b90934..0049424888 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, 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)) + 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)), + 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)); + 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)) - .execution_gas_limit, + block.gas_limit, 0, blob_gas_limit)) + .evm_gas, 39000); tx.blob_hashes[0] = 0x0200000000000000000000000000000000000000000000000000000000000001_bytes32; @@ -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, block.gas_limit, 0); 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); @@ -190,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); @@ -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, 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)), + state, block, tx, EVMC_CANCUN, block.gas_limit, 0, blob_gas_limit)), 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..6b05663e9e 100644 --- a/test/utils/block_transition.cpp +++ b/test/utils/block_transition.cpp @@ -49,8 +49,11 @@ TransitionResult apply_block(const TestState& state, evmc::VM& vm, const state:: std::vector receipts; int64_t block_gas_left = block.gas_limit; + int64_t block_state_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-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) @@ -62,8 +65,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, + block_state_gas_left, blob_gas_left); if (holds_alternative(res)) { @@ -78,11 +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); - // 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_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; blob_gas_left -= static_cast(tx.blob_gas_used()); receipts.emplace_back(std::move(receipt)); } @@ -114,6 +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-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/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..de0c228468 100644 --- a/test/utils/statetest_runner.cpp +++ b/test/utils/statetest_runner.cpp @@ -61,6 +61,7 @@ void run_state_test( const auto res = error ? error : transition(state, block, test.block_hashes, *tx, rev, vm, 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 0d3ac7a4ab..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 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); + const auto tx_props_or_error = state::validate_transaction( + 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 54330c756c..0dba2f20ea 100644 --- a/test/utils/test_state.hpp +++ b/test/utils/test_state.hpp @@ -72,7 +72,7 @@ class TestBlockHashes : public state::BlockHashes, public std::unordered_map transition(TestState& state, const state::BlockInfo& block, const state::BlockHashes& block_hashes, const state::Transaction& tx, evmc_revision rev, evmc::VM& vm, int64_t block_gas_left, - int64_t blob_gas_left); + int64_t 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,