From ea5ed46e3407afd1589a935dbba42d9bac163c8a Mon Sep 17 00:00:00 2001 From: mw2000 Date: Fri, 1 May 2026 00:02:35 -0700 Subject: [PATCH] refactor(test-support): unify tx execution between StateTest and BlockchainTest runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #127. Both runners had a near-identical validate → bump-nonce → execute → settle-fees pipeline. This commit extracts that pipeline into EEVM.TestSupport.TxExecutor and rewrites both runners on top of it. Net effect: - test/support/state_test_runner.ex: 181 → 80 lines - test/support/blockchain_test_runner.ex: 362 → 295 lines - test/support/tx_executor.ex: new, 105 lines One source of truth means CREATE support, blob-base-fee handling, and fee settlement edge cases can no longer drift between the two runners. StateTestRunner now goes through EEVM.Handler.Execution.run_top_level instead of EEVM.execute, picking up CREATE support that the BlockchainTest path already had. Co-Authored-By: Claude Opus 4.7 --- test/support/blockchain_test_runner.ex | 97 +++--------------- test/support/state_test_runner.ex | 131 +++---------------------- test/support/tx_executor.ex | 105 ++++++++++++++++++++ 3 files changed, 135 insertions(+), 198 deletions(-) create mode 100644 test/support/tx_executor.ex diff --git a/test/support/blockchain_test_runner.ex b/test/support/blockchain_test_runner.ex index 3a26cfd..dad91e1 100644 --- a/test/support/blockchain_test_runner.ex +++ b/test/support/blockchain_test_runner.ex @@ -26,14 +26,13 @@ defmodule EEVM.TestSupport.BlockchainTestRunner do alias EEVM.Context.{Block, Transaction} alias EEVM.Database alias EEVM.Database.InMemory - alias EEVM.Handler.Execution alias EEVM.HardforkConfig alias EEVM.StateRoot alias EEVM.SystemContracts.{BeaconRoots, BlockHashes} alias EEVM.TestSupport.BlockchainHeaderValidator, as: HeaderValidator alias EEVM.TestSupport.BlockchainTestFixture.{Account, Case, TransactionFields, Withdrawal} alias EEVM.TestSupport.BlockchainTestFixture.Block, as: FixtureBlock - alias EEVM.Transaction.{IntrinsicGas, Validator} + alias EEVM.TestSupport.TxExecutor @min_blob_base_fee 1 @@ -222,95 +221,29 @@ defmodule EEVM.TestSupport.BlockchainTestRunner do defp tx_executor(%Config{} = config, header) do fn %Transaction{} = tx, %Block{} = block_ctx, db -> block_ctx_with_blob = put_blob_base_fee(block_ctx, header) - execute_transaction(tx, db, block_ctx_with_blob, config) - end - end - - # Until full EIP-4844 fake-exponential blob-fee math lands, set blob_base_fee - # to its post-Cancun floor of 1 wei whenever the header carries blob fields, - # and leave it at 0 for older blocks. Non-blob fixtures don't read this. - defp put_blob_base_fee(%Block{} = block_ctx, header) do - blob_base_fee = if header.excess_blob_gas == nil, do: 0, else: @min_blob_base_fee - %{block_ctx | blob_base_fee: blob_base_fee} - end - - defp execute_transaction(%Transaction{} = tx, db, %Block{} = block_ctx, %Config{} = config) do - case Validator.validate(tx, db, block_ctx, config.hardfork) do - :ok -> - db_after_nonce = Database.increment_nonce(db, tx.origin) - - with {:ok, machine} <- run_interpreter(tx, db_after_nonce, block_ctx, config), - {:ok, finalized_db} <- finalize_fees(db_after_nonce, machine, tx, block_ctx) do - failed = failed_status?(machine.status) - gas_used = tx.gas_limit - machine.frame.gas + case TxExecutor.execute(tx, db, block_ctx_with_blob, config) do + {:ok, %{db: db, machine: machine, gas_used: gas_used, failed?: failed?}} -> {:ok, %{ - status: if(failed, do: 0, else: 1), + status: if(failed?, do: 0, else: 1), gas_used: gas_used, - logs: if(failed, do: [], else: machine.substate.logs), - db: finalized_db + logs: if(failed?, do: [], else: machine.substate.logs), + db: db }} - end - - {:error, reason} -> - {:error, {:validation, reason}} - end - end - - defp run_interpreter(%Transaction{} = tx, db, %Block{} = block_ctx, %Config{} = config) do - intrinsic_gas = IntrinsicGas.calculate(tx) - execution_gas = max(tx.gas_limit - intrinsic_gas, 0) - {machine, _new_address} = Execution.run_top_level(tx, db, block_ctx, config, execution_gas) - {:ok, machine} - end - - defp finalize_fees(db_after_nonce, machine, tx, %Block{} = block_ctx) do - failed = failed_status?(machine.status) - settled = if failed, do: db_after_nonce, else: machine.db - gas_used = tx.gas_limit - machine.frame.gas - apply_fees(settled, tx, block_ctx, gas_used) - end - defp apply_fees(db, tx, block_ctx, gas_used) do - sender_fee = gas_used * effective_gas_price(tx, block_ctx) - miner_reward = gas_used * miner_reward_per_gas(tx, block_ctx) - - with {:ok, debited} <- debit_balance(db, tx.origin, sender_fee) do - {:ok, credit_balance(debited, block_ctx.coinbase, miner_reward)} + {:error, _} = err -> + err + end end end - defp effective_gas_price(%Transaction{type: type} = tx, %Block{} = block_ctx) - when type in [:eip1559, :eip4844] do - min(tx.max_fee_per_gas, block_ctx.basefee + tx.max_priority_fee_per_gas) - end - - defp effective_gas_price(%Transaction{} = tx, _block_ctx), do: tx.gasprice - - defp miner_reward_per_gas(%Transaction{type: type} = tx, %Block{} = block_ctx) - when type in [:eip1559, :eip4844] do - max(effective_gas_price(tx, block_ctx) - block_ctx.basefee, 0) - end - - defp miner_reward_per_gas(%Transaction{} = tx, %Block{} = block_ctx) do - max(tx.gasprice - block_ctx.basefee, 0) - end - - defp failed_status?(status) when status in [:reverted, :invalid, :out_of_gas], do: true - defp failed_status?({:error, _}), do: true - defp failed_status?(_), do: false - - defp debit_balance(db, _addr, 0), do: {:ok, db} - - defp debit_balance(db, addr, amount) do - current = Database.get_balance(db, addr) - - if current < amount do - {:error, :insufficient_balance} - else - {:ok, Database.set_balance(db, addr, current - amount)} - end + # Until full EIP-4844 fake-exponential blob-fee math lands, set blob_base_fee + # to its post-Cancun floor of 1 wei whenever the header carries blob fields, + # and leave it at 0 for older blocks. Non-blob fixtures don't read this. + defp put_blob_base_fee(%Block{} = block_ctx, header) do + blob_base_fee = if header.excess_blob_gas == nil, do: 0, else: @min_blob_base_fee + %{block_ctx | blob_base_fee: blob_base_fee} end defp credit_balance(db, _addr, 0), do: db diff --git a/test/support/state_test_runner.ex b/test/support/state_test_runner.ex index 5b152c4..ca5f958 100644 --- a/test/support/state_test_runner.ex +++ b/test/support/state_test_runner.ex @@ -1,12 +1,10 @@ defmodule EEVM.TestSupport.StateTestRunner do @moduledoc "Executes a single StateTest case and validates the post-state root and logs hash." - alias EEVM.{Config, Database, StateRoot} - alias EEVM.Context.Contract + alias EEVM.{Config, StateRoot} alias EEVM.Database.InMemory - alias EEVM.TestSupport.StateTestFixture + alias EEVM.TestSupport.{StateTestFixture, TxExecutor} alias EEVM.TestSupport.StateTestFixture.{Case, PostExpectation} - alias EEVM.Transaction.{IntrinsicGas, Validator} @empty_logs_hash Base.decode16!( "1DCC4DE8DEC75D7AAB85B567B6CCD41AD312451B948A7413F0A142FD40D49347", @@ -17,99 +15,23 @@ defmodule EEVM.TestSupport.StateTestRunner do db = InMemory.new(accounts: state_test.pre_accounts, storage: state_test.pre_storage) tx = StateTestFixture.build_transaction(state_test, expectation) block = StateTestFixture.block(state_test.env) - - case execute_transaction(tx, db, block, expectation.hardfork) do - {:ok, result} -> validate_success(result, expectation) - {:error, reason} -> validate_failure(reason, expectation) - end - end - - defp execute_transaction(tx, db, block, hardfork) do - case Validator.validate(tx, db, block, Config.new(hardfork).hardfork) do - :ok -> - db_after_nonce = Database.increment_nonce(db, tx.origin) - - with {:ok, db_for_execution} <- apply_top_level_value(db_after_nonce, tx), - {:ok, machine} <- run_top_level(tx, db_for_execution, block, hardfork), - {:ok, finalized_db} <- finalize_fees(db_after_nonce, machine, tx, block) do - failed = failed_status?(machine.status) - - {:ok, - %{ - state_root: StateRoot.compute_state_root(finalized_db), - logs_hash: logs_hash(if(failed, do: [], else: machine.substate.logs)), - output: if(failed, do: <<>>, else: machine.frame.return_data), - status: machine.status, - gas_used: tx.gas_limit - machine.frame.gas, - db: finalized_db - }} - end + config = Config.new(expectation.hardfork) + + case TxExecutor.execute(tx, db, block, config) do + {:ok, %{db: db, machine: machine, failed?: failed?}} -> + validate_success( + %{ + state_root: StateRoot.compute_state_root(db), + logs_hash: logs_hash(if(failed?, do: [], else: machine.substate.logs)) + }, + expectation + ) {:error, reason} -> - {:error, {:validation, reason}} - end - end - - defp run_top_level(%{to: nil}, _db, _block, _hardfork), do: {:error, :unsupported_creation} - - defp run_top_level(tx, db, block, hardfork) do - intrinsic_gas = IntrinsicGas.calculate(tx) - execution_gas = tx.gas_limit - intrinsic_gas - code = Database.get_code(db, tx.to) - - machine = - EEVM.execute(code, - gas: execution_gas, - db: db, - tx: tx, - block: block, - contract: - Contract.new(address: tx.to, caller: tx.origin, callvalue: tx.value, calldata: tx.data), - config: Config.new(hardfork) - ) - - {:ok, machine} - end - - defp apply_top_level_value(db, %{to: nil}), do: {:ok, db} - defp apply_top_level_value(db, %{value: 0}), do: {:ok, db} - - defp apply_top_level_value(db, %{origin: origin, to: to, value: value}), - do: Database.transfer(db, origin, to, value) - - defp settle_state(db_after_nonce, _machine, true), do: db_after_nonce - defp settle_state(_db_after_nonce, machine, false), do: machine.db - - defp finalize_fees(db_after_nonce, machine, tx, block) do - failed = failed_status?(machine.status) - settled_db = settle_state(db_after_nonce, machine, failed) - gas_used = tx.gas_limit - machine.frame.gas - apply_fees(settled_db, tx, block, gas_used) - end - - defp apply_fees(db, tx, block, gas_used) do - sender_fee = gas_used * effective_gas_price(tx, block) - miner_reward = gas_used * miner_reward_per_gas(tx, block) - - with {:ok, debited_db} <- debit_balance(db, tx.origin, sender_fee) do - {:ok, credit_balance(debited_db, block.coinbase, miner_reward)} + validate_failure(reason, expectation) end end - defp effective_gas_price(%{type: type} = tx, block) when type in [:eip1559, :eip4844] do - min(tx.max_fee_per_gas, block.basefee + tx.max_priority_fee_per_gas) - end - - defp effective_gas_price(tx, _block), do: tx.gasprice - - defp miner_reward_per_gas(%{type: type} = tx, block) when type in [:eip1559, :eip4844] do - max(effective_gas_price(tx, block) - block.basefee, 0) - end - - defp miner_reward_per_gas(tx, block) do - max(tx.gasprice - block.basefee, 0) - end - defp validate_success(_result, %PostExpectation{expect_exception: exception}) when is_binary(exception) and exception != "" do {:error, {:expected_exception, exception}} @@ -128,18 +50,13 @@ defmodule EEVM.TestSupport.StateTestRunner do end end - defp validate_failure(reason, %PostExpectation{expect_exception: exception}) + defp validate_failure(_reason, %PostExpectation{expect_exception: exception}) when is_binary(exception) and exception != "" do - _ = reason :ok end defp validate_failure(reason, _expectation), do: {:error, reason} - defp failed_status?(status) when status in [:reverted, :invalid, :out_of_gas], do: true - defp failed_status?({:error, _}), do: true - defp failed_status?(_), do: false - defp logs_hash([]), do: @empty_logs_hash defp logs_hash(logs) do @@ -160,22 +77,4 @@ defmodule EEVM.TestSupport.StateTestRunner do true -> binary_part(encoded, byte_size(encoded) - bytes, bytes) end end - - defp debit_balance(db, _address, 0), do: {:ok, db} - - defp debit_balance(db, address, amount) do - current_balance = Database.get_balance(db, address) - - if current_balance < amount do - {:error, :insufficient_balance} - else - {:ok, Database.set_balance(db, address, current_balance - amount)} - end - end - - defp credit_balance(db, _address, 0), do: db - - defp credit_balance(db, address, amount) do - Database.set_balance(db, address, Database.get_balance(db, address) + amount) - end end diff --git a/test/support/tx_executor.ex b/test/support/tx_executor.ex new file mode 100644 index 0000000..2f34840 --- /dev/null +++ b/test/support/tx_executor.ex @@ -0,0 +1,105 @@ +defmodule EEVM.TestSupport.TxExecutor do + @moduledoc """ + Shared transaction-execution pipeline for the StateTest and BlockchainTest + runners. + + Performs the steps a real client folds into block processing: + validate → bump nonce → run the interpreter top-level → settle gas fees + (sender debit, coinbase credit). Each runner wraps the result for its own + post-state assertions. + """ + + alias EEVM.{Config, Database} + alias EEVM.Context.{Block, Transaction} + alias EEVM.Handler.Execution + alias EEVM.Interpreter.MachineState + alias EEVM.Transaction.{IntrinsicGas, Validator} + + @type result :: %{ + db: Database.t(), + machine: MachineState.t(), + gas_used: non_neg_integer(), + failed?: boolean() + } + + @spec execute(Transaction.t(), Database.t(), Block.t(), Config.t()) :: + {:ok, result()} | {:error, term()} + def execute(%Transaction{} = tx, %Database{} = db, %Block{} = block, %Config{} = config) do + with :ok <- validate(tx, db, block, config) do + db_after_nonce = Database.increment_nonce(db, tx.origin) + machine = run_interpreter(tx, db_after_nonce, block, config) + failed? = failed?(machine.status) + settled = if failed?, do: db_after_nonce, else: machine.db + gas_used = tx.gas_limit - machine.frame.gas + + with {:ok, finalized_db} <- apply_fees(settled, tx, block, gas_used) do + {:ok, %{db: finalized_db, machine: machine, gas_used: gas_used, failed?: failed?}} + end + end + end + + defp validate(%Transaction{} = tx, %Database{} = db, %Block{} = block, %Config{} = config) do + case Validator.validate(tx, db, block, config.hardfork) do + :ok -> :ok + {:error, reason} -> {:error, {:validation, reason}} + end + end + + defp run_interpreter( + %Transaction{} = tx, + %Database{} = db, + %Block{} = block, + %Config{} = config + ) do + intrinsic_gas = IntrinsicGas.calculate(tx) + execution_gas = max(tx.gas_limit - intrinsic_gas, 0) + {machine, _new_address} = Execution.run_top_level(tx, db, block, config, execution_gas) + machine + end + + defp apply_fees(db, %Transaction{} = tx, %Block{} = block, gas_used) do + sender_fee = gas_used * effective_gas_price(tx, block) + miner_reward = gas_used * miner_reward_per_gas(tx, block) + + with {:ok, debited} <- debit_balance(db, tx.origin, sender_fee) do + {:ok, credit_balance(debited, block.coinbase, miner_reward)} + end + end + + defp effective_gas_price(%Transaction{type: type} = tx, %Block{} = block) + when type in [:eip1559, :eip4844] do + min(tx.max_fee_per_gas, block.basefee + tx.max_priority_fee_per_gas) + end + + defp effective_gas_price(%Transaction{} = tx, _block), do: tx.gasprice + + defp miner_reward_per_gas(%Transaction{type: type} = tx, %Block{} = block) + when type in [:eip1559, :eip4844] do + max(effective_gas_price(tx, block) - block.basefee, 0) + end + + defp miner_reward_per_gas(%Transaction{} = tx, %Block{} = block) do + max(tx.gasprice - block.basefee, 0) + end + + defp failed?(status) when status in [:reverted, :invalid, :out_of_gas], do: true + defp failed?({:error, _}), do: true + defp failed?(_), do: false + + defp debit_balance(db, _addr, 0), do: {:ok, db} + + defp debit_balance(db, addr, amount) do + current = Database.get_balance(db, addr) + + if current < amount do + {:error, :insufficient_balance} + else + {:ok, Database.set_balance(db, addr, current - amount)} + end + end + + defp credit_balance(db, _addr, 0), do: db + + defp credit_balance(db, addr, amount), + do: Database.set_balance(db, addr, Database.get_balance(db, addr) + amount) +end