diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml new file mode 100644 index 00000000000..73f4e0a9de0 --- /dev/null +++ b/.markdownlint-cli2.yaml @@ -0,0 +1,4 @@ +ignores: + - EIP8037_REMAINING_FAILURES.md + - EIP8037_PORTED_STATIC_FAILURES.md + - EIP8037_IMPLEMENTATION.md diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/pre_alloc.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/pre_alloc.py index 7a7d5b81660..2027bdc0b43 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/pre_alloc.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/pre_alloc.py @@ -595,6 +595,11 @@ def _fund_eoa( # Send a transaction to fund the EOA fund_tx: PendingTransaction | None = None if delegation is not None or storage is not None: + fork = self._fork.fork_at( + block_number=self._block_number, timestamp=self._timestamp + ) + intrinsic_calc = fork.transaction_intrinsic_cost_calculator() + if storage is not None: if not isinstance(storage, Storage): storage = Storage.model_validate(storage) @@ -602,14 +607,24 @@ def _fund_eoa( f"Deploying storage contract for EOA {eoa} " f"with {len(storage)} storage slots" ) - sstore_address = self.deploy_contract( - code=( - sum( - Op.SSTORE(key, value) - for key, value in storage.items() + + storage_init_code = ( + sum( + Op.SSTORE( + key, + value, + # gas accounting + key_warm=False, + original_value=0, + current_value=0, + new_value=1, ) - + Op.STOP + for key, value in storage.items() ) + + Op.STOP + ) + sstore_address = self.deploy_contract( + code=storage_init_code, ) logger.debug( f"Storage contract deployed at {sstore_address} " @@ -629,7 +644,11 @@ def _fund_eoa( signer=eoa, ), ], - gas_limit=100_000, + gas_limit=( + intrinsic_calc(authorization_list_or_count=1) + + storage_init_code.gas_cost(fork) + + 500_000 + ), ) eoa.nonce = Number(eoa.nonce + 1) @@ -654,7 +673,7 @@ def _fund_eoa( signer=eoa, ), ], - gas_limit=100_000, + gas_limit=(intrinsic_calc(authorization_list_or_count=1)), ) eoa.nonce = Number(eoa.nonce + 1) else: @@ -672,7 +691,7 @@ def _fund_eoa( signer=eoa, ), ], - gas_limit=100_000, + gas_limit=intrinsic_calc(authorization_list_or_count=1), ) eoa.nonce = Number(eoa.nonce + 1) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index b66db851f67..5c1cf567321 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -66,6 +66,7 @@ FixtureBlockBase, FixtureConfig, FixtureEngineNewPayload, + FixtureExecutionPayloadModifier, FixtureHeader, FixtureTransaction, FixtureWithdrawal, @@ -395,6 +396,7 @@ class BuiltBlock(CamelModel): result: Result expected_exception: BLOCK_EXCEPTION_TYPE = None engine_api_error_code: EngineAPIError | None = None + rlp_modifier: Header | None = None fork: Fork block_access_list: BlockAccessList | None @@ -447,6 +449,36 @@ def get_block_rlp(self) -> Bytes: """Get the RLP of the block.""" return self.get_fixture_block().rlp + @staticmethod + def derive_engine_payload_modifier( + rlp_modifier: Header | None, + block_access_list: BlockAccessList | None, + ) -> "FixtureExecutionPayloadModifier | None": + """ + Propagate ``rlp_modifier``'s header changes to the engine payload. + + The engine ``ExecutionPayload`` schema does not carry + ``block_access_list_hash`` directly; the equivalent payload field is + the ``block_access_list`` body. So a header modifier that touches the + BAL hash needs to drive a matching change on the payload body. + """ + if rlp_modifier is None: + return None + bal_hash_override = rlp_modifier.block_access_list_hash + if bal_hash_override is None: + return None + if bal_hash_override is Header.REMOVE_FIELD: + return FixtureExecutionPayloadModifier( + block_access_list=( + FixtureExecutionPayloadModifier.REMOVE_FIELD + ), + ) + if block_access_list is None: + return FixtureExecutionPayloadModifier( + block_access_list=Bytes(b""), + ) + return None + def get_fixture_engine_new_payload(self) -> FixtureEngineNewPayload: """Get a FixtureEngineNewPayload from the built block.""" return FixtureEngineNewPayload.from_fixture_header( @@ -458,6 +490,9 @@ def get_fixture_engine_new_payload(self) -> FixtureEngineNewPayload: block_access_list=self.block_access_list.rlp if self.block_access_list else None, + execution_payload_modifier=self.derive_engine_payload_modifier( + self.rlp_modifier, self.block_access_list + ), validation_error=self.expected_exception, error_code=self.engine_api_error_code, ) diff --git a/packages/testing/src/execution_testing/tools/tests/test_iterating_bytecode.py b/packages/testing/src/execution_testing/tools/tests/test_iterating_bytecode.py index 4c5309bfbbf..6e82db00bad 100644 --- a/packages/testing/src/execution_testing/tools/tests/test_iterating_bytecode.py +++ b/packages/testing/src/execution_testing/tools/tests/test_iterating_bytecode.py @@ -325,7 +325,8 @@ def test_tx_iterations_by_total_iteration_count_raises_on_impossible() -> None: with pytest.raises( ValueError, - match="Single iteration gas cost is greater than gas limit.", + match="Single iteration gas cost exceeds gas_limit " + "or compute_gas_limit.", ): list( bytecode.tx_iterations_by_total_iteration_count( diff --git a/packages/testing/src/execution_testing/tools/tools_code/generators.py b/packages/testing/src/execution_testing/tools/tools_code/generators.py index 3e82c18bd0f..9d15d425e59 100644 --- a/packages/testing/src/execution_testing/tools/tools_code/generators.py +++ b/packages/testing/src/execution_testing/tools/tools_code/generators.py @@ -806,6 +806,10 @@ class IteratingBytecode(Bytecode): """ cleanup: Bytecode """Bytecode executed once at the end after all iterations complete.""" + iterating_state_gas: int + """ + State-gas portion (EIP-8037) charged per loop iteration. + """ def __new__( cls, @@ -815,6 +819,7 @@ def __new__( cleanup: Bytecode | None = None, warm_iterating: Bytecode | None = None, iterating_subcall: Bytecode | int | None = None, + iterating_state_gas: int = 0, ) -> Self: """ Create a new iterating bytecode. @@ -833,6 +838,8 @@ def __new__( calculation. The value can also be an integer, in which case it represents the gas cost of the subcall (e.g. the subcall is a precompiled contract). + iterating_state_gas: EIP-8037 state-gas portion charged + per iteration, defaults to 0. Returns: A new IteratingBytecode instance. @@ -860,6 +867,7 @@ def __new__( if cleanup is None: cleanup = Bytecode() instance.cleanup = cleanup + instance.iterating_state_gas = iterating_state_gas return instance def iterating_subcall_gas_cost( @@ -985,60 +993,85 @@ def tx_gas_limit_by_iteration_count( **intrinsic_cost_kwargs, ) + self.iterating_subcall_reserve(fork=fork) + def _iterations_fit_within_gas_limits( + self, + *, + fork: Fork, + iteration_count: int, + start_iteration: int, + gas_limit: int, + compute_gas_limit: int | None = None, + **intrinsic_cost_kwargs: Any, + ) -> bool: + """ + Check whether iteration_count iterations fit within the gas limits. + + Returns True when both: + - The combined regular+state gas (i.e. tx.gas) is <= + gas_limit (block-budget constraint). + - The regular gas, computed as + combined - iteration_count * iterating_state_gas, + respects the compute_gas_limit. + """ + if iteration_count <= 0: + return True + combined = self.tx_gas_limit_by_iteration_count( + fork=fork, + iteration_count=iteration_count, + start_iteration=start_iteration, + **intrinsic_cost_kwargs, + ) + if combined > gas_limit: + return False + if compute_gas_limit is not None: + compute = combined - iteration_count * self.iterating_state_gas + if compute > compute_gas_limit: + return False + return True + def _binary_search_iterations( self, *, fork: Fork, gas_limit: int, start_iteration: int, + compute_gas_limit: int | None = None, **intrinsic_cost_kwargs: Any, ) -> Tuple[int, int]: """ Binary search for the maximum iterations that fit within a gas limit. """ - single_iteration_gas = self.tx_gas_limit_by_iteration_count( - fork=fork, - iteration_count=1, - start_iteration=start_iteration, + fits_kwargs: Dict[str, Any] = { + "fork": fork, + "start_iteration": start_iteration, + "gas_limit": gas_limit, + "compute_gas_limit": compute_gas_limit, **intrinsic_cost_kwargs, - ) - if single_iteration_gas > gas_limit: + } + + if not self._iterations_fit_within_gas_limits( + iteration_count=1, **fits_kwargs + ): raise ValueError( - "Single iteration gas cost is greater than gas limit." + "Single iteration gas cost exceeds gas_limit " + "or compute_gas_limit." ) + low = 1 high = 2 # Exponential search to find upper bound - high_gas_cost = self.tx_gas_limit_by_iteration_count( - fork=fork, - iteration_count=high, - start_iteration=start_iteration, - **intrinsic_cost_kwargs, - ) - while high_gas_cost < gas_limit: + while self._iterations_fit_within_gas_limits( + iteration_count=high, **fits_kwargs + ): low = high high *= 2 - high_gas_cost = self.tx_gas_limit_by_iteration_count( - fork=fork, - iteration_count=high, - start_iteration=start_iteration, - **intrinsic_cost_kwargs, - ) # Binary search for exact fit - best_iterations = 0 while low < high: mid = (low + high) // 2 - - if ( - self.tx_gas_limit_by_iteration_count( - fork=fork, - iteration_count=mid, - start_iteration=start_iteration, - **intrinsic_cost_kwargs, - ) - > gas_limit + if not self._iterations_fit_within_gas_limits( + iteration_count=mid, **fits_kwargs ): high = mid else: @@ -1082,17 +1115,11 @@ def tx_iterations_by_gas_limit( start_iteration=start_iteration, **intrinsic_cost_kwargs, ): - # Binary search for the maximum number of iterations that fits - # within remaining_gas - max_gas_limit = ( - min(remaining_gas, gas_limit_cap) - if gas_limit_cap is not None - else remaining_gas - ) best_iterations, best_iterations_gas = ( self._binary_search_iterations( fork=fork, - gas_limit=max_gas_limit, + gas_limit=remaining_gas, + compute_gas_limit=gas_limit_cap, start_iteration=start_iteration, **intrinsic_cost_kwargs, ) @@ -1142,6 +1169,7 @@ def tx_iterations_by_total_iteration_count( best_iterations, _ = self._binary_search_iterations( fork=fork, gas_limit=gas_limit_cap, + compute_gas_limit=gas_limit_cap, start_iteration=start_iteration, **intrinsic_cost_kwargs, ) diff --git a/src/ethereum/forks/amsterdam/blocks.py b/src/ethereum/forks/amsterdam/blocks.py index 550d17b491c..302533da837 100644 --- a/src/ethereum/forks/amsterdam/blocks.py +++ b/src/ethereum/forks/amsterdam/blocks.py @@ -257,13 +257,6 @@ class Header: [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 [cbalh]: ref:ethereum.forks.amsterdam.block_access_lists.hash_block_access_list """ # noqa: E501 - slot_number: U64 - """ - The slot number of this block as provided by the consensus layer. - Introduced in [EIP-7843]. - - [EIP-7843]: https://eips.ethereum.org/EIPS/eip-7843 - """ slot_number: U64 """ diff --git a/tests/benchmark/stateful/bloatnet/test_single_opcode.py b/tests/benchmark/stateful/bloatnet/test_single_opcode.py index 6f97d3ff8ec..a80756be67f 100644 --- a/tests/benchmark/stateful/bloatnet/test_single_opcode.py +++ b/tests/benchmark/stateful/bloatnet/test_single_opcode.py @@ -9,7 +9,7 @@ from enum import Enum, auto from functools import partial -from typing import Generator, List +from typing import Any, Callable, Generator, List import pytest from execution_testing import ( @@ -93,6 +93,7 @@ def _sender_generator( def delegate_with_calldata( pre: Alloc, + fork: Fork, authority: EOA, address: Address, calldata: Hash, @@ -103,8 +104,13 @@ def delegate_with_calldata( The delegated code determines what happens with the calldata. The authority nonce is incremented in-place. """ + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + calldata=bytes(calldata), + authorization_list_or_count=1, + ) + gas_limit = intrinsic_gas + 500_000 tx = Transaction( - gas_limit=100_000, + gas_limit=gas_limit, to=authority, value=0, data=calldata, @@ -133,12 +139,10 @@ def run_bloated_eoa_benchmark( existing_slots: bool, runtime_code: Bytecode, cache_strategy: CacheStrategy, + tx_generator: Callable[[EOA], list[Transaction]] | None = None, ) -> None: """ Run a bloated-EOA benchmark with the given runtime delegation code. - - Handles authority setup, slot 0 initialization, delegation to - runtime code, benchmark tx generation, and test invocation. """ slot_0_value = Hash(1) if existing_slots else Hash(START_SLOT) @@ -146,30 +150,41 @@ def run_bloated_eoa_benchmark( runtime_address = pre.deploy_contract(code=runtime_code) init_tx = delegate_with_calldata( - pre, authority, setter_address, slot_0_value + pre, + fork, + authority, + setter_address, + slot_0_value, ) runtime_tx = delegate_with_calldata( - pre, authority, runtime_address, Hash(0) + pre, + fork, + authority, + runtime_address, + Hash(0), ) blocks: list[Block] = [Block(txs=[init_tx, runtime_tx])] - gas_available = gas_benchmark_value - intrinsic_gas = fork.transaction_intrinsic_cost_calculator()() sender = pre.fund_eoa() txs: list[Transaction] = [] with TestPhaseManager.execution(): - while gas_available >= intrinsic_gas: - tx_gas = min(gas_available, tx_gas_limit) - txs.append( - Transaction( - gas_limit=tx_gas, - to=authority, - sender=sender, + if tx_generator is not None: + txs = tx_generator(sender) + else: + gas_available = gas_benchmark_value + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()() + while gas_available >= intrinsic_gas: + tx_gas = min(gas_available, tx_gas_limit) + txs.append( + Transaction( + gas_limit=tx_gas, + to=authority, + sender=sender, + ) ) - ) - gas_available -= tx_gas + gas_available -= tx_gas cache_txs: list[Transaction] = [] if cache_strategy == CacheStrategy.CACHE_PREVIOUS_BLOCK: @@ -179,6 +194,7 @@ def run_bloated_eoa_benchmark( cache_txs.append( Transaction( gas_limit=tx.gas_limit, + data=tx.data, to=authority, sender=cache_sender, ) @@ -197,7 +213,7 @@ def run_bloated_eoa_benchmark( @pytest.mark.repricing @pytest.mark.stub_parametrize("token_name", "bloated_eoa_") @pytest.mark.parametrize("existing_slots", [False, True]) -@pytest.mark.parametrize("cache_strategy", list(CacheStrategy)) +@pytest.mark.parametrize("cache_strategy", [CacheStrategy.NO_CACHE]) def test_sload_bloated( benchmark_test: BenchmarkTestFiller, pre: Alloc, @@ -309,7 +325,11 @@ def test_sload_bloated_prefetch_miss( # forcing the prefetcher's pre-block snapshot to disagree with # the actual slot 0 value seen by every max-gas tx that follows. delegation_tx = delegate_with_calldata( - pre, authority, runtime_address, Hash(0) + pre, + fork, + authority, + runtime_address, + Hash(0), ) blocks: list[Block] = [Block(txs=[delegation_tx])] @@ -578,7 +598,7 @@ def test_sload_bloated_multi_contract( @pytest.mark.stub_parametrize("token_name", "bloated_eoa_") @pytest.mark.parametrize("write_new_value", [False, True]) @pytest.mark.parametrize("existing_slots", [True, False]) -@pytest.mark.parametrize("cache_strategy", list(CacheStrategy)) +@pytest.mark.parametrize("cache_strategy", [CacheStrategy.NO_CACHE]) def test_sstore_bloated( benchmark_test: BenchmarkTestFiller, pre: Alloc, @@ -592,73 +612,119 @@ def test_sstore_bloated( ) -> None: """ Benchmark SSTORE opcodes targeting an EOA with storage bloated. - - The storage is assumed to be filled from 0-N linearly, where - each slot has the value of the key. Except slot 0, this is the - pointer to the next free (empty) storage slot. - - For this test to work correctly under all parameters then above - has to be true. If this is not the case then some tests will not - test what they claim to do. For instance, for `write_new_value` - set to False we need to know the current value of the slots. """ + sstore_metadata: dict[str, Any] = {} + # If CACHE_TX, there would be one cold SLOAD before SSTORE + sstore_metadata["key_warm"] = cache_strategy == CacheStrategy.CACHE_TX + + # SSTORE metadata matrix: + # + # existing_slots | write_new_value | original | current | new + # ---------------+-----------------+----------+---------+----- + # True | True | 1 | 1 | 2 + # True | False | 1 | 1 | 1 + # False | True | 0 | 0 | 1 + # False | False | 0 | 0 | 0 + + initial_value = int(existing_slots) + + # When existing_slots is False, the initial value is always 0 + # Otherwise, the initial value starts at 1 instead. + sstore_metadata["original_value"] = initial_value + sstore_metadata["current_value"] = initial_value + + # If not writing a new value, the new value is the same as the current one + # If writing a new value, the new value is current value + 1 + sstore_metadata["new_value"] = ( + initial_value if not write_new_value else initial_value + 1 + ) + setup = ( - Op.PUSH0 # [0] - + Op.SLOAD # [key], s[0] = key - + Op.DUP1 # [key, key] + Op.CALLDATALOAD(32) # [end_slot] + + Op.CALLDATALOAD(0) # [counter, end_slot] ) - if write_new_value: - setup += ( - Op.PUSH1(1) # [1, key, key] - + Op.ADD # [key+1, key] - + Op.SWAP1 # [key, key+1] - ) + # stack element: [counter, end_slot] - # After setup phase, the stack element represents - # [slot, value], slot to write and value to write + loop = Bytecode() + loop += Op.JUMPDEST # jump target - cache_op = Bytecode() + # If CACHE_TX, warm the slot with a cold SLOAD before the SSTORE loop if cache_strategy == CacheStrategy.CACHE_TX: - cache_op = ( - Op.DUP1 # [slot, slot, value] - + Op.SLOAD # [s[slot], slot, value] - + Op.POP # [slot, value] + loop += Op.POP(Op.SLOAD(Op.DUP1, key_warm=False)) + + sstore_op: Bytecode = Bytecode() + if write_new_value: + # s[counter] = counter + 1 + sstore_op = ( + Op.DUP1 # [counter, counter, end_slot] + + Op.DUP1 # [counter, counter, counter, end_slot] + + Op.PUSH1(1) # [1, counter, counter, counter, end_slot] + + Op.ADD # [counter+1, counter, counter, end_slot] + + Op.SWAP1 # [counter, counter+1, counter, end_slot] + + Op.SSTORE(**sstore_metadata) # [counter, end_slot] + ) + else: + # s[counter] = counter (existing slot) or 0 (non existing slot) + push_value = Op.DUP1 if existing_slots else Op.PUSH1(0) + sstore_op = ( + push_value # [value, counter, end_slot] + + Op.DUP2 # [counter, value, counter, end_slot] + + Op.SSTORE(**sstore_metadata) # [counter, end_slot] ) - # The cache mechanism touches the slot before SSTORE + loop += sstore_op - runtime_code = ( - setup - + While( - body=( - cache_op # [slot, value] - + Op.DUP2 # [value, slot, value] - + Op.DUP2 # [slot, value, slot, value] - + Op.SSTORE # [slot, value], s[slot] = value - + Op.PUSH1(1) # [1, slot, value] - + Op.ADD # [slot+1, value] - + Op.SWAP1 # [value, slot+1] - + Op.PUSH1(1) # [1, value, slot+1] - + Op.ADD # [value+1, slot+1] - + Op.SWAP1 # [slot+1, value+1] - ), - condition=Op.GT(Op.GAS, 0xFFFF), - ) - + Op.PUSH0 # [0, slot+1, value+1] - + Op.SSTORE # s[0] = slot+1 + # stack element: [counter, end_slot] + + loop += ( + Op.PUSH1(1) # [1, counter, end_slot] + + Op.ADD # [counter+1, end_slot] + + Op.DUP2 # [end_slot, counter+1, end_slot] + + Op.DUP2 # [counter+1, end_slot, counter+1, end_slot] + + Op.LT # [counter+1 bytes: + return Hash(start_iteration) + Hash(start_iteration + iteration_count) + + def tx_generator(sender: EOA) -> list[Transaction]: + return list( + runtime_code.transactions_by_gas_limit( + fork=fork, + gas_limit=gas_benchmark_value, + sender=sender, + to=authority, + start_iteration=start_slot, + calldata=calldata_gen, + ) + ) + run_bloated_eoa_benchmark( benchmark_test=benchmark_test, pre=pre, fork=fork, gas_benchmark_value=gas_benchmark_value, tx_gas_limit=tx_gas_limit, - authority=pre.stub_eoa(token_name), + authority=authority, existing_slots=existing_slots, runtime_code=runtime_code, cache_strategy=cache_strategy, + tx_generator=tx_generator, ) @@ -1510,7 +1576,7 @@ class AccountMode(Enum): @pytest.mark.repricing -@pytest.mark.parametrize("cache_strategy", list(CacheStrategy)) +@pytest.mark.parametrize("cache_strategy", [CacheStrategy.NO_CACHE]) @pytest.mark.parametrize( "opcode,value_sent,account_mode", account_access_params() ) diff --git a/tests/benchmark/stateful/bloatnet/test_transaction_types.py b/tests/benchmark/stateful/bloatnet/test_transaction_types.py index 875c69fecdf..171c12a9e08 100644 --- a/tests/benchmark/stateful/bloatnet/test_transaction_types.py +++ b/tests/benchmark/stateful/bloatnet/test_transaction_types.py @@ -16,13 +16,14 @@ Transaction, compute_create2_address, compute_create_address, + keccak256, ) # Deterministic sender pool of 15K accounts. # Funded via system contract withdrawals (funding.txt) in payload generation. # Placed outside pre-allocation to ensure accounts remain uncached. -SENDER_BASE_KEY = ( - 0x1111111111111111111111111111111111111111111111111111111111111111 +SENDER_BASE_KEY = int.from_bytes( + keccak256(b"gas-repricings-private-key"), "big" ) diff --git a/tests/benchmark/stateful/stubs/stubs_jochemnet.json b/tests/benchmark/stateful/stubs/stubs_jochemnet.json new file mode 100644 index 00000000000..a52d9d9b7de --- /dev/null +++ b/tests/benchmark/stateful/stubs/stubs_jochemnet.json @@ -0,0 +1,6 @@ +{ + "bloated_eoa_10GB": { + "addr": "0x87a6314da5ac8832f6e7a176c8fb133b19f5be04", + "pkey": "0x4da32d29f6dcffa26e09dc4e102033f2d105de1444fb893493ae703289275e0e" + } +}