diff --git a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7997.py b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7997.py new file mode 100644 index 00000000000..fe4be8f40d8 --- /dev/null +++ b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7997.py @@ -0,0 +1,55 @@ +""" +EIP-7997: Deterministic Factory Predeploy. + +Predeploy a minimal `CREATE2` factory at address `0x12` so deterministic +deployments are available across chains without bootstrapping transactions. + +https://eips.ethereum.org/EIPS/eip-7997 +""" + +from typing import Mapping + +from execution_testing.base_types import Address + +from ....base_fork import BaseFork + +DETERMINISTIC_FACTORY_PREDEPLOY_ADDRESS = 0x12 +DETERMINISTIC_FACTORY_PREDEPLOY_BYTECODE = bytes.fromhex( + "60203610602f57" + "60003560203603806020600037600034f5" + "806026573d600060003e3d6000fd" + "5b60005260206000f3" + "5b60006000fd" +) + + +class EIP7997(BaseFork): + """EIP-7997 class.""" + + @classmethod + def deterministic_factory_predeploy_address(cls) -> Address | None: + """Return the EIP-7997 deterministic factory predeploy address.""" + return Address( + DETERMINISTIC_FACTORY_PREDEPLOY_ADDRESS, + label="DETERMINISTIC_FACTORY_PREDEPLOY_ADDRESS", + ) + + @classmethod + def pre_allocation(cls) -> Mapping: + """Pre-allocate the deterministic factory predeploy.""" + return { + DETERMINISTIC_FACTORY_PREDEPLOY_ADDRESS: { + "nonce": 1, + "code": DETERMINISTIC_FACTORY_PREDEPLOY_BYTECODE, + } + } | super(EIP7997, cls).pre_allocation() # type: ignore + + @classmethod + def pre_allocation_blockchain(cls) -> Mapping: + """Pre-allocate the deterministic factory predeploy.""" + return { + DETERMINISTIC_FACTORY_PREDEPLOY_ADDRESS: { + "nonce": 1, + "code": DETERMINISTIC_FACTORY_PREDEPLOY_BYTECODE, + } + } | super(EIP7997, cls).pre_allocation_blockchain() # type: ignore diff --git a/src/ethereum/forks/amsterdam/__init__.py b/src/ethereum/forks/amsterdam/__init__.py index e6f3e9476a0..6844dfce232 100644 --- a/src/ethereum/forks/amsterdam/__init__.py +++ b/src/ethereum/forks/amsterdam/__init__.py @@ -1,14 +1,17 @@ """ -The Amsterdam fork ([EIP-7773]) includes block-level access lists. +The Amsterdam fork ([EIP-7773]) includes block-level access lists and the +deterministic ``CREATE2`` factory predeploy. ### Changes - [EIP-7928: Block-Level Access Lists][EIP-7928] +- [EIP-7997: Deterministic Factory Predeploy][EIP-7997] ### Releases [EIP-7773]: https://eips.ethereum.org/EIPS/eip-7773 [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 +[EIP-7997]: https://eips.ethereum.org/EIPS/eip-7997 """ from ethereum.fork_criteria import ForkCriteria, Unscheduled diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 0ff0bf663ed..d28677eda08 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -32,10 +32,13 @@ from ethereum.merkle_patricia_trie import root, trie_set from ethereum.state import ( EMPTY_CODE_HASH, + Account, Address, BlockDiff, State, apply_changes_to_state, + set_account, + store_code, ) from . import vm @@ -126,6 +129,14 @@ HISTORY_STORAGE_ADDRESS = hex_to_address( "0x0000F90827F1C53a10cb7A02335B175320002935" ) +DETERMINISTIC_FACTORY_ADDRESS = hex_to_address("0x12") +DETERMINISTIC_FACTORY_CODE = bytes.fromhex( + "60203610602f57" + "60003560203603806020600037600034f5" + "806026573d600060003e3d6000fd" + "5b60005260206000f3" + "5b60006000fd" +) MAX_BLOCK_SIZE = 10_485_760 SAFETY_MARGIN = 2_097_152 MAX_RLP_BLOCK_SIZE = MAX_BLOCK_SIZE - SAFETY_MARGIN @@ -169,6 +180,12 @@ def apply_fork(old: BlockChain) -> BlockChain: is used to handle the irregularity. See the :ref:`DAO Fork ` for an example. + EIP-7997 introduces a deterministic `CREATE2` factory at + [`DETERMINISTIC_FACTORY_ADDRESS`]. The factory bytecode is injected + directly into state at fork activation so the same factory address is + available across chains without relying on a one-shot deployment + transaction. + Parameters ---------- old : @@ -179,7 +196,16 @@ def apply_fork(old: BlockChain) -> BlockChain: new : `BlockChain` Upgraded block chain object for this hard fork. + [`DETERMINISTIC_FACTORY_ADDRESS`]: + ref:ethereum.forks.amsterdam.fork.DETERMINISTIC_FACTORY_ADDRESS + """ + code_hash = store_code(old.state, DETERMINISTIC_FACTORY_CODE) + set_account( + old.state, + DETERMINISTIC_FACTORY_ADDRESS, + Account(nonce=Uint(1), balance=U256(0), code_hash=code_hash), + ) return old diff --git a/tests/amsterdam/eip7997_deterministic_factory_predeploy/__init__.py b/tests/amsterdam/eip7997_deterministic_factory_predeploy/__init__.py new file mode 100644 index 00000000000..ed408ffee14 --- /dev/null +++ b/tests/amsterdam/eip7997_deterministic_factory_predeploy/__init__.py @@ -0,0 +1 @@ +"""Tests for EIP-7997: Deterministic Factory Predeploy.""" diff --git a/tests/amsterdam/eip7997_deterministic_factory_predeploy/spec.py b/tests/amsterdam/eip7997_deterministic_factory_predeploy/spec.py new file mode 100644 index 00000000000..32e78193f8e --- /dev/null +++ b/tests/amsterdam/eip7997_deterministic_factory_predeploy/spec.py @@ -0,0 +1,31 @@ +"""Reference spec for [EIP-7997: Deterministic Factory Predeploy](https://eips.ethereum.org/EIPS/eip-7997).""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ReferenceSpec: + """Reference specification.""" + + git_path: str + version: str + + +ref_spec_7997 = ReferenceSpec( + git_path="EIPS/eip-7997.md", + version="ec05b85e530a7ea97ea52a1f0312d88eb0eb1be2", +) + + +@dataclass(frozen=True) +class Spec: + """Constants from EIP-7997.""" + + FACTORY_ADDRESS: int = 0x12 + FACTORY_BYTECODE: bytes = bytes.fromhex( + "60203610602f57" + "60003560203603806020600037600034f5" + "806026573d600060003e3d6000fd" + "5b60005260206000f3" + "5b60006000fd" + ) diff --git a/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_factory.py b/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_factory.py new file mode 100644 index 00000000000..f033ad29498 --- /dev/null +++ b/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_factory.py @@ -0,0 +1,848 @@ +""" +Tests for [EIP-7997: Deterministic Factory Predeploy](https://eips.ethereum.org/EIPS/eip-7997). + +The factory at `0x12` interprets calldata as `salt (32) || initcode` and +invokes `CREATE2` with the call value forwarded. It returns the created +address (left-padded to 32 bytes) on success, reverts with the creation-frame +return data on `CREATE2` failure, and reverts with empty data when calldata +is shorter than 32 bytes. +""" + +import pytest +from execution_testing import ( + AccessList, + Account, + Alloc, + Hash, + Initcode, + Op, + StateTestFiller, + Storage, + Transaction, + compute_create2_address, +) + +from .spec import Spec, ref_spec_7997 + +REFERENCE_SPEC_GIT_PATH = ref_spec_7997.git_path +REFERENCE_SPEC_VERSION = ref_spec_7997.version + +pytestmark = pytest.mark.valid_from("EIP7997") + +FACTORY = Spec.FACTORY_ADDRESS + + +def test_factory_predeploy_account( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """The factory bytecode is present at `0x12` with nonce 1.""" + caller = pre.deploy_contract( + Op.SSTORE(0, Op.EXTCODESIZE(FACTORY)) + Op.STOP, + ) + state_test( + pre=pre, + tx=Transaction( + sender=pre.fund_eoa(), + to=caller, + gas_limit=200_000, + ), + post={ + FACTORY: Account( + nonce=1, + code=Spec.FACTORY_BYTECODE, + ), + caller: Account( + storage={0: len(Spec.FACTORY_BYTECODE)}, + ), + }, + ) + + +def test_factory_deploys_contract( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Calling the factory with `salt || initcode` deploys a contract at the + expected `CREATE2` address and returns that address. + """ + salt = 0x42 + runtime_code = Op.PUSH1(0x01) + Op.PUSH1(0x00) + Op.RETURN + initcode = Initcode(deploy_code=runtime_code) + expected_address = compute_create2_address(FACTORY, salt, initcode) + + storage = Storage() + caller = pre.deploy_contract( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.SSTORE( + storage.store_next(1, "factory_call_success"), + Op.CALL( + gas=Op.GAS, + address=FACTORY, + value=0, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0, + ret_size=32, + ), + ) + + Op.SSTORE( + storage.store_next(expected_address, "returned_address"), + Op.MLOAD(0), + ) + + Op.STOP, + ) + + state_test( + pre=pre, + tx=Transaction( + sender=pre.fund_eoa(), + to=caller, + data=Hash(salt) + bytes(initcode), + gas_limit=500_000, + ), + post={ + caller: Account(storage=storage), + expected_address: Account( + nonce=1, + code=bytes(runtime_code), + ), + }, + ) + + +def test_factory_forwards_value( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """`CALLVALUE` is forwarded from the factory to the created contract.""" + salt = 0x1234 + runtime_code = Op.STOP + initcode = Initcode(deploy_code=runtime_code) + expected_address = compute_create2_address(FACTORY, salt, initcode) + forwarded_value = 0xBA1 + + storage = Storage() + caller = pre.deploy_contract( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.SSTORE( + storage.store_next(1, "factory_call_success"), + Op.CALL( + gas=Op.GAS, + address=FACTORY, + value=forwarded_value, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0, + ret_size=32, + ), + ) + + Op.STOP, + balance=forwarded_value, + ) + + state_test( + pre=pre, + tx=Transaction( + sender=pre.fund_eoa(), + to=caller, + data=Hash(salt) + bytes(initcode), + gas_limit=500_000, + ), + post={ + caller: Account(storage=storage, balance=0), + expected_address: Account( + nonce=1, + balance=forwarded_value, + code=bytes(runtime_code), + ), + }, + ) + + +@pytest.mark.parametrize( + "calldata", + [ + pytest.param(b"", id="empty"), + pytest.param(b"\x00", id="one_byte"), + pytest.param(b"\xff" * 31, id="thirty_one_bytes"), + ], +) +def test_factory_reverts_short_calldata( + state_test: StateTestFiller, + pre: Alloc, + calldata: bytes, +) -> None: + """ + Calldata shorter than 32 bytes makes the factory revert with empty + return data and no contract is deployed. + """ + storage = Storage() + caller = pre.deploy_contract( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.SSTORE( + storage.store_next(0, "call_failed"), + Op.CALL( + gas=Op.GAS, + address=FACTORY, + value=0, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0, + ret_size=32, + ), + ) + + Op.SSTORE( + storage.store_next(0, "returndatasize"), + Op.RETURNDATASIZE, + ) + + Op.STOP, + ) + + state_test( + pre=pre, + tx=Transaction( + sender=pre.fund_eoa(), + to=caller, + data=calldata, + gas_limit=200_000, + ), + post={caller: Account(storage=storage)}, + ) + + +def test_factory_exactly_32_bytes( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Calldata of exactly 32 bytes (the salt alone, with no initcode) is the + boundary between revert and accept. The factory should deploy an empty + contract at the computed `CREATE2` address and return that address. + """ + salt = 0xAA + initcode = b"" + expected_address = compute_create2_address(FACTORY, salt, initcode) + + storage = Storage() + caller = pre.deploy_contract( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.SSTORE( + storage.store_next(1, "factory_call_success"), + Op.CALL( + gas=Op.GAS, + address=FACTORY, + value=0, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0, + ret_size=32, + ), + ) + + Op.SSTORE( + storage.store_next(expected_address, "returned_address"), + Op.MLOAD(0), + ) + + Op.STOP, + ) + + state_test( + pre=pre, + tx=Transaction( + sender=pre.fund_eoa(), + to=caller, + data=Hash(salt), + gas_limit=200_000, + ), + post={ + caller: Account(storage=storage), + expected_address: Account(nonce=1, code=b""), + }, + ) + + +def test_factory_propagates_initcode_revert( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + When the initcode reverts with data, the factory reverts with the same + return data, and no contract is deployed. + """ + salt = 0x99 + revert_data = bytes.fromhex("deadbeef") + b"\x00" * 28 + initcode = Op.MSTORE(0, int.from_bytes(revert_data, "big")) + Op.REVERT( + offset=0, size=32 + ) + expected_address = compute_create2_address(FACTORY, salt, initcode) + + storage = Storage() + caller = pre.deploy_contract( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.SSTORE( + storage.store_next(0, "factory_call_failed"), + Op.CALL( + gas=Op.GAS, + address=FACTORY, + value=0, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0, + ret_size=32, + ), + ) + + Op.SSTORE( + storage.store_next(32, "returndatasize"), + Op.RETURNDATASIZE, + ) + + Op.RETURNDATACOPY(0, 0, Op.RETURNDATASIZE) + + Op.SSTORE( + storage.store_next( + int.from_bytes(revert_data, "big"), "revert_payload" + ), + Op.MLOAD(0), + ) + + Op.STOP, + ) + + state_test( + pre=pre, + tx=Transaction( + sender=pre.fund_eoa(), + to=caller, + data=Hash(salt) + bytes(initcode), + gas_limit=500_000, + ), + post={ + caller: Account(storage=storage), + expected_address: Account.NONEXISTENT, + }, + ) + + +def test_factory_address_collision_reverts( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + A second deployment to the same `CREATE2` target reverts. `CREATE2` + fails when the destination already has code, returns 0, and the factory + reverts with the (empty) creation-frame return data. + """ + salt = 0x77 + runtime_code = Op.STOP + initcode = Initcode(deploy_code=runtime_code) + target = compute_create2_address(FACTORY, salt, initcode) + + storage = Storage() + caller = pre.deploy_contract( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.SSTORE( + storage.store_next(1, "first_call_success"), + Op.CALL( + gas=Op.GAS, + address=FACTORY, + value=0, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0x100, + ret_size=32, + ), + ) + + Op.SSTORE( + storage.store_next(0, "second_call_failed"), + Op.CALL( + gas=Op.GAS, + address=FACTORY, + value=0, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0x100, + ret_size=32, + ), + ) + + Op.STOP, + ) + + state_test( + pre=pre, + tx=Transaction( + sender=pre.fund_eoa(), + to=caller, + data=Hash(salt) + bytes(initcode), + gas_limit=1_000_000, + ), + post={ + caller: Account(storage=storage), + target: Account(nonce=1, code=bytes(runtime_code)), + }, + ) + + +def test_factory_different_salts_produce_different_addresses( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Two calls to the factory with the same initcode but different salts + must deploy at distinct, salt-derived addresses, proving the salt is + actually plumbed through to `CREATE2`. + """ + salt_a = 0x11 + salt_b = 0x22 + runtime_code = Op.PUSH1(0x01) + Op.PUSH1(0x00) + Op.RETURN + initcode = Initcode(deploy_code=runtime_code) + addr_a = compute_create2_address(FACTORY, salt_a, initcode) + addr_b = compute_create2_address(FACTORY, salt_b, initcode) + assert addr_a != addr_b + + initcode_offset = 32 + args_size = initcode_offset + len(bytes(initcode)) + + caller = pre.deploy_contract( + Op.CALLDATACOPY(initcode_offset, 0, Op.CALLDATASIZE) + + Op.MSTORE(0, salt_a) + + Op.SSTORE( + 0, + Op.CALL( + gas=Op.GAS, + address=FACTORY, + value=0, + args_offset=0, + args_size=args_size, + ret_offset=0x200, + ret_size=32, + ), + ) + + Op.SSTORE(1, Op.MLOAD(0x200)) + + Op.MSTORE(0, salt_b) + + Op.SSTORE( + 2, + Op.CALL( + gas=Op.GAS, + address=FACTORY, + value=0, + args_offset=0, + args_size=args_size, + ret_offset=0x200, + ret_size=32, + ), + ) + + Op.SSTORE(3, Op.MLOAD(0x200)) + + Op.STOP, + ) + + state_test( + pre=pre, + tx=Transaction( + sender=pre.fund_eoa(), + to=caller, + data=bytes(initcode), + gas_limit=1_000_000, + ), + post={ + caller: Account( + storage={0: 1, 1: addr_a, 2: 1, 3: addr_b}, + ), + addr_a: Account(nonce=1, code=bytes(runtime_code)), + addr_b: Account(nonce=1, code=bytes(runtime_code)), + }, + ) + + +def test_factory_direct_eoa_call( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + A transaction sent directly to the factory address (no relay contract) + deploys at the expected `CREATE2` address. + """ + salt = 0xCAFE + runtime_code = Op.PUSH1(0x01) + Op.PUSH1(0x00) + Op.RETURN + initcode = Initcode(deploy_code=runtime_code) + expected_address = compute_create2_address(FACTORY, salt, initcode) + + state_test( + pre=pre, + tx=Transaction( + sender=pre.fund_eoa(), + to=FACTORY, + data=Hash(salt) + bytes(initcode), + gas_limit=200_000, + ), + post={ + expected_address: Account(nonce=1, code=bytes(runtime_code)), + }, + ) + + +def test_factory_staticcall_reverts( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Calling the factory via `STATICCALL` fails because `CREATE2` requires a + writable context. No contract is deployed. + """ + salt = 0x33 + runtime_code = Op.STOP + initcode = Initcode(deploy_code=runtime_code) + expected_address = compute_create2_address(FACTORY, salt, initcode) + + storage = Storage() + caller = pre.deploy_contract( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.SSTORE( + storage.store_next(0, "staticcall_failed"), + Op.STATICCALL( + gas=Op.GAS, + address=FACTORY, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0x100, + ret_size=32, + ), + ) + + Op.STOP, + ) + + state_test( + pre=pre, + tx=Transaction( + sender=pre.fund_eoa(), + to=caller, + data=Hash(salt) + bytes(initcode), + gas_limit=500_000, + ), + post={ + caller: Account(storage=storage), + expected_address: Account.NONEXISTENT, + }, + ) + + +@pytest.mark.parametrize("call_opcode", [Op.DELEGATECALL, Op.CALLCODE]) +def test_factory_in_caller_context( + state_test: StateTestFiller, + pre: Alloc, + call_opcode: Op, +) -> None: + """ + Under `DELEGATECALL` or `CALLCODE`, the factory's bytecode runs in the + caller's context, so `CREATE2`'s deployer is the caller — not the + factory. The contract is deployed at the address derived from the + caller, and the factory-derived address is empty. + """ + salt = 0x44 + runtime_code = Op.STOP + initcode = Initcode(deploy_code=runtime_code) + factory_derived = compute_create2_address(FACTORY, salt, initcode) + + if call_opcode is Op.CALLCODE: + call_op = Op.CALLCODE( + gas=Op.GAS, + address=FACTORY, + value=0, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0x100, + ret_size=32, + ) + else: + call_op = Op.DELEGATECALL( + gas=Op.GAS, + address=FACTORY, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0x100, + ret_size=32, + ) + + caller = pre.deploy_contract( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.SSTORE(0, call_op) + + Op.SSTORE(1, Op.MLOAD(0x100)) + + Op.STOP, + ) + caller_derived = compute_create2_address(caller, salt, initcode) + + state_test( + pre=pre, + tx=Transaction( + sender=pre.fund_eoa(), + to=caller, + data=Hash(salt) + bytes(initcode), + gas_limit=500_000, + ), + post={ + caller: Account(storage={0: 1, 1: caller_derived}), + caller_derived: Account(nonce=1, code=bytes(runtime_code)), + factory_derived: Account.NONEXISTENT, + }, + ) + + +def test_factory_caller_revert_rolls_back_deployment( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + A successful factory deployment is rolled back when the calling frame + reverts. Standard EVM semantics, but worth pinning explicitly. + """ + salt = 0x55 + runtime_code = Op.STOP + initcode = Initcode(deploy_code=runtime_code) + expected_address = compute_create2_address(FACTORY, salt, initcode) + + inner = pre.deploy_contract( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.POP( + Op.CALL( + gas=Op.GAS, + address=FACTORY, + value=0, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0x100, + ret_size=32, + ) + ) + + Op.REVERT(0, 0), + ) + + storage = Storage() + caller = pre.deploy_contract( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.SSTORE( + storage.store_next(0, "inner_reverted"), + Op.CALL( + gas=Op.GAS, + address=inner, + value=0, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0, + ret_size=0, + ), + ) + + Op.STOP, + ) + + state_test( + pre=pre, + tx=Transaction( + sender=pre.fund_eoa(), + to=caller, + data=Hash(salt) + bytes(initcode), + gas_limit=1_000_000, + ), + post={ + caller: Account(storage=storage), + expected_address: Account.NONEXISTENT, + }, + ) + + +@pytest.mark.pre_alloc_mutable +def test_factory_deploys_to_pre_funded_address( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + `CREATE2` to an address that has only a balance (no code, no storage, + nonce 0) succeeds and preserves the existing balance. + """ + salt = 0x66 + runtime_code = Op.STOP + initcode = Initcode(deploy_code=runtime_code) + expected_address = compute_create2_address(FACTORY, salt, initcode) + pre_balance = 0xBA1 + + pre[expected_address] = Account(balance=pre_balance) + + storage = Storage() + caller = pre.deploy_contract( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.SSTORE( + storage.store_next(1, "factory_call_success"), + Op.CALL( + gas=Op.GAS, + address=FACTORY, + value=0, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0x100, + ret_size=32, + ), + ) + + Op.STOP, + ) + + state_test( + pre=pre, + tx=Transaction( + sender=pre.fund_eoa(), + to=caller, + data=Hash(salt) + bytes(initcode), + gas_limit=500_000, + ), + post={ + caller: Account(storage=storage), + expected_address: Account( + nonce=1, + balance=pre_balance, + code=bytes(runtime_code), + ), + }, + ) + + +@pytest.mark.parametrize( + "use_access_list,expected_delta", + [ + pytest.param(False, 2500, id="without_access_list"), + pytest.param(True, 0, id="with_access_list"), + ], +) +def test_factory_access_list_prewarming( + state_test: StateTestFiller, + pre: Alloc, + use_access_list: bool, + expected_delta: int, +) -> None: + """ + Measure the gas-cost difference between a first and second `CALL` to + the factory in the same transaction. Both calls send empty calldata + so the factory reverts immediately, eliminating variability from the + inner `CREATE2`. + + - Without access list: difference is 2,500. + - With access list including the factory: difference is 0. + """ + # Identical measurement block around each call: GAS, CALL, POP, GAS, + # SWAP1, SUB. Leaves the call's gas cost on top of the stack. Doing + # the same operations on both sides means any opcode overhead cancels + # out exactly in the difference. + measure = ( + Op.GAS + + Op.POP( + Op.CALL( + gas=Op.GAS, + address=FACTORY, + value=0, + args_offset=0, + args_size=0, + ret_offset=0x100, + ret_size=0, + ) + ) + + Op.GAS + + Op.SWAP1 + + Op.SUB + ) + + storage = Storage() + caller = pre.deploy_contract( + # First measurement: stack ends as [cost1]. + measure + # Second measurement: stack ends as [cost2, cost1]. + + measure + # delta = cost1 - cost2. + + Op.SWAP1 + + Op.SUB + # Stack: [delta]. SSTORE pops [key, value], so push the key. + + Op.PUSH1(storage.store_next(expected_delta, "first_minus_second")) + + Op.SSTORE + + Op.STOP, + ) + + access_list = ( + [AccessList(address=FACTORY, storage_keys=[])] + if use_access_list + else None + ) + + state_test( + pre=pre, + tx=Transaction( + sender=pre.fund_eoa(), + to=caller, + gas_limit=500_000, + access_list=access_list, + ), + post={caller: Account(storage=storage)}, + ) + + +@pytest.mark.pre_alloc_mutable +def test_factory_receives_balance_via_selfdestruct( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + `SELFDESTRUCT` to the factory transfers the originator's balance to + `0x12`. The factory's other state is untouched: same nonce, same code. + Calling the factory after the transfer still works. + + Tests that the factory address has no special handling under + `SELFDESTRUCT` — it behaves like any other contract beneficiary. + """ + forwarded_value = 0xBA1 + + sd_actor = pre.deploy_contract( + Op.SELFDESTRUCT(FACTORY), + balance=forwarded_value, + ) + + salt = 0x88 + runtime_code = Op.STOP + initcode = Initcode(deploy_code=runtime_code) + expected_address = compute_create2_address(FACTORY, salt, initcode) + + storage = Storage() + caller = pre.deploy_contract( + Op.POP(Op.CALL(gas=Op.GAS, address=sd_actor)) + + Op.SSTORE( + storage.store_next(forwarded_value, "factory_balance_after_sd"), + Op.BALANCE(FACTORY), + ) + + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.SSTORE( + storage.store_next(1, "factory_call_success"), + Op.CALL( + gas=Op.GAS, + address=FACTORY, + value=0, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0x100, + ret_size=32, + ), + ) + + Op.STOP, + ) + + state_test( + pre=pre, + tx=Transaction( + sender=pre.fund_eoa(), + to=caller, + data=Hash(salt) + bytes(initcode), + gas_limit=1_000_000, + ), + post={ + caller: Account(storage=storage), + FACTORY: Account( + nonce=2, + balance=forwarded_value, + code=Spec.FACTORY_BYTECODE, + ), + expected_address: Account( + nonce=1, + code=bytes(runtime_code), + ), + }, + ) diff --git a/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_factory_transition.py b/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_factory_transition.py new file mode 100644 index 00000000000..b6231bc197d --- /dev/null +++ b/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_factory_transition.py @@ -0,0 +1,99 @@ +""" +Fork-transition tests for [EIP-7997: Deterministic Factory Predeploy](https://eips.ethereum.org/EIPS/eip-7997). +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Hash, + Initcode, + Op, + Storage, + Transaction, + compute_create2_address, +) + +from .spec import Spec, ref_spec_7997 + +REFERENCE_SPEC_GIT_PATH = ref_spec_7997.git_path +REFERENCE_SPEC_VERSION = ref_spec_7997.version + +FACTORY = Spec.FACTORY_ADDRESS + + +@pytest.mark.valid_at_transition_to("EIP7997") +@pytest.mark.pre_alloc_mutable +def test_factory_absent_pre_fork( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + Verify pre-Amsterdam blocks observe no contract at the factory address + (calls to `0x12` look like calls to an empty EOA). + + The test framework auto-allocates the factory at genesis when the + destination fork is Amsterdam; this test uses `pre_alloc_mutable` to + clear that allocation so the pre-fork block sees the address as it + actually was before EIP-7997. + + Note: this test does not assert that the factory APPEARS at the + Amsterdam-side block because the t8n tool does not invoke apply_fork + at transition time. With the genesis allocation cleared, the Amsterdam + block would also observe a missing factory — verifying that case would + contradict the EIP's intent. Post-fork factory behavior is covered by + the regular tests in test_factory.py (which rely on the auto-allocated + predeploy). + """ + pre[FACTORY] = Account(code=b"", nonce=0, balance=0) + + salt = 0x42 + runtime = Op.STOP + initcode = Initcode(deploy_code=runtime) + expected_address = compute_create2_address(FACTORY, salt, initcode) + + storage = Storage() + caller = pre.deploy_contract( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.SSTORE( + storage.store_next(1, "call_to_empty_eoa_succeeds"), + Op.CALL( + gas=Op.GAS, + address=FACTORY, + value=0, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0x100, + ret_size=32, + ), + ) + + Op.SSTORE( + storage.store_next(0, "no_returndata"), + Op.RETURNDATASIZE, + ) + + Op.STOP, + ) + + pre_block = Block( + timestamp=14_999, + txs=[ + Transaction( + sender=pre.fund_eoa(), + to=caller, + data=Hash(salt) + bytes(initcode), + gas_limit=500_000, + ) + ], + ) + + blockchain_test( + pre=pre, + blocks=[pre_block], + post={ + caller: Account(storage=storage), + expected_address: Account.NONEXISTENT, + FACTORY: Account.NONEXISTENT, + }, + ) diff --git a/tests/frontier/precompiles/test_precompile_absence.py b/tests/frontier/precompiles/test_precompile_absence.py index c0e28b79750..9d99afc95fc 100644 --- a/tests/frontier/precompiles/test_precompile_absence.py +++ b/tests/frontier/precompiles/test_precompile_absence.py @@ -37,11 +37,14 @@ def test_precompile_absence( fork. """ active_precompiles = fork.precompiles() + factory_address = fork.deterministic_factory_predeploy_address() storage = Storage() call_code = Bytecode() for address in range(1, UPPER_BOUND + 1): if Address(address) in active_precompiles: continue + if factory_address is not None and Address(address) == factory_address: + continue call_code += Op.SSTORE( address, Op.CALL(gas=0, address=address, args_size=calldata_size), diff --git a/tests/frontier/precompiles/test_precompiles.py b/tests/frontier/precompiles/test_precompiles.py index dce0628b465..e6647ac8220 100644 --- a/tests/frontier/precompiles/test_precompiles.py +++ b/tests/frontier/precompiles/test_precompiles.py @@ -30,14 +30,19 @@ def precompile_addresses(fork: Fork) -> Iterator[Tuple[Address, bool]]: """ supported_precompiles = fork.precompiles() + factory_address = fork.deterministic_factory_predeploy_address() for address in supported_precompiles: address_int = int.from_bytes(address, byteorder="big") yield (address, True) if address_int > 0 and (address_int - 1) not in supported_precompiles: - yield (Address(address_int - 1), False) + below = Address(address_int - 1) + if factory_address is None or below != factory_address: + yield (below, False) if (address_int + 1) not in supported_precompiles: - yield (Address(address_int + 1), False) + above = Address(address_int + 1) + if factory_address is None or above != factory_address: + yield (above, False) @pytest.mark.ported_from( diff --git a/tests/ported_static/stPreCompiledContracts/test_precomps_eip2929_cancun.py b/tests/ported_static/stPreCompiledContracts/test_precomps_eip2929_cancun.py index 6bc03947432..f2ba4fdc3da 100644 --- a/tests/ported_static/stPreCompiledContracts/test_precomps_eip2929_cancun.py +++ b/tests/ported_static/stPreCompiledContracts/test_precomps_eip2929_cancun.py @@ -4221,9 +4221,35 @@ def test_precomps_eip2929_cancun( "network": [">=Cancun"], "result": {target: Account(storage={0: 0, 1: 25000})}, }, + # CALLs to 0x12 at data indexes 38, 80, 122 split per-fork: + # before Amsterdam, 0x12 is empty so the CALL with value pays the + # 25,000-gas NEW_ACCOUNT charge (EIP-150) on top of the 2,500 base + # cold cost. On Amsterdam+, the EIP-7997 deterministic factory + # predeploy at 0x12 means the account exists, so NEW_ACCOUNT no + # longer applies and only the 2,500 base cost is observed. { "indexes": { - "data": [38, 39, 80, 81, 122, 123], + "data": [38, 80, 122], + "gas": -1, + "value": -1, + }, + "network": [">=Cancun=Amsterdam"], + "result": {target: Account(storage={0: 0, 1: 2500})}, + }, + # CALLs to 0x100000 (data indexes 39, 81, 123): empty on every + # fork, gas math unchanged. + { + "indexes": { + "data": [39, 81, 123], "gas": -1, "value": -1, },