Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions tests/amsterdam/eip7708_eth_transfer_logs/test_burn_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down
45 changes: 45 additions & 0 deletions tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
Bytecode,
Bytes,
Environment,
Fork,
Initcode,
Op,
StateTestFiller,
Expand All @@ -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

Expand Down Expand Up @@ -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)