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 index fd47cebd69b..52b06e9ff7f 100644 --- 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 @@ -2830,122 +2830,190 @@ def test_bal_create_selfdestruct_to_self_with_call( ) +@pytest.mark.with_all_create_opcodes +@pytest.mark.parametrize( + "modification", + ["collision_only", "then_nonce_change", "then_storage_change"], +) @pytest.mark.pre_alloc_mutable() -def test_bal_create2_collision( +def test_bal_create_collision( pre: Alloc, blockchain_test: BlockchainTestFiller, + fork: Fork, + create_opcode: Op, + modification: str, ) -> None: """ - Test BAL with CREATE2 collision against pre-existing contract. - - Pre-existing contract has code=STOP, nonce=1. - Factory (nonce=1, slot[0]=0xDEAD) executes CREATE2 targeting it. - - Expected BAL: - - Factory: nonce_changes (1→2), storage_changes slot 0 (0xDEAD→0) - - Collision address: empty (accessed during collision check) - - Collision address MUST NOT have nonce_changes or code_changes + BAL with CREATE/CREATE2 collision against pre-existing contract X, + optionally followed by a tx that modifies X via call (closes #2914 + nonce/storage axes). Balance axis is already covered by the suite's + existing collision-then-value-transfer tests. The `code_changes` + axis isn't reachable in forward order — see + `test_bal_create2_deploy_then_collision`. """ alice = pre.fund_eoa() + bob = pre.fund_eoa() - # Init code that deploys simple STOP contract - init_code = Initcode(deploy_code=Op.STOP) + # Storage-touching init: a client that wrongly runs init on the + # collision leaks a slot-0 access into X's BAL. + init_code = Initcode( + deploy_code=Op.STOP, + initcode_prefix=Op.SSTORE(0, Op.ADD(Op.SLOAD(0), 1)), + ) init_code_bytes = bytes(init_code) - # Factory code: CREATE2 and store result in slot 0 factory_code = ( - # Push init code to memory Op.MSTORE(0, Op.PUSH32(init_code_bytes)) - # SSTORE(0, CREATE2(...)) - stores CREATE2 result in slot 0 + Op.SSTORE( 0x00, - Op.CREATE2( + create_opcode( value=0, offset=32 - len(init_code_bytes), size=len(init_code_bytes), - salt=0, ), ) + Op.STOP ) - - # Deploy factory - it starts with nonce=1 by default factory = pre.deploy_contract( code=factory_code, - storage={0x00: 0xDEAD}, # Initial value to prove SSTORE works + storage={0x00: 0xDEAD}, ) - # Calculate the CREATE2 target address collision_address = compute_create_address( address=factory, nonce=1, salt=0, initcode=init_code_bytes, - opcode=Op.CREATE2, + opcode=create_opcode, ) - # Set up the collision by pre-populating the target address - # This contract has code (STOP) and nonce=1, causing collision - pre[collision_address] = Account( - code=Op.STOP, - nonce=1, - ) + if modification == "collision_only": + x_code: Bytecode = Op.STOP + elif modification == "then_nonce_change": + inner_init = Initcode(deploy_code=Op.STOP) + x_code = ( + Op.MSTORE(0, Op.PUSH32(bytes(inner_init))) + + Op.CREATE(0, 32 - len(inner_init), len(inner_init)) + + Op.STOP + ) + elif modification == "then_storage_change": + x_code = Op.SSTORE(0x01, 0xCAFE) + Op.STOP + else: + raise ValueError(f"unknown modification: {modification}") - tx = Transaction( - sender=alice, - to=factory, - gas_limit=1_000_000, - ) + pre[collision_address] = Account(code=x_code, nonce=1) - block = Block( - txs=[tx], - expected_block_access_list=BlockAccessListExpectation( - account_expectations={ - alice: BalAccountExpectation( - nonce_changes=[ - BalNonceChange(block_access_index=1, post_nonce=1) - ], - ), - factory: BalAccountExpectation( - # Nonce incremented 1→2 even on failed CREATE2 - nonce_changes=[ - BalNonceChange(block_access_index=1, post_nonce=2) + tx_gas_limit = fork.transaction_gas_limit_cap() + txs = [Transaction(sender=alice, to=factory, gas_limit=tx_gas_limit)] + + account_expectations: dict = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + ), + # Factory's nonce bumps even on failed CREATE/CREATE2; slot 0 + # records the failure return value (0). + factory: BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=2)], + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange(block_access_index=1, post_value=0) ], - # Storage changes: slot 0 = 0xDEAD → 0 (CREATE2 returned 0) - storage_changes=[ - BalStorageSlot( - slot=0x00, - slot_changes=[ - BalStorageChange( - block_access_index=1, post_value=0 - ) - ], + ) + ], + ), + } + + post: dict = { + alice: Account(nonce=1), + factory: Account(nonce=2, storage={0x00: 0}), + } + + if modification == "collision_only": + account_expectations[collision_address] = BalAccountExpectation.empty() + post[collision_address] = Account( + code=x_code, nonce=1, balance=0, storage={} + ) + elif modification == "then_nonce_change": + txs.append( + Transaction( + sender=bob, to=collision_address, gas_limit=tx_gas_limit + ) + ) + account_expectations[bob] = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=2, post_nonce=1)], + ) + # Strict: only the inner-CREATE nonce bump appears; no spurious + # code/storage/balance entries from the index-1 collision touch. + account_expectations[collision_address] = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=2, post_nonce=2)], + balance_changes=[], + code_changes=[], + storage_changes=[], + storage_reads=[], + ) + # Inner CREATE deploys at addr(X, 1). + inner_created = compute_create_address( + address=collision_address, nonce=1 + ) + account_expectations[inner_created] = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=2, post_nonce=1)], + code_changes=[ + BalCodeChange(block_access_index=2, new_code=bytes(Op.STOP)) + ], + balance_changes=[], + storage_changes=[], + storage_reads=[], + ) + post[bob] = Account(nonce=1) + post[collision_address] = Account( + code=x_code, nonce=2, balance=0, storage={} + ) + post[inner_created] = Account( + nonce=1, code=bytes(Op.STOP), balance=0, storage={} + ) + elif modification == "then_storage_change": + txs.append( + Transaction( + sender=bob, to=collision_address, gas_limit=tx_gas_limit + ) + ) + account_expectations[bob] = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=2, post_nonce=1)], + ) + # Strict: only the SSTORE slot appears; no spurious other entries. + account_expectations[collision_address] = BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange( + block_access_index=2, post_value=0xCAFE ) ], - ), - # Collision address: empty (accessed but no state changes) - # Explicitly verify ALL fields are empty - collision_address: BalAccountExpectation( - nonce_changes=[], # MUST NOT have nonce changes - balance_changes=[], # MUST NOT have balance changes - code_changes=[], # MUST NOT have code changes - storage_changes=[], # MUST NOT have storage changes - storage_reads=[], # MUST NOT have storage reads - ), - } + ) + ], + nonce_changes=[], + balance_changes=[], + code_changes=[], + storage_reads=[], + ) + post[bob] = Account(nonce=1) + post[collision_address] = Account( + code=x_code, nonce=1, balance=0, storage={0x01: 0xCAFE} + ) + else: + raise ValueError(f"unknown modification: {modification}") + + block = Block( + txs=txs, + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations, ), ) - blockchain_test( - pre=pre, - blocks=[block], - post={ - alice: Account(nonce=1), - factory: Account(nonce=2, storage={0x00: 0}), - # Collision address unchanged - contract still exists - collision_address: Account(code=bytes(Op.STOP), nonce=1), - }, - ) + blockchain_test(pre=pre, blocks=[block], post=post) def test_bal_transient_storage_not_tracked( @@ -3022,6 +3090,136 @@ def test_bal_transient_storage_not_tracked( ) +def test_bal_create2_deploy_then_collision( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, +) -> None: + """ + Reverse-order companion to `test_bal_create_collision`: tx1 deploys X + via CREATE2, tx2 retries the same CREATE2 → collision. Covers the + `code_changes` axis of #2914 (forward order would need 7702 + + signable EOA at a deterministic CREATE address, infeasible). + + Init increments X's slot 0, so post-state slot 0 == 1 proves it ran + once (tx1); the tx2 collision must not re-run it (else a demoted + read leaks into X's `storage_reads`). + + CREATE2-only: CREATE auto-increments factory.nonce between txs, so + the second attempt targets a different address. + """ + alice = pre.fund_eoa() + + init_code = Initcode( + deploy_code=Op.STOP, + initcode_prefix=Op.SSTORE(0, Op.ADD(Op.SLOAD(0), 1)), + ) + init_code_bytes = bytes(init_code) + + factory_code = ( + Op.MSTORE(0, Op.PUSH32(init_code_bytes)) + + Op.SSTORE( + 0x00, + Op.CREATE2( + value=0, + offset=32 - len(init_code_bytes), + size=len(init_code_bytes), + salt=0, + ), + ) + + Op.STOP + ) + factory = pre.deploy_contract( + code=factory_code, + storage={0x00: 0xDEAD}, + ) + + target = compute_create_address( + address=factory, + salt=0, + initcode=init_code_bytes, + opcode=Op.CREATE2, + ) + + tx_gas_limit = fork.transaction_gas_limit_cap() + tx_deploy = Transaction(sender=alice, to=factory, gas_limit=tx_gas_limit) + tx_collide = Transaction(sender=alice, to=factory, gas_limit=tx_gas_limit) + + block = Block( + txs=[tx_deploy, tx_collide], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1), + BalNonceChange(block_access_index=2, post_nonce=2), + ], + ), + factory: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=2), + BalNonceChange(block_access_index=2, post_nonce=3), + ], + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=target + ), + BalStorageChange( + block_access_index=2, post_value=0 + ), + ], + ) + ], + ), + # Index-1 deployment entries must survive the + # index-2 collision touch. Init ran once (tx1), writing + # slot 0 = 1. The tx2 collision must add nothing — in + # particular `storage_reads` MUST stay empty (a client + # that runs init then reverts on collision would leak + # slot 0 here as a demoted read). + target: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1), + ], + code_changes=[ + BalCodeChange( + block_access_index=1, new_code=bytes(Op.STOP) + ), + ], + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=1 + ) + ], + ) + ], + balance_changes=[], + storage_reads=[], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=2), + factory: Account(nonce=3, storage={0x00: 0}), + # slot 0 == 1 proves init ran exactly once (tx2 collided). + target: Account( + nonce=1, code=bytes(Op.STOP), balance=0, storage={0x00: 1} + ), + }, + ) + + @pytest.mark.parametrize( "oog_boundary", [ diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 0e577d4d904..0dd13217ee8 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -127,7 +127,8 @@ | `test_bal_create_transaction_empty_code` | Ensure BAL does not record spurious code changes for CREATE transaction deploying empty code | Alice sends CREATE transaction with empty initcode (deploys code `b""`). Contract address gets nonce = 1 and code = `b""`. | BAL **MUST** include Alice with `nonce_changes` and created contract with `nonce_changes` but **MUST NOT** include `code_changes` for contract (setting `b"" -> b""` is net-zero). | ✅ Completed | | `test_bal_cross_block_ripemd160_state_leak` | Ensure internal EVM state for precompile handling does not leak between blocks | Block 1: Alice calls RIPEMD-160 (0x03) with zero value (RIPEMD-160 must be pre-funded). Block 2: Bob's transaction triggers an exception (stack underflow). | BAL for Block 1 **MUST** include RIPEMD-160. BAL for Block 2 **MUST NOT** include RIPEMD-160 (never accessed in Block 2). Internal state from Parity Touch Bug (EIP-161) handling must be reset between blocks. | ✅ Completed | | `test_bal_all_transaction_types` | Ensure BAL correctly captures state changes from all transaction types in a single block | Single block with 5 transactions: Type 0 (Legacy), Type 1 (EIP-2930 Access List), Type 2 (EIP-1559), Type 3 (EIP-4844 Blob), Type 4 (EIP-7702 Set Code). Each tx writes to contract storage. Note: Access list addresses are pre-warmed but NOT recorded in BAL (no state access). | BAL **MUST** include: (1) All 5 senders with `nonce_changes`. (2) Contracts 0-3 with `storage_changes`. (3) Alice (7702 target) with `nonce_changes`, `code_changes` (delegation), `storage_changes`. (4) Oracle (delegation source) with empty changes. | ✅ Completed | -| `test_bal_create2_collision` | Ensure BAL handles CREATE2 address collision correctly | Factory contract (nonce=1, storage slot 0=0xDEAD) executes `CREATE2(salt=0, initcode)` targeting address that already has `code=STOP, nonce=1`. Pre-deploy contract at calculated CREATE2 target address before factory deployment. | BAL **MUST** include: (1) Factory with `nonce_changes` (1→2, incremented even on failed CREATE2), `storage_changes` for slot 0 (0xDEAD→0, stores failure). (2) Collision address with empty changes (accessed during collision check, no state changes). CREATE2 returns 0. Collision address **MUST NOT** have `nonce_changes` or `code_changes`. | ✅ Completed | +| `test_bal_create_collision` | Ensure BAL handles CREATE/CREATE2 address collision correctly, with or without a subsequent tx that modifies the colliding address. Parametrized: `@pytest.mark.with_all_create_opcodes`, `modification ∈ {"collision_only", "then_nonce_change", "then_storage_change"}`. | Factory (nonce=1, slot[0]=0xDEAD) executes CREATE/CREATE2 targeting a pre-populated address X. For `collision_only`, X is `code=STOP, nonce=1`. For `then_nonce_change`, X is a contract that runs its own inner CREATE when called. For `then_storage_change`, X is a contract that SSTOREs when called. If `modification != "collision_only"`, a second tx (from Bob) calls X. | BAL **MUST** include: (1) Factory with `nonce_changes` (1→2, bumped even on failed CREATE/CREATE2), `storage_changes` for slot 0 (0xDEAD→0). (2) X for the collision touch at index 1. For `collision_only`, X's BAL entry is `empty()`. For `then_nonce_change`, X has `nonce_changes` at index 2 (post=2) and the inner-created child has `nonce_changes`/`code_changes` at index 2; X **MUST NOT** have spurious `code_changes`/`storage_changes`/`storage_reads`/`balance_changes`. For `then_storage_change`, X has `storage_changes` for slot 0x01 (post=0xCAFE) at index 2; **MUST NOT** have spurious entries on other axes. | ✅ Completed | +| `test_bal_create2_deploy_then_collision` | Ensure BAL correctly preserves a colliding address's deployment entries when the same address is later touched again via a collision check, and that init code does NOT execute on the collision. | Tx1: factory CREATE2 deploys X; X's init code does `SSTORE(0, SLOAD(0)+1)` (increment) then deploys `STOP`. Tx2: same factory retries the identical CREATE2 — collision against X. | BAL **MUST** include: (1) Factory with `nonce_changes` at indices 1 and 2 (1→2→3) and `storage_changes` at slot 0 with entries at indices 1 (post=X.address) and 2 (post=0 from the failed CREATE2 return). (2) X with `nonce_changes=[(1, 1)]`, `code_changes=[(1, STOP)]`, and `storage_changes` slot 0 `[(1, 1)]` from the deployment; **no** entries at index 2 and `storage_reads` **MUST** be empty — a client that runs init then reverts on collision would leak slot 0 as a demoted read. Post-state: X slot 0 == 1 proves init executed exactly once (tx2 collided, didn't re-run init). | ✅ Completed | | `test_bal_create_selfdestruct_to_self_with_call` | Ensure BAL handles init code that calls external contract then selfdestructs to itself | Factory executes `CREATE2` with endowment=100. Init code (embedded in factory via CODECOPY): (1) `CALL(Oracle, 0)` - Oracle writes to its storage slot 0x01. (2) `SSTORE(0x01, 0x42)` - write to own storage. (3) `SELFDESTRUCT(SELF)` - selfdestruct to own address. Contract created and destroyed in same tx. | BAL **MUST** include: (1) Factory with `nonce_changes`, `balance_changes` (loses 100). (2) Oracle with `storage_changes` for slot 0x01 (external call succeeded). (3) Created address with `storage_reads` for slot 0x01 (aborted write becomes read) - **MUST NOT** have `nonce_changes`, `code_changes`, `storage_changes`, or `balance_changes` (ephemeral contract, balance burned via SELFDESTRUCT to self). | ✅ Completed | | `test_bal_selfdestruct_to_7702_delegation` | Ensure BAL correctly handles SELFDESTRUCT to a 7702 delegated account (no code execution on recipient) | Tx1: Alice authorizes delegation to Oracle (sets code to `0xef0100\|\|Oracle`). Tx2: Victim contract (balance=100) executes `SELFDESTRUCT(Alice)`. Two separate transactions in same block. Note: Alice starts with initial balance which accumulates with selfdestruct. | BAL **MUST** include: (1) Alice at block_access_index=1 with `code_changes` (delegation), `nonce_changes`. (2) Alice at block_access_index=2 with `balance_changes` (receives selfdestruct). (3) Victim at block_access_index=2 with `balance_changes` (100→0). **Oracle MUST NOT appear in tx2** - per EVM spec, SELFDESTRUCT transfers balance without executing recipient code, so delegation target is never accessed. | ✅ Completed | | `test_bal_call_revert_insufficient_funds` | Ensure BAL handles value-transferring call failure due to insufficient balance (not OOG), with and without 7702 delegation | Caller contract (balance=100, storage slot 0x02=0xDEAD) executes: `SLOAD(0x01), call_opcode(target, value=1000), SSTORE(0x02, result)`. The call fails because 1000 > 100. Parametrized: (1) `call_opcode` over CALL and CALLCODE via `with_all_call_opcodes(selector=...)`, (2) `delegated` (target is plain EOA vs. 7702-delegated EOA pointing to `delegation_target`=STOP), (3) `target_is_warm` (cold/warm via EIP-2930 access list), (4) `delegation_is_warm` (only when delegated). | BAL **MUST** include: (1) Caller with `storage_reads` for slot 0x01, `storage_changes` for slot 0x02 (value=0, call returned failure). (2) Target with empty changes — accessed before the balance check fails. (3) When delegated: `delegation_target` **MUST NOT** appear in the BAL — the balance check fails before `generic_call` runs, so the delegation target's account is never read. Access-list warming does NOT add to BAL on its own, so the BAL is identical across warm/cold variants. | ✅ Completed |