Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""
EIP-8246: Remove SELFDESTRUCT balance burn.

https://eips.ethereum.org/EIPS/eip-8246
"""

from ....base_fork import BaseFork


class EIP8246(BaseFork):
"""EIP-8246 class."""

pass
2 changes: 2 additions & 0 deletions src/ethereum/forks/amsterdam/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
### Changes

- [EIP-7928: Block-Level Access Lists][EIP-7928]
- [EIP-8246: Remove SELFDESTRUCT balance burn][EIP-8246]

### Releases

[EIP-7773]: https://eips.ethereum.org/EIPS/eip-7773
[EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928
[EIP-8246]: https://eips.ethereum.org/EIPS/eip-8246
"""

from ethereum.fork_criteria import ForkCriteria, Unscheduled
Expand Down
3 changes: 2 additions & 1 deletion src/ethereum/forks/amsterdam/fork.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
BlockState,
TransactionState,
account_exists_and_is_empty,
convert_to_balance_only_account,
create_ether,
destroy_account,
extract_block_diff,
Expand Down Expand Up @@ -1087,7 +1088,7 @@ def process_transaction(
block_output.block_logs += tx_output.logs

for address in tx_output.accounts_to_delete:
destroy_account(tx_state, address)
convert_to_balance_only_account(tx_state, address)

incorporate_tx_into_block(tx_state, block_env.block_access_list_builder)

Expand Down
31 changes: 27 additions & 4 deletions src/ethereum/forks/amsterdam/state_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,10 +431,9 @@ def destroy_account(tx_state: TransactionState, address: Address) -> None:
"""
Completely remove the account at ``address`` and all of its storage.

This function is made available exclusively for the ``SELFDESTRUCT``
opcode. It is expected that ``SELFDESTRUCT`` will be disabled in a
future hardfork and this function will be removed. Only supports same
transaction destruction.
Invoked by ``modify_state`` (and the coinbase fee-credit path) to
clean up an account that has become empty (zero nonce, empty
code, and zero balance) so it does not appear in the post-state.
Comment on lines +434 to +436
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's something funky about this. I've opened #2908.


Parameters
----------
Expand All @@ -448,6 +447,30 @@ def destroy_account(tx_state: TransactionState, address: Address) -> None:
set_account(tx_state, address, None)


def convert_to_balance_only_account(
tx_state: TransactionState, address: Address
) -> None:
"""
Clear an account's nonce, code, and storage while preserving its
balance.

Parameters
----------
tx_state :
The transaction state.
address :
Address of the account to modify.

"""

def clear_account(account: Account) -> None:
account.nonce = Uint(0)
account.code_hash = EMPTY_CODE_HASH

destroy_storage(tx_state, address)
modify_state(tx_state, address, clear_account)


def destroy_storage(tx_state: TransactionState, address: Address) -> None:
"""
Completely remove the storage at ``address``.
Expand Down
4 changes: 0 additions & 4 deletions src/ethereum/forks/amsterdam/vm/instructions/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
increment_nonce,
is_account_alive,
move_ether,
set_account_balance,
)
from ...utils.address import (
compute_contract_address,
Expand Down Expand Up @@ -609,9 +608,6 @@ def selfdestruct(evm: Evm) -> None:
# register account for deletion only if it was created
# in the same transaction
if originator in tx_state.created_accounts:
# If beneficiary is the same as originator, then
# the ether is burnt.
set_account_balance(tx_state, originator, U256(0))
evm.accounts_to_delete.add(originator)

# HALT the execution
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2655,13 +2655,18 @@ def test_bal_create_selfdestruct_to_self_with_call(
),
# Created address: ephemeral (created and destroyed same tx)
# - storage_reads for slot 0x01 (aborted write becomes read)
# - NO nonce/code/storage/balance changes
# - NO nonce/code/storage changes
# - Balance remains per eip-8246
created_address: BalAccountExpectation(
storage_reads=[0x01],
storage_changes=[],
nonce_changes=[],
code_changes=[],
balance_changes=[],
balance_changes=[
BalBalanceChange(
block_access_index=1, post_balance=endowment
)
],
),
}
),
Expand All @@ -2674,8 +2679,9 @@ def test_bal_create_selfdestruct_to_self_with_call(
alice: Account(nonce=1),
factory: Account(nonce=2, balance=factory_balance - endowment),
oracle: Account(storage={0x01: 0x42}),
# Created address doesn't exist - destroyed in same tx
created_address: Account.NONEXISTENT,
created_address: Account(
balance=endowment, nonce=0, code=b"", storage={}
),
},
)

