diff --git a/docs/writing_tests/test_markers.md b/docs/writing_tests/test_markers.md index 25fce189257..22c613ab498 100644 --- a/docs/writing_tests/test_markers.md +++ b/docs/writing_tests/test_markers.md @@ -203,6 +203,30 @@ def test_something_with_all_system_contracts( In this example, the test will be parameterized for parameter `system_contract` with value `[0x000F3DF6D732807EF1319FB7B8BB8522D0BEAC02]` for fork Cancun. +### `@pytest.mark.with_all_refund_types` + +This marker is used to automatically parameterize a test with all types of refunds that are valid for the fork being tested. + +Useful to mark tests to fail if a new refund type is introduced by a future fork and the test needs to be kept up to date and maintained. + +```python +import pytest + +from execution_testing import Address, Alloc, RefundTypes, StateTestFiller + +@pytest.mark.with_all_refund_types +@pytest.mark.valid_from("Prague") +def test_something_with_all_refund_types( + state_test: StateTestFiller, + pre: Alloc, + refund_type: RefundTypes, +): + pass + +``` + +In this example, the test will be parameterized for parameter `refund_type` with value `[RefundTypes.STORAGE_CLEAR, RefundTypes.AUTHORIZATION_EXISTING_AUTHORITY]` for fork Prague. + ### Covariant Marker Keyword Arguments All fork covariant markers accept the following keyword arguments: diff --git a/packages/testing/src/execution_testing/__init__.py b/packages/testing/src/execution_testing/__init__.py index ae04d86a2e0..e8a5b2e3211 100644 --- a/packages/testing/src/execution_testing/__init__.py +++ b/packages/testing/src/execution_testing/__init__.py @@ -30,7 +30,7 @@ TransactionException, ) from .fixtures import BaseFixture, FixtureCollector -from .forks import Fork, GasCosts, TransitionFork +from .forks import Fork, GasCosts, RefundTypes, TransitionFork from .specs import ( BaseTest, BenchmarkTest, @@ -185,6 +185,7 @@ "ParameterSet", "ReferenceSpec", "ReferenceSpecTypes", + "RefundTypes", "Removable", "Requests", "SequentialAddressLayout", diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py index 2dc7336f6e8..b10bc17c5ec 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py @@ -453,6 +453,15 @@ def covariant_decorator( fork_attribute_name="system_contracts", argnames=["system_contract"], ), + covariant_decorator( + marker_name="with_all_refund_types", + description=( + "marks a test to be parametrized for all refund types at " + "parameter named refund_type" + ), + fork_attribute_name="refund_types", + argnames=["refund_type"], + ), ] diff --git a/packages/testing/src/execution_testing/forks/__init__.py b/packages/testing/src/execution_testing/forks/__init__.py index 9dd5f0d4334..cd333c559e8 100644 --- a/packages/testing/src/execution_testing/forks/__init__.py +++ b/packages/testing/src/execution_testing/forks/__init__.py @@ -1,5 +1,6 @@ """Ethereum test fork definitions.""" +from .base_fork import RefundTypes from .forks.forks import ( BPO1, BPO2, @@ -87,6 +88,7 @@ "TransitionFork", "TransitionForkAdapter", "TransitionForkOrNoneAdapter", + "RefundTypes", "Amsterdam", "ArrowGlacier", "Berlin", diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index c074c3da840..419b27a915b 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -2,6 +2,7 @@ import re from abc import ABCMeta, abstractmethod +from enum import Enum, auto from typing import ( TYPE_CHECKING, Callable, @@ -168,6 +169,13 @@ def __call__( pass +class RefundTypes(Enum): + """Enum used to describe all refund types a fork can have.""" + + STORAGE_CLEAR = auto() + AUTHORIZATION_EXISTING_AUTHORITY = auto() + + class BaseForkMeta(ABCMeta): """Metaclass for BaseFork.""" @@ -979,6 +987,15 @@ def max_request_type(cls) -> int: """Return max request type supported by the fork.""" pass + @classmethod + @abstractmethod + def refund_types(cls) -> List[RefundTypes]: + """ + Return the list of refund types that are possible given current + fork logic. + """ + pass + # Meta information about the fork @classmethod def name(cls) -> str: diff --git a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7778.py b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7778.py new file mode 100644 index 00000000000..e7153a7fc8a --- /dev/null +++ b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7778.py @@ -0,0 +1,16 @@ +""" +EIP-7778: Block Gas Accounting without Refunds. + +Prevent Block Gas Limit Circumvention by Excluding Refunds from Block Gas +Accounting. + +https://eips.ethereum.org/EIPS/eip-7778 +""" + +from ....base_fork import BaseFork + + +class EIP7778(BaseFork): + """EIP-7778 class.""" + + pass diff --git a/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7702.py b/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7702.py index d19fd6009c1..424c4ab2dab 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7702.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7702.py @@ -15,6 +15,7 @@ from ....base_fork import ( BaseFork, + RefundTypes, TransactionIntrinsicCostCalculator, ) from ....gas_costs import GasCosts @@ -95,3 +96,12 @@ def fn( return intrinsic_cost return fn + + @classmethod + def refund_types(cls) -> List[RefundTypes]: + """ + At Prague, existing authorization refund is introduced. + """ + refunds = super(EIP7702, cls).refund_types() + refunds.append(RefundTypes.AUTHORIZATION_EXISTING_AUTHORITY) + return refunds diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index d1d6cd06143..454f3d8cabd 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -29,6 +29,7 @@ CalldataGasCalculator, ExcessBlobGasCalculator, MemoryExpansionGasCalculator, + RefundTypes, TransactionDataFloorCostCalculator, TransactionIntrinsicCostCalculator, ) @@ -1199,6 +1200,13 @@ def max_request_type(cls) -> int: """At genesis, no request type is supported, signaled by -1.""" return -1 + @classmethod + def refund_types(cls) -> List[RefundTypes]: + """ + At genesis, storage clearing refund is introduced. + """ + return [RefundTypes.STORAGE_CLEAR] + @classmethod def pre_allocation(cls) -> Mapping: """ diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 61ab40360fd..3313e8795f3 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -303,6 +303,8 @@ class Block(Header): """Post state for verification after block execution in BlockchainTest""" block_access_list: Bytes | None = Field(None) """EIP-7928: Block-level access lists (serialized).""" + expected_gas_used: int | None = None + """Expected gas used for the block.""" def set_environment(self, env: Environment) -> Environment: """ @@ -695,6 +697,14 @@ def generate_block_data( f"Verification of block {int(env.number)} failed" ) from e + if block.expected_gas_used is not None: + gas_used = int(transition_tool_output.result.gas_used) + assert gas_used == block.expected_gas_used, ( + f"gas_used ({gas_used}) does not match expected_gas_used " + f"({block.expected_gas_used})" + f", difference: {gas_used - block.expected_gas_used}" + ) + requests_list: List[Bytes] | None = None if fork.header_requests_required(): assert transition_tool_output.result.requests is not None, ( diff --git a/src/ethereum/forks/amsterdam/blocks.py b/src/ethereum/forks/amsterdam/blocks.py index f67d52adfc2..5a4e1b410f0 100644 --- a/src/ethereum/forks/amsterdam/blocks.py +++ b/src/ethereum/forks/amsterdam/blocks.py @@ -360,6 +360,7 @@ class Receipt: cumulative_gas_used: Uint """ Total gas used in the block up to and including this transaction. + This is the gas used after refunds, paid by the user. """ bloom: Bloom diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index bbb1d832193..0af84822dc8 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -654,7 +654,7 @@ def make_receipt( Error in the top level frame of the transaction, if any. cumulative_gas_used : The total gas used so far in the block after the transaction was - executed. + executed. This is the gas used after refunds. logs : The logs produced by the transaction. @@ -1039,16 +1039,17 @@ def process_transaction( # Transactions with less execution_gas_used than the floor pay at the # floor cost. - tx_gas_used_after_refund = max( - tx_gas_used_after_refund, calldata_floor_gas_cost + tx_gas_used = max(tx_gas_used_after_refund, calldata_floor_gas_cost) + block_gas_used_in_tx = max( + tx_gas_used_before_refund, calldata_floor_gas_cost ) - tx_gas_left = tx.gas - tx_gas_used_after_refund + tx_gas_left = tx.gas - tx_gas_used gas_refund_amount = tx_gas_left * effective_gas_price # For non-1559 transactions effective_gas_price == tx.gas_price priority_fee_per_gas = effective_gas_price - block_env.base_fee_per_gas - transaction_fee = tx_gas_used_after_refund * priority_fee_per_gas + transaction_fee = tx_gas_used * priority_fee_per_gas # refund gas sender_balance_after_refund = get_account(tx_state, sender).balance + U256( @@ -1090,11 +1091,15 @@ def process_transaction( ): destroy_account(tx_state, block_env.coinbase) - block_output.block_gas_used += tx_gas_used_after_refund + block_output.cumulative_gas_used += tx_gas_used + block_output.block_gas_used += block_gas_used_in_tx block_output.blob_gas_used += tx_blob_gas_used receipt = make_receipt( - tx, tx_output.error, block_output.block_gas_used, all_logs + tx, + tx_output.error, + block_output.cumulative_gas_used, + all_logs, ) receipt_key = rlp.encode(Uint(index)) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 2ae8d4c984c..ff523e2c33c 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -88,6 +88,7 @@ class BlockOutput: """ block_gas_used: Uint = Uint(0) + cumulative_gas_used: Uint = Uint(0) transactions_trie: Trie[Bytes, Optional[Bytes | LegacyTransaction]] = ( field(default_factory=lambda: Trie(secured=False, default=None)) ) diff --git a/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/__init__.py b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/__init__.py new file mode 100644 index 00000000000..fcecd89390c --- /dev/null +++ b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/__init__.py @@ -0,0 +1 @@ +"""Tests for [EIP-7778: Block Gas Accounting without Refunds](https://eips.ethereum.org/EIPS/eip-7778).""" diff --git a/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/eip_checklist_external_coverage.txt b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/eip_checklist_external_coverage.txt new file mode 100644 index 00000000000..eafb5d812a7 --- /dev/null +++ b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/eip_checklist_external_coverage.txt @@ -0,0 +1,2 @@ +general/code_coverage/eels = Please check https://app.codecov.io/gh/ethereum/execution-specs/pull/2840 for relevant test coverage +general/code_coverage/test_coverage = Please run the test with `--cov` flag for final coverage diff --git a/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/eip_checklist_not_applicable.txt b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/eip_checklist_not_applicable.txt new file mode 100644 index 00000000000..54926eee1f9 --- /dev/null +++ b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/eip_checklist_not_applicable.txt @@ -0,0 +1,17 @@ +opcode = EIP does not introduce a new opcode +precompile = EIP does not introduce a new precompile +removed_precompile = EIP does not remove a precompile +system_contract = EIP does not introduce a new system contract +transaction_type = EIP does not introduce a new transaction type +block_header_field = EIP does not add any new block header fields +block_body_field = EIP does not add any new block body fields +gas_cost_changes = EIP does not modify per-operation gas costs +gas_refunds_changes/test/refund_calculation/over = EIP does not change refund calculation, only block-level accounting +gas_refunds_changes/test/refund_calculation/exact = EIP does not change refund calculation, only block-level accounting +gas_refunds_changes/test/refund_calculation/under = EIP does not change refund calculation, only block-level accounting +gas_refunds_changes/test/exceptional_abort/revertable = EIP does not change refund behavior on revertable aborts +gas_refunds_changes/test/exceptional_abort/non_revertable = EIP does not change refund behavior on non-revertable aborts +blob_count_changes = EIP does not introduce any blob count changes +execution_layer_request = EIP does not introduce an execution layer request +new_transaction_validity_constraint = EIP does not introduce a new transaction validity constraint +modified_transaction_validity_constraint = EIP does not introduce a modified transaction validity constraint \ No newline at end of file diff --git a/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/test_gas_accounting.py b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/test_gas_accounting.py new file mode 100644 index 00000000000..27f373a1409 --- /dev/null +++ b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/test_gas_accounting.py @@ -0,0 +1,530 @@ +""" +Test cases for +[EIP-7778 Block Gas Accounting without Refunds](https://eips.ethereum.org/EIPS/eip-7778). +""" + +from enum import Enum +from typing import Set, Tuple + +import pytest +from execution_testing import ( + Account, + Alloc, + AuthorizationTuple, + Block, + BlockchainTestFiller, + BlockException, + Bytecode, + Environment, + Fork, + RefundTypes, + Transaction, + TransactionException, +) +from execution_testing.base_types import HashInt +from execution_testing.vm import Op + +REFERENCE_SPEC_GIT_PATH = "EIPS/eip-7778.md" +REFERENCE_SPEC_VERSION = "ce17d00b8341032a946301944124c4a6013032d6" + + +def build_refund_tx( + fork: Fork, + pre: Alloc, + post: Alloc, + refund_types: Set[RefundTypes], + refunds_count: int = 1, + refund_tx_reverts: bool = False, + call_data: bytes = b"", + refund_tx_has_extra_gas_limit: bool = False, + exceed_block_gas_limit: bool = False, +) -> Tuple[int, int, int, Transaction]: + """Build a transaction that has different refund types from a fork.""" + # All essential calc functions + intrinsic_cost_calc = fork.transaction_intrinsic_cost_calculator() + max_refund_quotient = fork.max_refund_quotient() + gsc = fork.gas_costs() + data_floor_calc = fork.transaction_data_floor_cost_calculator() + + # Initial account pre loading + initial_fund = 10**18 + refund_tx_sender = pre.fund_eoa(initial_fund) + + # Initialize other aspects of pre-alloc + code = Bytecode() + authorization_list = None + refund_counter = 0 + storage_slots = list(range(HashInt(refunds_count))) + + empty_storage_on_success = False + refund_tx_extra_gas = 1 if refund_tx_has_extra_gas_limit else 0 + + for refund_type in sorted(refund_types, key=lambda r: r.value): + match refund_type: + case RefundTypes.STORAGE_CLEAR: + for slot in storage_slots: + code += Op.SSTORE( + slot, + Op.PUSH0, + # Gas accounting + original_value=1, + new_value=0, + ) + empty_storage_on_success = True + + case RefundTypes.AUTHORIZATION_EXISTING_AUTHORITY: + code += Op.PUSH0 + delegated_contract = pre.deploy_contract(code=Bytecode()) + authorization_list = [ + AuthorizationTuple( + address=delegated_contract, + nonce=0, + signer=pre.fund_eoa(amount=1), + ) + for _ in range(refunds_count) + ] + refund_counter += ( + gsc.REFUND_AUTH_PER_EXISTING_ACCOUNT * refunds_count + ) + case _: + raise ValueError( + f"Unknown refund type: {refund_type} (Test needs update)" + ) + + if refund_tx_reverts: + code += Op.REVERT(0, 0) + + contract_address = pre.deploy_contract( + code=code, + storage=dict.fromkeys(storage_slots, 1), + ) + + gas_used_pre_refund = intrinsic_cost_calc( + calldata=call_data, + return_cost_deducted_prior_execution=True, + authorization_list_or_count=authorization_list, + ) + code.gas_cost(fork) + + # Calculate refund (still applied to user's balance) + if not refund_tx_reverts: + refund_counter += code.refund(fork) + + effective_refund = min( + refund_counter, gas_used_pre_refund // max_refund_quotient + ) + gas_used_post_refund = gas_used_pre_refund - effective_refund + call_data_floor_cost = data_floor_calc(data=call_data) + + refund_tx_block_gas_used = max(call_data_floor_cost, gas_used_pre_refund) + refund_tx_gas_used = max(call_data_floor_cost, gas_used_post_refund) + + # Build refund transaction + refund_tx = Transaction( + to=contract_address, + data=call_data, + gas_limit=refund_tx_block_gas_used + refund_tx_extra_gas, + sender=refund_tx_sender, + authorization_list=authorization_list, + expected_receipt={ + "gas_used": refund_tx_gas_used, + }, + ) + refund_tx_gas_price = ( + refund_tx.gas_price + if refund_tx.gas_price + else refund_tx.max_fee_per_gas + ) + + if ( + refund_tx_reverts + or exceed_block_gas_limit + or not empty_storage_on_success + ): + post[contract_address] = Account( + storage=dict.fromkeys(storage_slots, 1), + ) + else: + post[contract_address] = Account( + storage=dict.fromkeys(storage_slots, 0), + ) + + assert refund_tx_gas_price is not None, ( + "refund_tx_gas_price should not be None" + ) + expected_balance = initial_fund - ( + refund_tx_gas_used * refund_tx_gas_price + ) + + if not exceed_block_gas_limit: + post[refund_tx_sender] = Account(balance=expected_balance) + + return ( + gas_used_post_refund, + gas_used_pre_refund, + call_data_floor_cost, + refund_tx, + ) + + +@pytest.mark.parametrize( + "refund_tx_reverts", + [ + pytest.param(True, id="refund_tx_reverts"), + pytest.param(False, id=""), + ], +) +@pytest.mark.with_all_refund_types() +@pytest.mark.execute(pytest.mark.skip(reason="Requires specific gas price")) +@pytest.mark.valid_from("EIP7778") +def test_simple_gas_accounting( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + refund_type: RefundTypes, + refund_tx_reverts: bool, +) -> None: + """Test gas accounting for all refund types available in the given fork.""" + refunds_count = 10 + + post = Alloc() + + (_, gas_used_pre_refund, call_data_floor_cost, refund_tx) = ( + build_refund_tx( + fork=fork, + pre=pre, + post=post, + refund_types={refund_type}, + refunds_count=refunds_count, + refund_tx_reverts=refund_tx_reverts, + ) + ) + + refund_tx_block_gas_used = max(gas_used_pre_refund, call_data_floor_cost) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[refund_tx], + expected_gas_used=refund_tx_block_gas_used, + ) + ], + post=post, + ) + + +@pytest.mark.parametrize( + "refund_tx_reverts", + [ + pytest.param(True, id="refund_tx_reverts"), + pytest.param(False, id=""), + ], +) +@pytest.mark.parametrize( + "refund_tx_has_extra_gas_limit", + [ + pytest.param(True, id="refund_tx_has_extra_gas"), + pytest.param(False, id=""), + ], +) +@pytest.mark.parametrize( + "extra_tx_data_floor", + [ + pytest.param(True, id=""), + pytest.param(False, id="extra_tx_hits_data_floor"), + ], +) +@pytest.mark.parametrize( + "exceed_block_gas_limit", + [ + pytest.param(True, marks=pytest.mark.exception_test), + False, + ], +) +@pytest.mark.with_all_refund_types() +@pytest.mark.execute(pytest.mark.skip(reason="Requires specific gas price")) +@pytest.mark.valid_from("EIP7778") +def test_multi_transaction_gas_accounting( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + refund_type: RefundTypes, + refund_tx_has_extra_gas_limit: bool, + exceed_block_gas_limit: bool, + extra_tx_data_floor: bool, + refund_tx_reverts: bool, +) -> None: + """ + Test block gas accounting with refunds per EIP-7778. + + When exceed_block_gas_limit=True, we create a scenario where: + - Pre-refund gas (gas_used) > block_gas_limit - intrinsic_cost + (no room for another tx with correct EIP-7778 accounting) + - Post-refund gas (gas_spent) <= block_gas_limit - intrinsic_cost + (appears to have room with old refund-based accounting) + + This tests that clients correctly use pre-refund gas for block accounting. + """ + intrinsic_cost_calc = fork.transaction_intrinsic_cost_calculator() + + refunds_count = 10 + stop_bytecode = Op.STOP + stop_address = pre.deterministic_deploy_contract(deploy_code=stop_bytecode) + + post = Alloc() + ( + gas_used_post_refund, + gas_used_pre_refund, + call_data_floor_cost, + refund_tx, + ) = build_refund_tx( + fork=fork, + pre=pre, + post=post, + refund_types={refund_type}, + refunds_count=refunds_count, + refund_tx_reverts=refund_tx_reverts, + call_data=b"", + refund_tx_has_extra_gas_limit=refund_tx_has_extra_gas_limit, + exceed_block_gas_limit=exceed_block_gas_limit, + ) + refund_tx_gas_used = max(gas_used_post_refund, call_data_floor_cost) + refund_tx_block_gas_used = max(gas_used_pre_refund, call_data_floor_cost) + + extra_tx_sender = pre.fund_eoa() + extra_tx_calldata = b"\xff" if extra_tx_data_floor else b"" + extra_tx_intrinsic_gas_cost = intrinsic_cost_calc( + calldata=extra_tx_calldata + ) + + extra_tx = Transaction( + to=stop_address, + data=extra_tx_calldata, + gas_limit=extra_tx_intrinsic_gas_cost, + sender=extra_tx_sender, + expected_receipt={ + "gas_used": refund_tx_gas_used + extra_tx_intrinsic_gas_cost, + }, + error=TransactionException.GAS_ALLOWANCE_EXCEEDED + if exceed_block_gas_limit + else None, + ) + + total_block_gas_used = ( + refund_tx_block_gas_used + extra_tx_intrinsic_gas_cost + ) + if exceed_block_gas_limit: + environment_gas_limit = total_block_gas_used - 1 + else: + environment_gas_limit = total_block_gas_used + + txs = [refund_tx, extra_tx] + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=txs, + exception=BlockException.GAS_USED_OVERFLOW + if exceed_block_gas_limit + else None, + expected_gas_used=total_block_gas_used + if not exceed_block_gas_limit + else None, + gas_limit=environment_gas_limit, + ) + ], + post=post, + genesis_environment=Environment(gas_limit=environment_gas_limit), + ) + + +class CallDataTestType(Enum): + """Refund test type.""" + + DATA_FLOOR_LT_TX_GAS_AFTER_REFUND = -1 + """ + calldata_floor < tx_gas_after_refund. + """ + DATA_FLOOR_BETWEEN_TX_GAS_BEFORE_AND_AFTER = 0 + """ + tx_gas_after_refund < calldata_floor < tx_gas_before_refund. + """ + DATA_FLOOR_GT_TX_GAS_BEFORE_REFUND = 1 + """calldata_floor > tx_gas_before_refund.""" + + +@pytest.mark.parametrize( + "refund_tx_reverts", + [ + pytest.param(True, id="refund_tx_reverts"), + pytest.param(False, id=""), + ], +) +@pytest.mark.parametrize( + "calldata_test_type", + [ + CallDataTestType.DATA_FLOOR_LT_TX_GAS_AFTER_REFUND, + CallDataTestType.DATA_FLOOR_BETWEEN_TX_GAS_BEFORE_AND_AFTER, + CallDataTestType.DATA_FLOOR_GT_TX_GAS_BEFORE_REFUND, + ], +) +@pytest.mark.with_all_refund_types() +@pytest.mark.valid_from("EIP7778") +def test_varying_calldata_costs( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + refund_type: RefundTypes, + refund_tx_reverts: bool, + calldata_test_type: CallDataTestType, +) -> None: + """ + Test by varying the calldata_floor_cost. + + Performs tests for the following 3 scenarios. + + 1. calldata_floor < tx_gas_after_refund + 2. tx_gas_after_refund < calldata_floor < tx_gas_before_refund + 3. calldata_floor > tx_gas_before_refund + """ + if refund_type == RefundTypes.STORAGE_CLEAR: + if ( + refund_tx_reverts + and calldata_test_type + == CallDataTestType.DATA_FLOOR_BETWEEN_TX_GAS_BEFORE_AND_AFTER + ): + pytest.skip( + "calldata_cost cannot be between pre and post refund gas" + "since refund is zero when execution reverts" + ) + + match refund_type: + case RefundTypes.STORAGE_CLEAR: + bytes_to_add_per_iteration = b"00" * 2 + case RefundTypes.AUTHORIZATION_EXISTING_AUTHORITY: + bytes_to_add_per_iteration = b"00" * 10 + case _: + raise ValueError( + f"Unknown refund type: {refund_type} (Test needs update)" + ) + + data = b"" + + # Time to start searching for appropriate call data for each scenario + num_iterations = 200 + # Currently in EIP-7778, the optimal call data is found in about + # 30 iterations for CallDataTestType.DATA_FLOOR_GT_TX_GAS_BEFORE_REFUND. + # Setting this higher just to make it + # a bit more future proof if the gas calc logic changes + found_call_data = False + for _ in range(num_iterations): + post = Alloc() + + ( + gas_used_post_refund, + gas_used_pre_refund, + call_data_floor_cost, + refund_tx, + ) = build_refund_tx( + fork=fork, + pre=pre, + post=post, + refund_types={refund_type}, + refund_tx_reverts=refund_tx_reverts, + call_data=data, + ) + + if ( + calldata_test_type + == CallDataTestType.DATA_FLOOR_LT_TX_GAS_AFTER_REFUND + ): + if call_data_floor_cost < gas_used_post_refund: + found_call_data = True + break + elif ( + calldata_test_type + == CallDataTestType.DATA_FLOOR_BETWEEN_TX_GAS_BEFORE_AND_AFTER + ): + if ( + gas_used_post_refund + < call_data_floor_cost + < gas_used_pre_refund + ): + found_call_data = True + break + elif ( + calldata_test_type + == CallDataTestType.DATA_FLOOR_GT_TX_GAS_BEFORE_REFUND + ): + if gas_used_pre_refund < call_data_floor_cost: + found_call_data = True + break + else: + raise ValueError("Invalid calldata test type") + + data += bytes_to_add_per_iteration + + if not found_call_data: + raise ValueError( + f"Could not find the call_data with {num_iterations} iterations." + ) + + refund_tx_block_gas_used = max(call_data_floor_cost, gas_used_pre_refund) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[refund_tx], + expected_gas_used=refund_tx_block_gas_used, + ) + ], + post=post, + ) + + +@pytest.mark.parametrize( + "refund_tx_reverts", + [ + pytest.param(True, id="refund_tx_reverts"), + pytest.param(False, id=""), + ], +) +@pytest.mark.execute(pytest.mark.skip(reason="Requires specific gas price")) +@pytest.mark.valid_from("Amsterdam") +def test_multiple_refund_types_in_one_tx( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + refund_tx_reverts: bool, +) -> None: + """Test gas accounting for all refund types available in the given fork.""" + refunds_count = 10 + + post = Alloc() + refund_types = set(fork.refund_types()) + + (_, gas_used_pre_refund, call_data_floor_cost, refund_tx) = ( + build_refund_tx( + fork=fork, + pre=pre, + post=post, + refund_types=refund_types, + refunds_count=refunds_count, + refund_tx_reverts=refund_tx_reverts, + ) + ) + + refund_tx_block_gas_used = max(gas_used_pre_refund, call_data_floor_cost) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[refund_tx], + expected_gas_used=refund_tx_block_gas_used, + ) + ], + post=post, + ) diff --git a/tests/benchmark/compute/scenario/test_transaction_types.py b/tests/benchmark/compute/scenario/test_transaction_types.py index b23d3180d22..5d4c3d1b002 100644 --- a/tests/benchmark/compute/scenario/test_transaction_types.py +++ b/tests/benchmark/compute/scenario/test_transaction_types.py @@ -612,11 +612,18 @@ def test_auth_transaction( ) ) + # EIP-7778: refunds no longer reduce block-level gas accounting + expected_gas_usage = ( + total_gas_used + if fork.is_eip_enabled(7778) + else total_gas_used - total_refund + ) + benchmark_test( pre=pre, post={}, blocks=[Block(txs=txs)], - expected_benchmark_gas_used=total_gas_used - total_refund, + expected_benchmark_gas_used=expected_gas_usage, )