diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c033ae09d59..dd34172fc23 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -29,12 +29,14 @@ Test fixtures for use by clients are available for each release on the [Github r - โœจ Add flexible API for absence checks for EIP-7928 (BAL) tests ([#2124](https://github.com/ethereum/execution-spec-tests/pull/2124)). - ๐Ÿž Use ``engine_newPayloadV5`` for `>=Amsterdam` forks in `consume engine` ([#2170](https://github.com/ethereum/execution-spec-tests/pull/2170)). - ๐Ÿ”€ Refactor EIP-7928 (BAL) absence checks into a friendlier class-based DevEx ([#2175](https://github.com/ethereum/execution-spec-tests/pull/2175)). +- ๐Ÿž Tighten up validation for empty lists on Block-Level Access List tests ([#2118](https://github.com/ethereum/execution-spec-tests/pull/2118)). ### ๐Ÿงช Test Cases - โœจ Add safe EIP-6110 workaround to allow Geth/Reth to pass invalid deposit request tests even thought they are out of spec ([#2177](https://github.com/ethereum/execution-spec-tests/pull/2177), [#2233](https://github.com/ethereum/execution-spec-tests/pull/2233)). - โœจ Add an EIP-7928 test case targeting the `SELFDESTRUCT` opcode. ([#2159](https://github.com/ethereum/execution-spec-tests/pull/2159)). - โœจ Add essential tests for coverage gaps in EIP-7951 (`p256verify` precompile) ([#2179](https://github.com/ethereum/execution-spec-tests/pull/2159), [#2203](https://github.com/ethereum/execution-spec-tests/pull/2203), [#2215](https://github.com/ethereum/execution-spec-tests/pull/2215), [#2216](https://github.com/ethereum/execution-spec-tests/pull/2216), [#2217](https://github.com/ethereum/execution-spec-tests/pull/2217), [#2218](https://github.com/ethereum/execution-spec-tests/pull/2218), [#2221](https://github.com/ethereum/execution-spec-tests/pull/2221), [#2229](https://github.com/ethereum/execution-spec-tests/pull/2229), [#2230](https://github.com/ethereum/execution-spec-tests/pull/2230), [#2237](https://github.com/ethereum/execution-spec-tests/pull/2237), [#2238](https://github.com/ethereum/execution-spec-tests/pull/2238)). +- โœจ Add EIP-7928 successful and OOG single-opcode tests ([#2118](https://github.com/ethereum/execution-spec-tests/pull/2118)). ## [v5.0.0](https://github.com/ethereum/execution-spec-tests/releases/tag/v5.0.0) - 2025-09-05 diff --git a/src/ethereum_test_types/block_access_list/expectations.py b/src/ethereum_test_types/block_access_list/expectations.py index 5a69d45e7dc..ddb3d19f496 100644 --- a/src/ethereum_test_types/block_access_list/expectations.py +++ b/src/ethereum_test_types/block_access_list/expectations.py @@ -305,6 +305,10 @@ def _compare_account_expectations( if field_name not in expected.model_fields_set: continue + # Check if explicitly set to empty but actual has values + if not expected_list and actual_list: + raise AssertionError(f"Expected {field_name} to be empty but found {actual_list}") + if field_name == "storage_reads": # storage_reads is a simple list of StorageKey actual_idx = 0 @@ -377,49 +381,39 @@ def _compare_account_expectations( else: # Handle nonce_changes, balance_changes, code_changes - if not expected_list and actual_list: - # Empty expected but non-empty actual - error - item_type = field_name.replace("_changes", "") - raise AssertionError( - f"Expected {field_name} to be empty but found {actual_list}" - ) - + # Create tuples for comparison (ordering already validated) + if field_name == "nonce_changes": + expected_tuples = [(c.tx_index, c.post_nonce) for c in expected_list] + actual_tuples = [(c.tx_index, c.post_nonce) for c in actual_list] + item_type = "nonce" + elif field_name == "balance_changes": + expected_tuples = [(c.tx_index, int(c.post_balance)) for c in expected_list] + actual_tuples = [(c.tx_index, int(c.post_balance)) for c in actual_list] + item_type = "balance" + elif field_name == "code_changes": + expected_tuples = [(c.tx_index, bytes(c.new_code)) for c in expected_list] + actual_tuples = [(c.tx_index, bytes(c.new_code)) for c in actual_list] + item_type = "code" else: - # Create tuples for comparison (ordering already validated) - if field_name == "nonce_changes": - expected_tuples = [(c.tx_index, c.post_nonce) for c in expected_list] - actual_tuples = [(c.tx_index, c.post_nonce) for c in actual_list] - item_type = "nonce" - elif field_name == "balance_changes": - expected_tuples = [ - (c.tx_index, int(c.post_balance)) for c in expected_list - ] - actual_tuples = [(c.tx_index, int(c.post_balance)) for c in actual_list] - item_type = "balance" - elif field_name == "code_changes": - expected_tuples = [(c.tx_index, bytes(c.new_code)) for c in expected_list] - actual_tuples = [(c.tx_index, bytes(c.new_code)) for c in actual_list] - item_type = "code" - else: - # sanity check - raise ValueError(f"Unexpected field type: {field_name}") - - # Check that expected forms a subsequence of actual - actual_idx = 0 - for exp_tuple in expected_tuples: - found = False - while actual_idx < len(actual_tuples): - if actual_tuples[actual_idx] == exp_tuple: - found = True - actual_idx += 1 - break + # sanity check + raise ValueError(f"Unexpected field type: {field_name}") + + # Check that expected forms a subsequence of actual + actual_idx = 0 + for exp_tuple in expected_tuples: + found = False + while actual_idx < len(actual_tuples): + if actual_tuples[actual_idx] == exp_tuple: + found = True actual_idx += 1 + break + actual_idx += 1 - if not found: - raise AssertionError( - f"{item_type.capitalize()} change {exp_tuple} not found " - f"or not in correct order. Actual changes: {actual_tuples}" - ) + if not found: + raise AssertionError( + f"{item_type.capitalize()} change {exp_tuple} not found " + f"or not in correct order. Actual changes: {actual_tuples}" + ) __all__ = [ diff --git a/src/ethereum_test_types/tests/test_block_access_lists.py b/src/ethereum_test_types/tests/test_block_access_lists.py index 20ad08b497e..40ab13c3e76 100644 --- a/src/ethereum_test_types/tests/test_block_access_lists.py +++ b/src/ethereum_test_types/tests/test_block_access_lists.py @@ -77,8 +77,11 @@ def test_empty_list_validation(): [ BalAccountChange( address=alice, - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], - balance_changes=[], # no balance changes + nonce_changes=[], + balance_changes=[], + code_changes=[], + storage_changes=[], + storage_reads=[], ), ] ) @@ -86,8 +89,11 @@ def test_empty_list_validation(): expectation = BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], - balance_changes=[], # explicitly expect no balance changes + nonce_changes=[], + balance_changes=[], + code_changes=[], + storage_changes=[], + storage_reads=[], ), } ) @@ -95,29 +101,38 @@ def test_empty_list_validation(): expectation.verify_against(actual_bal) -def test_empty_list_validation_fails(): +@pytest.mark.parametrize( + "field,value", + [ + ["nonce_changes", BalNonceChange(tx_index=1, post_nonce=1)], + ["balance_changes", BalBalanceChange(tx_index=1, post_balance=100)], + ["code_changes", BalCodeChange(tx_index=1, new_code=b"code")], + [ + "storage_changes", + BalStorageSlot( + slot=0x01, + slot_changes=[BalStorageChange(tx_index=1, post_value=0x42)], + ), + ], + ["storage_reads", 0x01], + ], +) +def test_empty_list_validation_fails(field: str, value) -> None: """Test that validation fails when expecting empty but field has values.""" alice = Address(0xA) - actual_bal = BlockAccessList( - [ - BalAccountChange( - address=alice, - balance_changes=[BalBalanceChange(tx_index=1, post_balance=100)], - ), - ] - ) + bal_acct_change = BalAccountChange(address=alice) + setattr(bal_acct_change, field, [value]) + actual_bal = BlockAccessList([bal_acct_change]) - expectation = BlockAccessListExpectation( - account_expectations={ - # expect no balance changes (wrongly) - alice: BalAccountExpectation(balance_changes=[]), - } - ) + alice_acct_expectation = BalAccountExpectation() + setattr(alice_acct_expectation, field, []) + + expectation = BlockAccessListExpectation(account_expectations={alice: alice_acct_expectation}) with pytest.raises( BlockAccessListValidationError, - match="Expected balance_changes to be empty", + match=f"Expected {field} to be empty", ): expectation.verify_against(actual_bal) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index b5e9e542142..a99b01e15ca 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -11,7 +11,6 @@ Block, BlockchainTestFiller, Initcode, - Storage, Transaction, compute_create_address, ) @@ -128,89 +127,6 @@ def test_bal_balance_changes( ) -def test_bal_storage_writes( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -): - """Ensure BAL captures storage writes.""" - storage = Storage({0x01: 0}) # type: ignore - storage_contract = pre.deploy_contract( - code=Op.SSTORE(0x01, 0x42) + Op.STOP, - # pre-fill with canary value to detect writes in post-state - storage=storage.canary(), - ) - alice = pre.fund_eoa() - - tx = Transaction( - sender=alice, - to=storage_contract, - gas_limit=100000, - ) - - block = Block( - txs=[tx], - expected_block_access_list=BlockAccessListExpectation( - account_expectations={ - storage_contract: BalAccountExpectation( - storage_changes=[ - BalStorageSlot( - slot=0x01, - slot_changes=[BalStorageChange(tx_index=1, post_value=0x42)], - ) - ], - ), - } - ), - ) - - blockchain_test( - pre=pre, - blocks=[block], - post={ - alice: Account(nonce=1), - storage_contract: Account(storage={0x01: 0x42}), - }, - ) - - -def test_bal_storage_reads( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -): - """Ensure BAL captures storage reads.""" - storage_contract = pre.deploy_contract( - code=Op.SLOAD(0x01) + Op.STOP, - storage={0x01: 0x42}, - ) - alice = pre.fund_eoa() - - tx = Transaction( - sender=alice, - to=storage_contract, - gas_limit=100000, - ) - - block = Block( - txs=[tx], - expected_block_access_list=BlockAccessListExpectation( - account_expectations={ - storage_contract: BalAccountExpectation( - storage_reads=[0x01], - ), - } - ), - ) - - blockchain_test( - pre=pre, - blocks=[block], - post={ - alice: Account(nonce=1), - storage_contract: Account(storage={0x01: 0x42}), - }, - ) - - def test_bal_code_changes( pre: Alloc, blockchain_test: BlockchainTestFiller, diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py new file mode 100644 index 00000000000..7f3aa2baa9d --- /dev/null +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -0,0 +1,533 @@ +""" +Tests for EIP-7928 Block Access Lists with single-opcode success and OOG +scenarios. + +Block access lists (BAL) are generated via a client's state tracing journal. +Residual journal entries may persist when opcodes run out of gas, resulting +in a bloated BAL payload. + +Issues identified in: +https://github.com/paradigmxyz/reth/issues/17765 +https://github.com/bluealloy/revm/pull/2903 + +These tests ensure out-of-gas operations are not recorded in BAL, +preventing consensus issues. +""" + +import pytest + +from ethereum_test_forks import Fork +from ethereum_test_tools import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Transaction, +) +from ethereum_test_tools import ( + Opcodes as Op, +) +from ethereum_test_types.block_access_list import ( + BalAccountExpectation, + BalStorageChange, + BalStorageSlot, + BlockAccessListExpectation, +) +from ethereum_test_vm import Bytecode + +from .spec import ref_spec_7928 + +REFERENCE_SPEC_GIT_PATH = ref_spec_7928.git_path +REFERENCE_SPEC_VERSION = ref_spec_7928.version + + +pytestmark = pytest.mark.valid_from("Amsterdam") + + +@pytest.mark.parametrize( + "fails_at_sstore", [True, False], ids=["oog_at_sstore", "successful_sstore"] +) +def test_bal_sstore_and_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + fails_at_sstore: bool, +): + """ + Ensure BAL handles SSTORE and OOG during SSTORE appropriately. + """ + alice = pre.fund_eoa() + gas_costs = fork.gas_costs() + + # Create contract that attempts SSTORE to cold storage slot 0x01 + storage_contract_code = Bytecode( + Op.PUSH1(0x42) # Value to store + + Op.PUSH1(0x01) # Storage slot (cold) + + Op.SSTORE # Store value in slot - this will OOG + + Op.STOP + ) + + storage_contract = pre.deploy_contract(code=storage_contract_code) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas_cost = intrinsic_gas_calculator() + + # Costs: + # - PUSH1 (value and slot) = G_VERY_LOW * 2 + # - SSTORE cold (to zero slot) = G_STORAGE_SET + G_COLD_SLOAD + sstore_cold_cost = gas_costs.G_STORAGE_SET + gas_costs.G_COLD_SLOAD + push_cost = gas_costs.G_VERY_LOW * 2 + tx_gas_limit = intrinsic_gas_cost + push_cost + sstore_cold_cost + + if fails_at_sstore: + # subtract 1 gas to ensure OOG at SSTORE + tx_gas_limit -= 1 + + tx = Transaction( + sender=alice, + to=storage_contract, + gas_limit=tx_gas_limit, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + storage_contract: BalAccountExpectation( + storage_changes=[] + if fails_at_sstore + else [ + BalStorageSlot( + slot=0x01, + slot_changes=[BalStorageChange(tx_index=1, post_value=0x42)], + ), + ] + ) + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + storage_contract: Account(storage={} if fails_at_sstore else {0x01: 0x42}), + }, + ) + + +@pytest.mark.parametrize( + "fails_at_sload", + [True, False], + ids=["oog_at_sload", "successful_sload"], +) +def test_bal_sload_and_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + fails_at_sload: bool, +): + """ + Ensure BAL handles SLOAD and OOG during SLOAD appropriately. + """ + alice = pre.fund_eoa() + gas_costs = fork.gas_costs() + + # Create contract that attempts SLOAD from cold storage slot 0x01 + storage_contract_code = Bytecode( + Op.PUSH1(0x01) # Storage slot (cold) + + Op.SLOAD # Load value from slot - this will OOG + + Op.STOP + ) + + storage_contract = pre.deploy_contract(code=storage_contract_code) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas_cost = intrinsic_gas_calculator() + + # Costs: + # - PUSH1 (slot) = G_VERY_LOW + # - SLOAD cold = G_COLD_SLOAD + push_cost = gas_costs.G_VERY_LOW + sload_cold_cost = gas_costs.G_COLD_SLOAD + tx_gas_limit = intrinsic_gas_cost + push_cost + sload_cold_cost + + # if fails_at_sload: + # # subtract 1 gas to ensure OOG at SLOAD + # tx_gas_limit -= 1 + + tx = Transaction( + sender=alice, + to=storage_contract, + gas_limit=tx_gas_limit, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + storage_contract: BalAccountExpectation( + storage_reads=[] if fails_at_sload else [0x01], + ) + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + storage_contract: Account(storage={}), + }, + ) + + +@pytest.mark.parametrize( + "fails_at_balance", [True, False], ids=["oog_at_balance", "successful_balance"] +) +def test_bal_balance_and_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + fails_at_balance: bool, +): + """Ensure BAL handles BALANCE and OOG during BALANCE appropriately.""" + alice = pre.fund_eoa() + bob = pre.fund_eoa() + gas_costs = fork.gas_costs() + + # Create contract that attempts to check Bob's balance + balance_checker_code = Bytecode( + Op.PUSH20(bob) # Bob's address + + Op.BALANCE # Check balance (cold access) + + Op.STOP + ) + + balance_checker = pre.deploy_contract(code=balance_checker_code) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas_cost = intrinsic_gas_calculator() + + # Costs: + # - PUSH20 = G_VERY_LOW + # - BALANCE cold = G_COLD_ACCOUNT_ACCESS + push_cost = gas_costs.G_VERY_LOW + balance_cold_cost = gas_costs.G_COLD_ACCOUNT_ACCESS + tx_gas_limit = intrinsic_gas_cost + push_cost + balance_cold_cost + + if fails_at_balance: + # subtract 1 gas to ensure OOG at BALANCE + tx_gas_limit -= 1 + + tx = Transaction( + sender=alice, + to=balance_checker, + gas_limit=tx_gas_limit, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + balance_checker: BalAccountExpectation(), + # Bob should only appear in BAL if BALANCE succeeded + **({} if fails_at_balance else {bob: BalAccountExpectation()}), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + bob: Account(), + balance_checker: Account(), + }, + ) + + +@pytest.mark.parametrize( + "fails_at_extcodesize", [True, False], ids=["oog_at_extcodesize", "successful_extcodesize"] +) +def test_bal_extcodesize_and_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + fails_at_extcodesize: bool, +): + """ + Ensure BAL handles EXTCODESIZE and OOG during EXTCODESIZE appropriately. + """ + alice = pre.fund_eoa() + gas_costs = fork.gas_costs() + + # Create target contract with some code + target_contract = pre.deploy_contract(code=Bytecode(Op.STOP)) + + # Create contract that checks target's code size + codesize_checker_code = Bytecode( + Op.PUSH20(target_contract) # Target contract address + + Op.EXTCODESIZE # Check code size (cold access) + + Op.STOP + ) + + codesize_checker = pre.deploy_contract(code=codesize_checker_code) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas_cost = intrinsic_gas_calculator() + + # Costs: + # - PUSH20 = G_VERY_LOW + # - EXTCODESIZE cold = G_COLD_ACCOUNT_ACCESS + push_cost = gas_costs.G_VERY_LOW + extcodesize_cold_cost = gas_costs.G_COLD_ACCOUNT_ACCESS + tx_gas_limit = intrinsic_gas_cost + push_cost + extcodesize_cold_cost + + if fails_at_extcodesize: + # subtract 1 gas to ensure OOG at EXTCODESIZE + tx_gas_limit -= 1 + + tx = Transaction( + sender=alice, + to=codesize_checker, + gas_limit=tx_gas_limit, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + codesize_checker: BalAccountExpectation(), + # Target should only appear if EXTCODESIZE succeeded + **({} if fails_at_extcodesize else {target_contract: BalAccountExpectation()}), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + codesize_checker: Account(), + target_contract: Account(), + }, + ) + + +@pytest.mark.parametrize("fails_at_call", [True, False], ids=["oog_at_call", "successful_call"]) +def test_bal_call_and_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + fails_at_call: bool, +): + """Ensure BAL handles CALL and OOG during CALL appropriately.""" + alice = pre.fund_eoa() + bob = pre.fund_eoa() + gas_costs = fork.gas_costs() + + # Create contract that attempts to call Bob + call_contract_code = Bytecode( + Op.PUSH1(0) # retSize + + Op.PUSH1(0) # retOffset + + Op.PUSH1(0) # argsSize + + Op.PUSH1(0) # argsOffset + + Op.PUSH1(0) # value + + Op.PUSH20(bob) # address + + Op.PUSH2(0xFFFF) # gas (provide enough for the call) + + Op.CALL # Call (cold account access) + + Op.STOP + ) + + call_contract = pre.deploy_contract(code=call_contract_code) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas_cost = intrinsic_gas_calculator() + + # Costs: + # - 7 PUSH operations = G_VERY_LOW * 7 + # - CALL cold = G_COLD_ACCOUNT_ACCESS (minimum for account access) + push_cost = gas_costs.G_VERY_LOW * 7 + call_cold_cost = gas_costs.G_COLD_ACCOUNT_ACCESS + tx_gas_limit = intrinsic_gas_cost + push_cost + call_cold_cost + + if fails_at_call: + # subtract 1 gas to ensure OOG at CALL + tx_gas_limit -= 1 + + tx = Transaction( + sender=alice, + to=call_contract, + gas_limit=tx_gas_limit, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + call_contract: BalAccountExpectation(), + # Bob should only appear if CALL succeeded + **({} if fails_at_call else {bob: BalAccountExpectation()}), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + call_contract: Account(), + }, + ) + + +@pytest.mark.parametrize( + "fails_at_delegatecall", [True, False], ids=["oog_at_delegatecall", "successful_delegatecall"] +) +def test_bal_delegatecall_and_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + fails_at_delegatecall: bool, +): + """ + Ensure BAL handles DELEGATECALL and OOG during DELEGATECALL + appropriately. + """ + alice = pre.fund_eoa() + gas_costs = fork.gas_costs() + + # Create target contract + target_contract = pre.deploy_contract(code=Bytecode(Op.STOP)) + + # Create contract that attempts delegatecall to target + delegatecall_contract_code = Bytecode( + Op.PUSH1(0) # retSize + + Op.PUSH1(0) # retOffset + + Op.PUSH1(0) # argsSize + + Op.PUSH1(0) # argsOffset + + Op.PUSH20(target_contract) # address + + Op.PUSH2(0xFFFF) # gas (provide enough for the call) + + Op.DELEGATECALL # Delegatecall (cold account access) + + Op.STOP + ) + + delegatecall_contract = pre.deploy_contract(code=delegatecall_contract_code) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas_cost = intrinsic_gas_calculator() + + # Costs: + # - 6 PUSH operations = G_VERY_LOW * 6 + # - DELEGATECALL cold = G_COLD_ACCOUNT_ACCESS + push_cost = gas_costs.G_VERY_LOW * 6 + delegatecall_cold_cost = gas_costs.G_COLD_ACCOUNT_ACCESS + tx_gas_limit = intrinsic_gas_cost + push_cost + delegatecall_cold_cost + + if fails_at_delegatecall: + # subtract 1 gas to ensure OOG at DELEGATECALL + tx_gas_limit -= 1 + + tx = Transaction( + sender=alice, + to=delegatecall_contract, + gas_limit=tx_gas_limit, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + delegatecall_contract: BalAccountExpectation(), + # Target should only appear if DELEGATECALL succeeded + **({} if fails_at_delegatecall else {target_contract: BalAccountExpectation()}), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + delegatecall_contract: Account(), + target_contract: Account(), + }, + ) + + +@pytest.mark.parametrize( + "fails_at_extcodecopy", [True, False], ids=["oog_at_extcodecopy", "successful_extcodecopy"] +) +def test_bal_extcodecopy_and_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + fails_at_extcodecopy: bool, +): + """ + Ensure BAL handles EXTCODECOPY and OOG during EXTCODECOPY appropriately. + """ + alice = pre.fund_eoa() + gas_costs = fork.gas_costs() + + # Create target contract with some code + target_contract = pre.deploy_contract(code=Bytecode(Op.PUSH1(0x42) + Op.STOP)) + + # Create contract that attempts to copy code from target + extcodecopy_contract_code = Bytecode( + Op.PUSH1(0) # size - copy 0 bytes to minimize memory expansion cost + + Op.PUSH1(0) # codeOffset + + Op.PUSH1(0) # destOffset + + Op.PUSH20(target_contract) # address + + Op.EXTCODECOPY # Copy code (cold access + base cost) + + Op.STOP + ) + + extcodecopy_contract = pre.deploy_contract(code=extcodecopy_contract_code) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas_cost = intrinsic_gas_calculator() + + # Costs: + # - 4 PUSH operations = G_VERY_LOW * 4 + # - EXTCODECOPY cold = G_COLD_ACCOUNT_ACCESS + G_COPY (base cost) + push_cost = gas_costs.G_VERY_LOW * 4 + extcodecopy_cold_cost = gas_costs.G_COLD_ACCOUNT_ACCESS + gas_costs.G_COPY + tx_gas_limit = intrinsic_gas_cost + push_cost + extcodecopy_cold_cost + + if fails_at_extcodecopy: + # subtract 1 gas to ensure OOG at EXTCODECOPY + tx_gas_limit -= 1 + + tx = Transaction( + sender=alice, + to=extcodecopy_contract, + gas_limit=tx_gas_limit, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + extcodecopy_contract: BalAccountExpectation(), + # Target should only appear if EXTCODECOPY succeeded + **({} if fails_at_extcodecopy else {target_contract: BalAccountExpectation()}), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + extcodecopy_contract: Account(), + target_contract: Account(), + }, + )