Expand Down
1 change: 1 addition & 0 deletions tests/amsterdam/eip8246_selfdestruct_no_burn/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for [EIP-8246: Remove SELFDESTRUCT Burn](https://eips.ethereum.org/EIPS/eip-8246)."""
17 changes: 17 additions & 0 deletions tests/amsterdam/eip8246_selfdestruct_no_burn/spec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Reference spec for [EIP-8246](https://eips.ethereum.org/EIPS/eip-8246)."""

from dataclasses import dataclass


@dataclass(frozen=True)
class ReferenceSpec:
"""Reference specification."""

git_path: str
version: str


ref_spec_8246 = ReferenceSpec(
git_path="EIPS/eip-8246.md",
version="3b30ff829e5e698f1c6f69427111d194b80af38d",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
"""Tests for [EIP-8246: Remove SELFDESTRUCT balance burn](https://eips.ethereum.org/EIPS/eip-8246)."""

import pytest
from execution_testing import (
Account,
Address,
Alloc,
Block,
BlockchainTestFiller,
Bytecode,
Op,
Storage,
Transaction,
compute_create_address,
keccak256,
)

from .spec import ref_spec_8246

REFERENCE_SPEC_GIT_PATH = ref_spec_8246.git_path
REFERENCE_SPEC_VERSION = ref_spec_8246.version

pytestmark = pytest.mark.valid_from("EIP8246")


@pytest.mark.parametrize("initial_balance", [0, 1])
@pytest.mark.parametrize("create_opcode", [Op.CREATE, Op.CREATE2])
@pytest.mark.parametrize("post_send_count", [0, 1, 3])
@pytest.mark.parametrize(
"post_send_opcode", [Op.CALL, Op.CALLCODE, Op.SELFDESTRUCT]
)
@pytest.mark.parametrize(
"initial_storage",
[
pytest.param(False, id="no_storage"),
pytest.param(True, id="with_storage"),
],
)
@pytest.mark.parametrize(
"transfer_target, transfer_drains_victim",
[
pytest.param(Op.ADDRESS, False, id="self"),
pytest.param(0x01, True, id="precompile"),
pytest.param(
Address(keccak256(b"eip-8246-eoa-target")[-20:]),
True,
id="eoa",
),
],
)
@pytest.mark.parametrize(
"exit_op, execution_success",
[
pytest.param(Op.STOP, True, id="success"),
pytest.param(Op.REVERT(0, 0), False, id="revert"),
pytest.param(Op.MSTORE(2**32, 0), False, id="oog"),
],
)
def test_selfdestruct_preserves_balance(
blockchain_test: BlockchainTestFiller,
pre: Alloc,
initial_balance: int,
post_send_count: int,
create_opcode: Op,
post_send_opcode: Op,
initial_storage: bool,
transfer_target: Op,
transfer_drains_victim: bool,
exit_op: Op,
execution_success: bool,
) -> None:
"""
Same-tx SELFDESTRUCT preserves the victim's balance per EIP-8246.

Test flow:
selfdestruct_tx
tx.to = entry_contract
└─ CALL selfdestruct_contract_factory
└─ initcode runs:
[optional] SSTORE(slot, value)
SELFDESTRUCT(transfer_target) # registers victim
└─ selfdestruct_contract_factory exits via STOP | REVERT | OOG
└─ N * post-send to victim (CALL | CALLCODE | donor.SELFDESTRUCT)

tx finalize
- victim balance-only per EIP-8246,
- or NONEXISTENT if EIP-161 cleans up a zero-balance account

probe_tx
tx.to = probe_contract
└─ STORAGE [0] = BALANCE(victim)
STORAGE [1] = EXTCODEHASH(victim)
STORAGE [2] = EXTCODESIZE(victim)
STORAGE [3] = SHA3(EXTCODECOPY(victim, 0, 0, size))
"""
# Selfdestruct target contract template.
# Optionally initializes storage to test clearing.
storage_init = Op.SSTORE(0, 1) if initial_storage else Bytecode()
selfdestruct_initcode = storage_init + Op.SELFDESTRUCT(transfer_target)

selfdestruct_template = pre.deploy_contract(code=selfdestruct_initcode)

# Build selfdestruct target contract via CREATE/CREATE2
salt = 0
if create_opcode == Op.CREATE2:
create_call = create_opcode(
value=initial_balance,
size=len(selfdestruct_initcode),
salt=salt,
)
else:
create_call = create_opcode(
value=initial_balance,
size=len(selfdestruct_initcode),
)

# Selfdestruct target contract factory
# Exits via STOP/REVERT/OOG for different scenario
selfdestruct_contract_factory = pre.deploy_contract(
code=Op.EXTCODECOPY(
address=selfdestruct_template, size=len(selfdestruct_initcode)
)
+ Op.POP(create_call)
+ exit_op
)

victim = compute_create_address(
address=selfdestruct_contract_factory,
opcode=create_opcode,
nonce=1,
salt=salt,
initcode=selfdestruct_initcode,
)

# Post value sending to the victim
# Ensure the ether transfer is not burned after eip-8246.
post_send_value = 1
if post_send_opcode == Op.SELFDESTRUCT:
donor = pre.deploy_contract(code=Op.SELFDESTRUCT(victim))
post_send = Op.POP(
Op.CALL(gas=Op.GAS, address=donor, value=post_send_value)
)
else:
post_send = Op.POP(
post_send_opcode(gas=Op.GAS, address=victim, value=post_send_value)
)

entry_contract = pre.deploy_contract(
code=Op.POP(
Op.CALL(
gas=Op.GAS,
address=selfdestruct_contract_factory,
value=initial_balance,
)
)
+ post_send * post_send_count
)

total_balance = initial_balance + post_send_count * post_send_value

sender = pre.fund_eoa()
selfdestruct_tx = Transaction(
sender=sender,
to=entry_contract,
value=total_balance,
gas_limit=5_000_000,
)

# Balance verification
# retained:
# selfdestruct-to-self retains balance
# selfdestruct-to-others drains balance if not revert / OOG
# delivered: post-sends count except for CALLCODE
retained = 0 if transfer_drains_victim else initial_balance
delivered = (
0
if post_send_opcode == Op.CALLCODE
else post_send_count * post_send_value
)

expected_balance = retained + delivered if execution_success else delivered
victim_alive = expected_balance > 0

probe_storage = Storage()
probe_code = (
Op.SSTORE(
probe_storage.store_next(expected_balance),
Op.BALANCE(victim),
)
+ Op.SSTORE(
probe_storage.store_next(keccak256(b"") if victim_alive else 0),
Op.EXTCODEHASH(victim),
)
+ Op.SSTORE(
probe_storage.store_next(0),
Op.EXTCODESIZE(victim),
)
+ Op.EXTCODECOPY(victim, 0, 0, len(selfdestruct_initcode))
+ Op.SSTORE(
probe_storage.store_next(
keccak256(b"\x00" * len(selfdestruct_initcode))
),
Op.SHA3(0, len(selfdestruct_initcode)),
)
+ Op.STOP
)

probe_contract = pre.deploy_contract(
code=probe_code, storage=probe_storage.canary()
)

probe_tx = Transaction(
sender=sender,
to=probe_contract,
gas_limit=200_000,
)

blockchain_test(
pre=pre,
post={
victim: (
Account.NONEXISTENT
if not victim_alive
else Account(
balance=expected_balance, nonce=0, code=b"", storage={}
)
),
probe_contract: Account(storage=probe_storage),
},
blocks=[Block(txs=[selfdestruct_tx, probe_tx])],
)
Loading
Loading