diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py index d7efab8935d..89dfa574b16 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py @@ -515,6 +515,217 @@ def test_finalization_burn_logs( state_test(env=env, pre=pre, post=post, tx=tx) +@pytest.mark.parametrize( + "num_accounts", + [ + pytest.param(2, id="two_accounts"), + pytest.param(5, id="five_accounts"), + ], +) +def test_finalization_burn_logs_multi_account_ordering( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + fork: Fork, + num_accounts: int, +) -> None: + """ + Verify finalization burn logs are sorted lexicographically by address + when multiple accounts are marked for deletion in the same transaction. + + N accounts are created and SELFDESTRUCT'd in the same tx, then each + is funded by a dedicated payer contract called in REVERSE sorted + address order with a distinct nonzero amount. Every destroyed account + ends with a distinct nonzero balance at finalization, so a Burn log + is emitted for each. The resulting sequence of finalization burn logs + must appear in ascending address order regardless of call order. + """ + beneficiary = pre.deploy_contract(Op.STOP) + + factory_address = compute_create_address( + address=sender, nonce=sender.nonce + ) + created_addrs = [ + compute_create_address(address=factory_address, nonce=i + 1) + for i in range(num_accounts) + ] + sorted_addrs = sorted(created_addrs) + reverse_sorted = list(reversed(sorted_addrs)) + + # Each created contract is CALLed exactly once (to trigger SELFDESTRUCT); + # payers then forward via their own SELFDESTRUCT, so the created + # contracts are never re-invoked — no call-once guard is needed. + runtime = Op.SELFDESTRUCT(beneficiary) + initcode = Initcode(deploy_code=runtime) + initcode_len = len(initcode) + + create_balances = [1000 * (i + 1) for i in range(num_accounts)] + factory_balance = sum(create_balances) + pre.fund_address(factory_address, factory_balance) + + payer_code = Op.SELFDESTRUCT(Op.CALLDATALOAD(0)) + funding_amounts = [100 * (i + 1) for i in range(num_accounts)] + payers = [ + pre.deploy_contract(payer_code, balance=funding_amounts[i]) + for i in range(num_accounts) + ] + + factory_code: Bytecode = Om.MSTORE(initcode, 0) + for i in range(num_accounts): + factory_code += Op.TSTORE( + i, + Op.CREATE(value=create_balances[i], offset=0, size=initcode_len), + ) + for i in range(num_accounts): + factory_code += Op.CALL(gas=Op.GAS, address=Op.TLOAD(i), value=0) + for i in range(num_accounts): + factory_code += Op.MSTORE(0, reverse_sorted[i]) + factory_code += Op.CALL( + gas=Op.GAS, + address=payers[i], + args_offset=0, + args_size=32, + ) + + execution_logs = [ + transfer_log(factory_address, addr, create_balances[i]) + for i, addr in enumerate(created_addrs) + ] + execution_logs.extend( + transfer_log(addr, beneficiary, create_balances[i]) + for i, addr in enumerate(created_addrs) + ) + execution_logs.extend( + transfer_log(payers[i], reverse_sorted[i], funding_amounts[i]) + for i in range(num_accounts) + ) + + amount_by_addr = dict(zip(reverse_sorted, funding_amounts, strict=True)) + finalization_logs = [ + burn_log(addr, amount_by_addr[addr]) for addr in sorted_addrs + ] + + tx = Transaction( + sender=sender, + to=None, + value=0, + data=factory_code, + gas_limit=fork.transaction_gas_limit_cap(), + expected_receipt=TransactionReceipt( + logs=execution_logs + finalization_logs + ), + ) + + post: dict[Address, Account | None] = dict.fromkeys( + created_addrs, Account.NONEXISTENT + ) + post[beneficiary] = Account(balance=factory_balance) + for payer in payers: + post[payer] = Account(balance=0) + + state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.parametrize( + "num_transfers", + [ + pytest.param(2, id="two_transfers"), + pytest.param(5, id="five_transfers"), + ], +) +def test_finalization_burn_log_single_account_multiple_transfers( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + fork: Fork, + num_transfers: int, +) -> None: + """ + Verify finalization emits a single Burn log summing multiple ETH transfers + to one to-be-destructed account. + + A single account is created and SELFDESTRUCT'd in the same tx, then N + payer contracts each send a distinct nonzero amount to it. Exactly ONE + Burn log MUST be emitted at finalization with the combined residual + balance, a client emitting one log per transfer would fail. + """ + beneficiary = pre.deploy_contract(Op.STOP) + + factory_address = compute_create_address( + address=sender, nonce=sender.nonce + ) + x = compute_create_address(address=factory_address, nonce=1) + + # x is only CALLed once (to trigger SELFDESTRUCT); payers forward via + # their own SELFDESTRUCT, so no call-once guard is needed. + runtime = Op.SELFDESTRUCT(beneficiary) + initcode = Initcode(deploy_code=runtime) + initcode_len = len(initcode) + + create_balance = 1000 + pre.fund_address(factory_address, create_balance) + + # N payer contracts, each sending a distinct nonzero amount to x + payer_code = Op.SELFDESTRUCT(x) + funding_amounts = [100 * (i + 1) for i in range(num_transfers)] + payers = [ + pre.deploy_contract(payer_code, balance=funding_amounts[i]) + for i in range(num_transfers) + ] + + # Factory creates x, triggers its SELFDESTRUCT, then calls each payer with + # x as the beneficiary so each payer's balance is forwarded to x. + factory_code: Bytecode = ( + Om.MSTORE(initcode, 0) + + Op.TSTORE( + 0, Op.CREATE(value=create_balance, offset=0, size=initcode_len) + ) + + Op.CALL(gas=Op.GAS, address=Op.TLOAD(0), value=0) + ) + for i in range(num_transfers): + factory_code += Op.CALL( + gas=Op.GAS, + address=payers[i], + args_offset=0, + args_size=32, + ) + + execution_logs = [ + transfer_log(factory_address, x, create_balance), + transfer_log(x, beneficiary, create_balance), + ] + execution_logs.extend( + transfer_log(payers[i], x, funding_amounts[i]) + for i in range(num_transfers) + ) + + # Exactly one burn log with the SUM of transferred amounts + total_residual = sum(funding_amounts) + finalization_logs = [burn_log(x, total_residual)] + + tx = Transaction( + sender=sender, + to=None, + value=0, + data=factory_code, + gas_limit=fork.transaction_gas_limit_cap(), + expected_receipt=TransactionReceipt( + logs=execution_logs + finalization_logs + ), + ) + + post: dict[Address, Account | None] = { + x: Account.NONEXISTENT, + beneficiary: Account(balance=create_balance), + } + for payer in payers: + post[payer] = Account(balance=0) + + state_test(env=env, pre=pre, post=post, tx=tx) + + @pytest.mark.parametrize( "funded_after_selfdestruct", [ diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py index 8946a9a25a7..d99897d0248 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py @@ -18,6 +18,7 @@ Bytecode, Bytes, Environment, + Fork, Initcode, Op, StateTestFiller, @@ -27,6 +28,7 @@ compute_create2_address, compute_create_address, ) +from execution_testing.base_types import ZeroPaddedHexNumber from .spec import Spec, ref_spec_7708, transfer_log @@ -1298,3 +1300,46 @@ def test_call_to_delegated_account_with_value( post = {delegated_eoa: Account(balance=100)} state_test(env=env, pre=pre, post=post, tx=tx) + + +@pytest.mark.execute(pytest.mark.skip("Requires specific base fee")) +def test_call_with_value_to_coinbase_no_priority_fee_log( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + sender: EOA, + fork: Fork, +) -> None: + """ + Verify no Transfer log is emitted for the coinbase priority fee. + + A contract executes CALL with nonzero value to the coinbase address, + and the transaction pays a nonzero priority fee to that same + coinbase. Only the CALL-with-value must produce a Transfer log; the + priority fee crediting happens outside the EVM as a protocol-level + balance change and must not emit a log. + + An implementation that hooks all balance additions (instead of only + CALL / SELFDESTRUCT / tx-level value transfers) would emit an extra + Transfer log for the fee and fail the exact-log assertion. + """ + coinbase = env.fee_recipient + call_value = 1 + + caller_code = Op.CALL(gas=Op.GAS, address=coinbase, value=call_value) + caller = pre.deploy_contract(caller_code, balance=call_value) + env.base_fee_per_gas = ZeroPaddedHexNumber(7) + max_fee_per_gas = int(env.base_fee_per_gas) * 2 + tx = Transaction( + sender=sender, + to=caller, + value=0, + gas_limit=fork.transaction_gas_limit_cap(), + max_fee_per_gas=max_fee_per_gas, + max_priority_fee_per_gas=max_fee_per_gas, + expected_receipt=TransactionReceipt( + logs=[transfer_log(caller, coinbase, call_value)] + ), + ) + + state_test(env=env, pre=pre, post={}, tx=tx)