diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 6eea651a26a..46f6894a0b2 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -845,6 +845,19 @@ def generate_block_data( block_number=env.number, timestamp=env.timestamp ) env = env.set_fork_requirements(fork) + parent_fork = self.fork.fork_at( + block_number=max(int(env.number) - 1, 0), + timestamp=int(previous_env.timestamp), + ) + env = env.model_copy( + update={ + "parent_fork": ( + parent_fork.transition_tool_name() + if parent_fork is not fork + else None + ) + } + ) txs = block.txs[:] if any("gas_limit" not in tx.model_fields_set for tx in block.txs): max_tx_gas_limit = Transaction.calculate_max_gas_limit( diff --git a/packages/testing/src/execution_testing/test_types/block_types.py b/packages/testing/src/execution_testing/test_types/block_types.py index 56d367e2e68..2d1026dca0c 100644 --- a/packages/testing/src/execution_testing/test_types/block_types.py +++ b/packages/testing/src/execution_testing/test_types/block_types.py @@ -146,6 +146,13 @@ def strip_computed_fields(cls, data: Any) -> Any: block_access_list_hash: Hash | None = Field(None) block_access_lists: Bytes | None = Field(None) + parent_fork: str | None = Field( + None, + alias="parentFork", + description="Transition-tool name of the parent block's fork, set " + "only on a fork activation block.", + ) + @computed_field # type: ignore[prop-decorator] @cached_property def parent_hash(self) -> Hash | None: diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 302e0887ed2..717e0fa3808 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -80,6 +80,7 @@ incorporate_tx_into_block, increment_nonce, set_account_balance, + set_code, ) from .transactions import ( TX_MAX_GAS_LIMIT, @@ -146,6 +147,17 @@ HISTORY_STORAGE_ADDRESS = hex_to_address( "0x0000F90827F1C53a10cb7A02335B175320002935" ) +DETERMINISTIC_FACTORY_ADDRESS = hex_to_address( + "0x4e59b44847b379578588920cA78FbF26c0B4956C" +) +DETERMINISTIC_FACTORY_CODE = Bytes( + bytes.fromhex( + "7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "ffe03601600081602082378035828234f58015156039578182fd5b80825250" + "50506014600cf3" + ) +) +DETERMINISTIC_FACTORY_CODE_HASH = keccak256(DETERMINISTIC_FACTORY_CODE) MAX_BLOCK_SIZE = 10_485_760 SAFETY_MARGIN = 2_097_152 MAX_RLP_BLOCK_SIZE = MAX_BLOCK_SIZE - SAFETY_MARGIN @@ -337,6 +349,9 @@ def execute_block( slot_number=block.header.slot_number, ) + if isinstance(parent_header, PreviousHeader): + deploy_deterministic_factory(block_env) + block_output = apply_body( block_env=block_env, transactions=block.transactions, @@ -842,6 +857,30 @@ def process_unchecked_system_transaction( return system_tx_output +def deploy_deterministic_factory(block_env: vm.BlockEnvironment) -> None: + """ + Install the [EIP-7997] deterministic deployment factory on the fork + activation block if its canonical runtime code is not already + present. The factory account is recorded in the block access list at + the pre-execution index: with its changes when installed, or as an + accessed account when the code check finds it already present. + + [EIP-7997]: https://eips.ethereum.org/EIPS/eip-7997 + """ + tx_state = TransactionState(parent=block_env.state) + account = get_account(tx_state, DETERMINISTIC_FACTORY_ADDRESS) + if account.code_hash != DETERMINISTIC_FACTORY_CODE_HASH: + set_code( + tx_state, + DETERMINISTIC_FACTORY_ADDRESS, + DETERMINISTIC_FACTORY_CODE, + ) + if account.nonce == Uint(0): + increment_nonce(tx_state, DETERMINISTIC_FACTORY_ADDRESS) + + incorporate_tx_into_block(tx_state, block_env.block_access_list_builder) + + def apply_body( block_env: vm.BlockEnvironment, transactions: Tuple[LegacyTransaction | Bytes, ...], diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index f9ec92d6ded..1e088929def 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -119,6 +119,16 @@ def state_transition(self) -> Any: """state_transition function of the fork.""" return self._module("fork").state_transition + @property + def has_deploy_deterministic_factory(self) -> bool: + """Check if the fork installs the EIP-7997 factory.""" + return hasattr(self._module("fork"), "deploy_deterministic_factory") + + @property + def deploy_deterministic_factory(self) -> Any: + """deploy_deterministic_factory function of the fork.""" + return self._module("fork").deploy_deterministic_factory + @property def signing_hash(self) -> Any: """signing_hash function of the fork.""" diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index cd449ad9f03..e0cc43e9e5c 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -6,6 +6,7 @@ import fnmatch import json import os +import re from contextlib import AbstractContextManager from typing import Any, Final, TextIO, Type, TypeVar @@ -24,6 +25,7 @@ ) from ..loaders.fixture_loader import Load +from ..loaders.fork_loader import ForkLoad from ..utils import ( FatalError, find_fork, @@ -177,6 +179,7 @@ def __init__( else: stdin = None + self.forks = forks fork_module, self.fork_block = find_fork(forks, self.options, stdin) fork_criteria = None @@ -380,7 +383,31 @@ def run_state_test(self) -> Any: self.result.update(self, block_env, block_output) self.result.rejected = self.txs.rejected_txs + def _apply_fork_activation_operations(self, block_env: Any) -> None: + """ + Apply irregular state transitions on a fork activation block. + + The filler marks the activation block by passing the parent + block's fork name in the environment. + """ + if self.env.parent_fork is None: + return + if not self.fork.has_deploy_deterministic_factory: + return + parent_short_name = re.sub( + r"(? None: + self._apply_fork_activation_operations(block_env) + if self.fork.has_compute_requests_hash: self.fork.process_unchecked_system_transaction( block_env=block_env, diff --git a/src/ethereum_spec_tools/evm_tools/t8n/env.py b/src/ethereum_spec_tools/evm_tools/t8n/env.py index edf3763d573..740b677ae22 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/env.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/env.py @@ -42,6 +42,7 @@ class Env: prev_randao: Optional[Bytes32] parent_difficulty: Optional[Uint] parent_timestamp: Optional[U256] + parent_fork: Optional[str] base_fee_per_gas: Optional[Uint] parent_gas_used: Optional[Uint] parent_gas_limit: Optional[Uint] @@ -68,6 +69,7 @@ def __init__(self, t8n: "T8N", stdin: Optional[Dict] = None): self.block_gas_limit = parse_hex_or_int(data["currentGasLimit"], Uint) self.block_number = parse_hex_or_int(data["currentNumber"], Uint) self.block_timestamp = parse_hex_or_int(data["currentTimestamp"], U256) + self.parent_fork = data.get("parentFork") self.read_block_difficulty(data, t8n) self.read_base_fee_per_gas(data, t8n) 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 6bcc98b121a..64c2455f306 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -187,4 +187,8 @@ | `test_invalid_post_fork_block_without_bal_hash_field` | Verify clients reject an Amsterdam activation block whose header is missing `block_access_list_hash`. File: `tests/amsterdam/eip7928_block_level_access_lists/test_fork_transition.py`. | Single block at `timestamp=15_000` with a regular transfer, mutated via `rlp_modifier=Header(block_access_list_hash=Header.REMOVE_FIELD)` so the field is dropped from the header. | Block **MUST** be rejected with `BlockException.INVALID_BAL_HASH`: clients re-derive the BAL hash from execution and find no header hash to match. The engine fixture omits the `blockAccessList` param from `newPayloadV5`, which **MUST** return `-32602: Invalid params`. | ✅ Completed | | `test_fork_transition_bal_size_constraint` | Verify the BAL size constraint (`bal_items <= gas_limit // BLOCK_ACCESS_LIST_ITEM`) applies only on/after Amsterdam. File: `tests/amsterdam/eip7928_block_level_access_lists/test_fork_transition.py`. Parametrized over `exceeds_limit_at_fork`: `at_fork_within_budget` (`gas_limit == empty_block_bal_item_count() * BLOCK_ACCESS_LIST_ITEM`) and `at_fork_over_budget` (`gas_limit` one wei below that). | Two empty blocks: pre-fork (`timestamp=14_999`) and activation block (`timestamp=15_000`). The same low `gas_limit` is used for both via `genesis_environment=Environment(gas_limit=...)`. | Pre-fork block **MUST** be accepted under both budgets (constraint not yet enforced). Activation block **MUST** be accepted at the exact budget and **MUST** be rejected with `BlockException.BLOCK_ACCESS_LIST_GAS_LIMIT_EXCEEDED` one item over the budget. | ✅ Completed | | `test_bal_dirty_account_selfdestruct` | Ensure BAL does not record dirty state on a same-tx ephemeral contract whose `SELFDESTRUCT` takes effect. | A factory deploys an ephemeral whose initcode dirties balance, nonce, code, and storage; runtime `SELFDESTRUCT` routes through an intermediate oracle. | **success**: ephemeral's BAL entry contains only `storage_reads` for the demoted slots. **revert**: BAL records all four dirty fields (destruction rolled back). | ✅ Completed | +| `test_factory_installed_at_transition` | Ensure BAL records the [EIP-7997](https://eips.ethereum.org/EIPS/eip-7997) factory install on the Amsterdam activation block as an irregular state transition at `block_access_index=0`. File: `tests/amsterdam/eip7997_deterministic_factory_predeploy/test_fork_transition.py`. Uses `@pytest.mark.valid_at_transition_to("Amsterdam")`. | Factory removed from genesis via a zeroed pre-alloc override. Blocks at `timestamp=14_999` (pre-fork), `15_000` (empty activation block), `15_001` (CREATE2 deployment through the factory). | Activation block BAL **MUST** include the factory with `nonce_changes=[(0, 1)]` and `code_changes=[(0, runtime code)]`. The next block **MUST** record only the tx-driven `nonce_changes=[(1, 2)]` with explicitly empty `code_changes` (the install must not repeat). Post-state: factory at nonce 2 with canonical code, CREATE2 child deployed. | ✅ Completed | +| `test_factory_access_recorded_at_transition` | Ensure the activation block records an access-only BAL entry when the factory is already deployed (the mainnet case). File: `tests/amsterdam/eip7997_deterministic_factory_predeploy/test_fork_transition.py`. | Factory pre-existing with canonical code, parametrized `nonce ∈ [1, 5]`. Unrelated Alice→Bob transfers in the activation block and the block after. | Activation block BAL **MUST** include the factory as an accessed account with no changes (`BalAccountExpectation.empty()`). The following block **MUST NOT** include the factory at all. Post-state: factory unchanged. | ✅ Completed | +| `test_factory_code_reset_at_transition` | Ensure a factory account holding non-canonical code is reset on the activation block, with nonce and balance preservation rules in the BAL. File: `tests/amsterdam/eip7997_deterministic_factory_predeploy/test_fork_transition.py`. | Factory pre-existing with `STOP` code and balance 1000, parametrized: `nonce=0` (set to 1) and `nonce=7` (preserved). Empty activation block. | Activation block BAL **MUST** include `code_changes=[(0, runtime code)]`. `nonce_changes` **MUST** be `[(0, 1)]` for the zero-nonce case and explicitly empty for the nonzero case. Post-state: canonical code, balance preserved. | ✅ Completed | +| `test_factory_install_merged_with_same_block_use` | Ensure the factory install and a same-block CREATE2 use merge into a single BAL entry across indices. File: `tests/amsterdam/eip7997_deterministic_factory_predeploy/test_fork_transition.py`. | Factory removed from genesis. The activation block itself contains a CREATE2 deployment through the freshly installed factory. | BAL **MUST** contain one factory entry with `code_changes=[(0, runtime code)]` and `nonce_changes=[(0, 1), (1, 2)]` — the install at the pre-execution index and the tx-driven increment merged in index order. | ✅ Completed | diff --git a/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_fork_transition.py b/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_fork_transition.py index b124a0c5884..8fabab7836f 100644 --- a/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_fork_transition.py +++ b/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_fork_transition.py @@ -17,6 +17,7 @@ compute_create2_address, ) from execution_testing.test_types.block_access_list.account_changes import ( + BalCodeChange, BalNonceChange, ) from execution_testing.test_types.block_access_list.expectations import ( @@ -104,3 +105,265 @@ def test_factory_deploys_across_transition( ), }, ) + + +@pytest.mark.valid_at_transition_to("Amsterdam") +@pytest.mark.pre_alloc_mutable +def test_factory_installed_at_transition( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + A chain without the factory installs it on the Amsterdam activation + block, recording the nonce and code changes in that block's access + list at the pre-execution index. Later blocks do not touch the + account, and the installed factory is immediately usable. + """ + factory = Address(Spec.FACTORY_ADDRESS) + # Zero out the injected predeploy; the genesis allocation merge + # drops the resulting empty account entirely. + pre[factory] = Account(nonce=0, balance=0, code=b"") + sender = pre.fund_eoa() + + runtime_code = Op.RETURN(0, 1) + initcode = Initcode(deploy_code=runtime_code) + salt = Hash(0) + + blockchain_test( + pre=pre, + blocks=[ + Block(timestamp=FORK_TIMESTAMP - 1), + Block( + timestamp=FORK_TIMESTAMP, + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + factory: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=0, post_nonce=1 + ) + ], + code_changes=[ + BalCodeChange( + block_access_index=0, + new_code=Spec.FACTORY_BYTECODE, + ) + ], + ), + } + ), + ), + Block( + timestamp=FORK_TIMESTAMP + 1, + txs=[ + Transaction( + sender=sender, + to=factory, + data=salt + bytes(initcode), + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + factory: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=1, post_nonce=2 + ) + ], + code_changes=[], + ), + } + ), + ), + ], + post={ + factory: Account(nonce=2, code=Spec.FACTORY_BYTECODE), + compute_create2_address(factory, salt, initcode): Account( + nonce=1, code=bytes(runtime_code) + ), + }, + ) + + +@pytest.mark.valid_at_transition_to("Amsterdam") +@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize("factory_nonce", [1, 5]) +def test_factory_access_recorded_at_transition( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + factory_nonce: int, +) -> None: + """ + An already-deployed factory is left untouched by the activation + block, which records only the access: the account appears in the + activation block's access list with no changes, and not at all in + later blocks' access lists. + """ + factory = pre.deploy_contract( + code=Spec.FACTORY_BYTECODE, + address=Address(Spec.FACTORY_ADDRESS), + nonce=factory_nonce, + ) + sender = pre.fund_eoa() + receiver = pre.fund_eoa(amount=0) + + blockchain_test( + pre=pre, + blocks=[ + Block(timestamp=FORK_TIMESTAMP - 1), + Block( + timestamp=FORK_TIMESTAMP, + txs=[Transaction(sender=sender, to=receiver, value=1)], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + factory: BalAccountExpectation.empty(), + } + ), + ), + Block( + timestamp=FORK_TIMESTAMP + 1, + txs=[Transaction(sender=sender, to=receiver, value=1)], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + factory: None, + } + ), + ), + ], + post={ + factory: Account( + nonce=factory_nonce, code=Spec.FACTORY_BYTECODE + ), + }, + ) + + +@pytest.mark.valid_at_transition_to("Amsterdam") +@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize( + "factory_nonce,post_nonce,expected_nonce_changes", + [ + pytest.param( + 0, + 1, + [BalNonceChange(block_access_index=0, post_nonce=1)], + id="zero_nonce_set_to_one", + ), + pytest.param(7, 7, [], id="nonzero_nonce_preserved"), + ], +) +def test_factory_code_reset_at_transition( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + factory_nonce: int, + post_nonce: int, + expected_nonce_changes: list, +) -> None: + """ + A factory account holding non-canonical code is reset to the + canonical runtime code on the activation block. A zero nonce is set + to one, a nonzero nonce and the balance are preserved, and only the + resulting changes appear in the access list. + """ + factory = pre.deploy_contract( + code=Op.STOP, + address=Address(Spec.FACTORY_ADDRESS), + nonce=factory_nonce, + balance=1000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block(timestamp=FORK_TIMESTAMP - 1), + Block( + timestamp=FORK_TIMESTAMP, + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + factory: BalAccountExpectation( + nonce_changes=expected_nonce_changes, + code_changes=[ + BalCodeChange( + block_access_index=0, + new_code=Spec.FACTORY_BYTECODE, + ) + ], + ), + } + ), + ), + ], + post={ + factory: Account( + nonce=post_nonce, + code=Spec.FACTORY_BYTECODE, + balance=1000, + ), + }, + ) + + +@pytest.mark.valid_at_transition_to("Amsterdam") +@pytest.mark.pre_alloc_mutable +def test_factory_install_merged_with_same_block_use( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + The factory is installed and used within the activation block. The + install and the transaction-driven use merge into a single access + list entry: the code change at the pre-execution index and nonce + changes at both indices. + """ + factory = Address(Spec.FACTORY_ADDRESS) + # Zero out the injected predeploy; the genesis allocation merge + # drops the resulting empty account entirely. + pre[factory] = Account(nonce=0, balance=0, code=b"") + sender = pre.fund_eoa() + + runtime_code = Op.RETURN(0, 1) + initcode = Initcode(deploy_code=runtime_code) + salt = Hash(0) + + blockchain_test( + pre=pre, + blocks=[ + Block(timestamp=FORK_TIMESTAMP - 1), + Block( + timestamp=FORK_TIMESTAMP, + txs=[ + Transaction( + sender=sender, + to=factory, + data=salt + bytes(initcode), + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + factory: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=0, post_nonce=1 + ), + BalNonceChange( + block_access_index=1, post_nonce=2 + ), + ], + code_changes=[ + BalCodeChange( + block_access_index=0, + new_code=Spec.FACTORY_BYTECODE, + ) + ], + ), + } + ), + ), + ], + post={ + factory: Account(nonce=2, code=Spec.FACTORY_BYTECODE), + compute_create2_address(factory, salt, initcode): Account( + nonce=1, code=bytes(runtime_code) + ), + }, + )