diff --git a/packages/testing/src/execution_testing/forks/forks/eips/bogota/eip_8272.py b/packages/testing/src/execution_testing/forks/forks/eips/bogota/eip_8272.py new file mode 100644 index 00000000000..05a8c46da46 --- /dev/null +++ b/packages/testing/src/execution_testing/forks/forks/eips/bogota/eip_8272.py @@ -0,0 +1,55 @@ +""" +EIP-8272: Recent Roots for Frame Transactions. + +Add the recent root contract, an ordinary contract deployed by a +pre-signed creation transaction, that stores application roots by source +and slot and lets a frame transaction verify recent roots through a +`VERIFY` frame targeting it. The transaction payload, gas rules and +opcodes are unchanged. + +https://eips.ethereum.org/EIPS/eip-8272 +""" + +from typing import Mapping + +from ....base_fork import BaseFork + +RECENT_ROOT_ADDRESS = 0x8272D9679689EA2F307140CDF9002D27DC00FFFF +RECENT_ROOT_BYTECODE = bytes.fromhex( + "346100ba57366040146100c05736604836066100ba5780156100ba57610480" + "81116100ba574b60005b602081013560c01c828110156100ba578083036120" + "0011156100ba577f8f42481679c8e6fefa040974b3c905e0ce3f2e464ba93a" + "cdb074a41181617efc60005260488260203760686000207fbdc897da2177d2" + "60ff5f4be5d4b2aad43f89c3347a305b584fa5a2546d053daa60005290611f" + "ff1660c01b60405260486000205414156100ba5760480182811061002857005b" + "60006000fd5b33600052602060006020376034600c20807f8f42481679c8e6" + "fefa040974b3c905e0ce3f2e464ba93acdb074a41181617efc6040524b6068" + "52606052602060206088376068604020817fbdc897da2177d260ff5f4be5d4" + "b2aad43f89c3347a305b584fa5a2546d053daa60a852611fff4b1660d05260" + "c852604860a8205500" +) +RECENT_ROOT_NONCE = 1 + + +class EIP8272(BaseFork): + """EIP-8272 class.""" + + @classmethod + def pre_allocation(cls) -> Mapping: + """Pre-allocate the recent root contract.""" + return { + RECENT_ROOT_ADDRESS: { + "nonce": RECENT_ROOT_NONCE, + "code": RECENT_ROOT_BYTECODE, + } + } | super(EIP8272, cls).pre_allocation() # type: ignore + + @classmethod + def pre_allocation_blockchain(cls) -> Mapping: + """Pre-allocate the recent root contract.""" + return { + RECENT_ROOT_ADDRESS: { + "nonce": RECENT_ROOT_NONCE, + "code": RECENT_ROOT_BYTECODE, + } + } | super(EIP8272, cls).pre_allocation_blockchain() # type: ignore diff --git a/src/ethereum/forks/bogota/transactions/frame_transaction.py b/src/ethereum/forks/bogota/transactions/frame_transaction.py index 52f78f1242e..effd528f00e 100644 --- a/src/ethereum/forks/bogota/transactions/frame_transaction.py +++ b/src/ethereum/forks/bogota/transactions/frame_transaction.py @@ -85,6 +85,15 @@ big-endian expiry timestamp. """ +RECENT_ROOT_ADDRESS: Final[Address] = Address( + bytes.fromhex("8272d9679689ea2f307140cdf9002d27dc00ffff") +) +""" +Address of the recent root contract of [EIP-8272]. + +[EIP-8272]: https://eips.ethereum.org/EIPS/eip-8272 +""" + @final class FrameMode(UintEnum, boundary=STRICT): diff --git a/tests/bogota/eip8272_recent_roots/__init__.py b/tests/bogota/eip8272_recent_roots/__init__.py new file mode 100644 index 00000000000..54a32d938b1 --- /dev/null +++ b/tests/bogota/eip8272_recent_roots/__init__.py @@ -0,0 +1,6 @@ +""" +Tests for [EIP-8272: Recent Roots for Frame Transactions](https://eips.ethereum.org/EIPS/eip-8272). + +EIP-8272 is a delta against EIP-8141, so its tests build on the frame +transaction helpers of `tests/bogota/eip8141_frame_transactions`. +""" # noqa: E501 diff --git a/tests/bogota/eip8272_recent_roots/helpers.py b/tests/bogota/eip8272_recent_roots/helpers.py new file mode 100644 index 00000000000..d54ea51e294 --- /dev/null +++ b/tests/bogota/eip8272_recent_roots/helpers.py @@ -0,0 +1,106 @@ +"""Helpers for EIP-8272 recent root tests.""" + +from typing import Any, Dict, Sequence + +from execution_testing import Fork, Frame, Op + +from ..eip8141_frame_transactions.spec import Spec as FrameSpec +from .spec import Spec + +VALIDATION_FIXED_GAS = 119 +""" +Execution gas of the validation operation outside the tuple loop: the +value check, the calldata length dispatch and the three length checks, +reading the current slot and the loop offset, and the first tuple's +memory expansion to the four words its entry preimage occupies. +""" + +VALIDATION_TUPLE_GAS = 296 +""" +Execution gas of one tuple's successful path through the validation +loop, excluding its storage read: decoding the slot, the two slot +checks, hashing the entry preimage and the storage key preimage, the +comparison and the loop step. Measured against `RECENT_ROOT_CODE` and +confirmed by counting its assembly; re-derive both constants if the +canonical code changes. +""" + + +def validation_gas( + fork: Fork, *, tuples: int, cold_keys: int, target_warm: bool = False +) -> int: + """ + Return the execution gas a recent root verifier frame reports for + `tuples` references of which `cold_keys` read distinct storage keys + for the first time in the transaction. + + The frame charges its target's access at entry, then the contract's + fixed cost, its per-tuple cost, and one warm or cold storage read + per tuple. + """ + warm_keys = tuples - cold_keys + return ( + fork.frame_entry_gas_calculator()(target_warm=target_warm) + + VALIDATION_FIXED_GAS + + tuples * VALIDATION_TUPLE_GAS + + cold_keys + * Op.SLOAD.with_metadata(key_warm=False).execution_cost(fork) + + warm_keys + * Op.SLOAD.with_metadata(key_warm=True).execution_cost(fork) + ) + + +def recent_root_frame( + references: Sequence[bytes] | bytes, **overrides: Any +) -> Frame: + """ + Return a recent root verifier frame: a `VERIFY` frame targeting the + recent root contract with no flags, no value, no state gas and the + concatenated validation tuples as data. + + Keyword arguments override the corresponding frame fields, for + variants that differ from the canonical frame in a single field. + """ + data = ( + bytes(references) + if isinstance(references, (bytes, bytearray)) + else b"".join(references) + ) + kwargs: Dict[str, Any] = dict( + mode=FrameSpec.MODE_VERIFY, + flags=FrameSpec.APPROVE_NONE, + target=Spec.RECENT_ROOT_ADDRESS, + state_gas_limit=0, + data=data, + ) + kwargs.update(overrides) + return Frame(**kwargs) + + +def write_state_gas(fork: Fork, *, writes: int = 1) -> int: + """ + Return the state gas budget for `writes` fresh storage sets: a + published entry is one, and a relay that also records its own + witness slot needs a second. + """ + return writes * Op.SSTORE(0, 1).state_cost(fork) + + +def write_frame( + fork: Fork, salt: bytes, root: bytes, **overrides: Any +) -> Frame: + """ + Return a `SENDER` frame publishing `root` under `salt` for the + transaction sender: it calls the recent root contract with the 64-byte + write encoding and a state gas budget covering the entry's creation. + + Keyword arguments override the corresponding frame fields. + """ + kwargs: Dict[str, Any] = dict( + mode=FrameSpec.MODE_SENDER, + target=Spec.RECENT_ROOT_ADDRESS, + state_gas_limit=write_state_gas(fork), + data=bytes(salt) + bytes(root), + ) + kwargs.update(overrides) + return Frame(**kwargs) diff --git a/tests/bogota/eip8272_recent_roots/recent_root_deploy_tx.json b/tests/bogota/eip8272_recent_roots/recent_root_deploy_tx.json new file mode 100644 index 00000000000..7b31a691035 --- /dev/null +++ b/tests/bogota/eip8272_recent_roots/recent_root_deploy_tx.json @@ -0,0 +1,16 @@ +{ + "type": "0x0", + "nonce": "0x0", + "to": null, + "gas": "0x13d620", + "gasPrice": "0xe8d4a51000", + "maxPriorityFeePerGas": null, + "maxFeePerGas": null, + "value": "0x0", + "input": "0x61014080600a5f395ff3346100ba57366040146100c05736604836066100ba5780156100ba5761048081116100ba574b60005b602081013560c01c828110156100ba5780830361200011156100ba577f8f42481679c8e6fefa040974b3c905e0ce3f2e464ba93acdb074a41181617efc60005260488260203760686000207fbdc897da2177d260ff5f4be5d4b2aad43f89c3347a305b584fa5a2546d053daa60005290611fff1660c01b60405260486000205414156100ba5760480182811061002857005b60006000fd5b33600052602060006020376034600c20807f8f42481679c8e6fefa040974b3c905e0ce3f2e464ba93acdb074a41181617efc6040524b606852606052602060206088376068604020817fbdc897da2177d260ff5f4be5d4b2aad43f89c3347a305b584fa5a2546d053daa60a852611fff4b1660d05260c852604860a8205500", + "v": "0x1b", + "r": "0x539", + "s": "0xfadf66b1e192785c", + "hash": "0x56c2adbbfa3ba5dfe47adf48828f6c581134e39e14c3b80e06214de5e5875272", + "sender": "0x14bf16d4c9842bf1EbF396e553477C66EB0a8A82" +} diff --git a/tests/bogota/eip8272_recent_roots/spec.py b/tests/bogota/eip8272_recent_roots/spec.py new file mode 100644 index 00000000000..cc340d3cc51 --- /dev/null +++ b/tests/bogota/eip8272_recent_roots/spec.py @@ -0,0 +1,118 @@ +"""Defines EIP-8272 specification constants and types.""" + +from dataclasses import dataclass + +from execution_testing import Address, keccak256 + + +@dataclass(frozen=True) +class ReferenceSpec: + """Defines the reference spec version and git path.""" + + git_path: str + version: str + + +ref_spec_8272 = ReferenceSpec( + "EIPS/eip-8272.md", "0acc21ad8359be83c0929c7cc70e45326be8acff" +) + + +@dataclass(frozen=True) +class Spec: + """ + Parameters from the EIP-8272 specification as defined at + https://eips.ethereum.org/EIPS/eip-8272. + """ + + RECENT_ROOT_ADDRESS = Address(0x8272D9679689EA2F307140CDF9002D27DC00FFFF) + RECENT_ROOT_DEPLOYER = Address(0x14BF16D4C9842BF1EBF396E553477C66EB0A8A82) + RECENT_ROOT_CODE = bytes.fromhex( + "346100ba57366040146100c05736604836066100ba5780156100ba57610480" + "81116100ba574b60005b602081013560c01c828110156100ba578083036120" + "0011156100ba577f8f42481679c8e6fefa040974b3c905e0ce3f2e464ba93a" + "cdb074a41181617efc60005260488260203760686000207fbdc897da2177d2" + "60ff5f4be5d4b2aad43f89c3347a305b584fa5a2546d053daa60005290611f" + "ff1660c01b60405260486000205414156100ba5760480182811061002857005b" + "60006000fd5b33600052602060006020376034600c20807f8f42481679c8e6" + "fefa040974b3c905e0ce3f2e464ba93acdb074a41181617efc6040524b6068" + "52606052602060206088376068604020817fbdc897da2177d260ff5f4be5d4" + "b2aad43f89c3347a305b584fa5a2546d053daa60a852611fff4b1660d05260" + "c852604860a8205500" + ) + RECENT_ROOT_NONCE = 1 + RECENT_ROOT_LENGTH = 8192 + RECENT_ROOT_USABLE_WINDOW = 8191 + MAX_RECENT_ROOT_REFERENCES = 16 + RECENT_ROOT_TUPLE_BYTES = 72 + RECENT_ROOT_WRITE_BYTES = 64 + RECENT_ROOT_ENTRY_DOMAIN = keccak256(b"RECENT_ROOT_ENTRY") + RECENT_ROOT_STORAGE_DOMAIN = keccak256(b"RECENT_ROOT_STORAGE") + + # Reference vector from the EIP, valid at `current_slot = 2`. + VECTOR_SOURCE_ADDRESS = Address(0x01) + VECTOR_SALT = bytes(32) + VECTOR_SLOT = 1 + VECTOR_ROOT = (2).to_bytes(32, "big") + VECTOR_CURRENT_SLOT = 2 + VECTOR_SOURCE_ID = bytes.fromhex( + "b9382d35273c75a50631a3e84d3c75ec9266e2b18c35a627e16cdbf26a18ca85" + ) + VECTOR_ENTRY_HASH = bytes.fromhex( + "0a0d1254c851be5a133b4c9a9e300f5602fc0f43dbe65aa6a66930d4ca0a51b8" + ) + VECTOR_STORAGE_KEY = bytes.fromhex( + "5f027aa1cbe2df279bf6518edd4b44ea5409fd800189ec35224e10ab05e574c3" + ) + + +def source_id(source_address: Address, salt: bytes) -> bytes: + """ + Return the root source identifier: the keccak256 of the 20-byte source + address followed by the 32-byte salt. + """ + return bytes(keccak256(bytes(source_address) + bytes(salt))) + + +def entry_hash(source: bytes, slot: int, root: bytes) -> bytes: + """ + Return the committed entry for `(source_id, slot, root)`: the keccak256 + of the entry domain, the source identifier, the slot as an eight-byte + big-endian integer, and the root. + """ + return bytes( + keccak256( + bytes(Spec.RECENT_ROOT_ENTRY_DOMAIN) + + bytes(source) + + slot.to_bytes(8, "big") + + bytes(root) + ) + ) + + +def storage_key(source: bytes, slot: int) -> int: + """ + Return the recent root storage key holding the entry of `slot` for a + source: the keccak256 of the storage domain, the source identifier, + and the ring buffer index `slot mod RECENT_ROOT_LENGTH` as an + eight-byte big-endian integer. + """ + index = slot % Spec.RECENT_ROOT_LENGTH + return int.from_bytes( + keccak256( + bytes(Spec.RECENT_ROOT_STORAGE_DOMAIN) + + bytes(source) + + index.to_bytes(8, "big") + ), + "big", + ) + + +def validation_tuple(source: bytes, slot: int, root: bytes) -> bytes: + """Return the 72-byte validation encoding of `(source_id, slot, root)`.""" + return bytes(source) + slot.to_bytes(8, "big") + bytes(root) + + +def write_calldata(salt: bytes, root: bytes) -> bytes: + """Return the 64-byte write encoding of `(salt, root)`.""" + return bytes(salt) + bytes(root) diff --git a/tests/bogota/eip8272_recent_roots/test_contract_deployment.py b/tests/bogota/eip8272_recent_roots/test_contract_deployment.py new file mode 100644 index 00000000000..639801a1544 --- /dev/null +++ b/tests/bogota/eip8272_recent_roots/test_contract_deployment.py @@ -0,0 +1,175 @@ +""" +Deployment tests for the recent root contract of +[EIP-8272: Recent Roots for Frame Transactions](https://eips.ethereum.org/EIPS/eip-8272). + +The recent root contract is an ordinary contract deployed by the +pre-signed creation transaction published in the EIP, from a synthetic +sender whose only transaction it is. The protocol does not install it at +activation, so these tests deploy it with that transaction before, at, +and after the fork block, and check that it lands at +`Spec.RECENT_ROOT_ADDRESS` with the canonical runtime code, that a root +published in the deployment block verifies in the next, and that a +verifier frame finds no contract before the deployment. +""" + +from os.path import realpath +from pathlib import Path +from typing import Generator + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + DeploymentTestType, + Environment, + FrameReceipt, + Op, + StateTestFiller, + Transaction, + TransactionException, + TransactionReceipt, + generate_system_contract_deploy_test, +) +from execution_testing.checklists import EIPChecklist +from execution_testing.forks import Bogota, TransitionFork + +from ..eip8141_frame_transactions.helpers import sender_frame, verify_frame +from ..eip8141_frame_transactions.spec import Spec as FrameSpec +from .helpers import recent_root_frame, write_frame +from .spec import ( + Spec, + entry_hash, + ref_spec_8272, + source_id, + storage_key, + validation_tuple, +) + +REFERENCE_SPEC_GIT_PATH = ref_spec_8272.git_path +REFERENCE_SPEC_VERSION = ref_spec_8272.version + +SLOT_EXECUTED = 0x01 +"""Storage slot used by target contracts to record execution.""" + +SALT = bytes(32) +ROOT = (0xF0).to_bytes(32, "big") + +WRITE_SLOT = 1_000 +""" +Slot of the block publishing the root, past the slots of the generated +deployment blocks. +""" + + +@EIPChecklist.SystemContract.Test.Deployment.Address() +@EIPChecklist.SystemContract.Test.Deployment.Missing() +@generate_system_contract_deploy_test( + fork=Bogota, + tx_json_path=Path(realpath(__file__)).parent + / "recent_root_deploy_tx.json", + expected_deploy_address=Spec.RECENT_ROOT_ADDRESS, + fail_on_empty_code=False, +) +def test_recent_root_contract_deployment( + *, + fork: TransitionFork, + pre: Alloc, + post: Alloc, + test_type: DeploymentTestType, +) -> Generator[Block, None, None]: + """ + Verify the recent root contract deployment and exercise the contract. + + Once the deployment transaction has run, a root published in the + same block verifies through a recent root verifier frame in the next + block, which shows the deployed code is reachable as both a root + source target and a verifier frame target. A missing contract makes + no block invalid: nothing in the protocol calls it, so a block + without the contract is valid. + """ + sender = pre.fund_eoa() + target = pre.deploy_contract(code=Op.SSTORE(SLOT_EXECUTED, 1) + Op.STOP) + source = source_id(sender, SALT) + + publish = Transaction( + sender=sender, + frames=[ + verify_frame(), + write_frame(fork.transitions_to(), SALT, ROOT), + ], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + ], + ), + ) + yield Block(txs=[publish], slot_number=WRITE_SLOT) + + write_slot = WRITE_SLOT + verify = Transaction( + sender=sender, + frames=[ + recent_root_frame(validation_tuple(source, write_slot, ROOT)), + verify_frame(), + sender_frame(target=target), + ], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt(status=FrameSpec.STATUS_SUCCESS) for _ in range(3) + ], + ), + ) + yield Block(txs=[verify], slot_number=WRITE_SLOT + 1) + + post[Spec.RECENT_ROOT_ADDRESS] = Account( + nonce=Spec.RECENT_ROOT_NONCE, + code=Spec.RECENT_ROOT_CODE, + storage={ + storage_key(source, write_slot): int.from_bytes( + entry_hash(source, write_slot, ROOT), "big" + ) + }, + ) + post[target] = Account(storage={SLOT_EXECUTED: 1}) + + +@pytest.mark.valid_from("Bogota") +@pytest.mark.pre_alloc_mutable +@pytest.mark.exception_test +def test_verifier_frame_before_deployment( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + A recent root verifier frame executed before the contract is + deployed finds no code at its target and runs EIP-8141's default + `VERIFY` code, which fails because the frame's flags approve + nothing; the transaction is invalid. + """ + pre[Spec.RECENT_ROOT_ADDRESS] = Account(nonce=0, balance=1) + sender = pre.fund_eoa() + target = pre.deploy_contract(code=Op.SSTORE(SLOT_EXECUTED, 1) + Op.STOP) + state_test( + env=Environment(slot_number=Spec.VECTOR_CURRENT_SLOT), + pre=pre, + tx=Transaction( + sender=sender, + frames=[ + recent_root_frame( + validation_tuple( + Spec.VECTOR_SOURCE_ID, + Spec.VECTOR_SLOT, + Spec.VECTOR_ROOT, + ) + ), + verify_frame(), + sender_frame(target=target), + ], + error=TransactionException.TYPE_6_INVALID_FRAME_EXECUTION, + ), + post={target: Account(storage={SLOT_EXECUTED: 0})}, + ) diff --git a/tests/bogota/eip8272_recent_roots/test_fork_transition.py b/tests/bogota/eip8272_recent_roots/test_fork_transition.py new file mode 100644 index 00000000000..cc2cb5917bc --- /dev/null +++ b/tests/bogota/eip8272_recent_roots/test_fork_transition.py @@ -0,0 +1,190 @@ +""" +Tests for +[EIP-8272: Recent Roots for Frame Transactions](https://eips.ethereum.org/EIPS/eip-8272) +across the fork that activates it. + +The recent root contract is ordinary code and every opcode it runs +exists before the fork, so a root can be published before frame +transactions do. A recent root verifier frame in the first block of the +fork references such a root exactly as it would one published after it: +the slot clock does not reset at the fork. +""" + +import json +from os.path import realpath +from pathlib import Path + +import pytest +from execution_testing import ( + Account, + Alloc, + BalAccountExpectation, + BalStorageChange, + BalStorageSlot, + Block, + BlockAccessListExpectation, + BlockchainTestFiller, + Fork, + FrameReceipt, + Op, + Transaction, + TransactionReceipt, +) + +from ..eip8141_frame_transactions.helpers import sender_frame, verify_frame +from ..eip8141_frame_transactions.spec import Spec as FrameSpec +from .helpers import recent_root_frame, validation_gas +from .spec import ( + Spec, + entry_hash, + ref_spec_8272, + source_id, + storage_key, + validation_tuple, + write_calldata, +) + +REFERENCE_SPEC_GIT_PATH = ref_spec_8272.git_path +REFERENCE_SPEC_VERSION = ref_spec_8272.version + +pytestmark = pytest.mark.valid_at_transition_to("Bogota") + +FORK_TIMESTAMP = 15_000 +"""Timestamp at which the transition fork activates EIP-8272.""" + +SLOT_EXECUTED = 0x01 +"""Storage slot used by target contracts to record execution.""" + +WRITE_SLOT = 500 +"""Slot of the last pre-fork block, in which the root is published.""" + +SALT = bytes(32) +ROOT = (0xF0).to_bytes(32, "big") + +DEPLOY_TX_PATH = Path(realpath(__file__)).parent / "recent_root_deploy_tx.json" + + +def deployment_transaction() -> Transaction: + """ + Return the pre-signed creation transaction the EIP publishes, as the + deployment test generator reads it. + """ + tx_json = json.loads(DEPLOY_TX_PATH.read_text()) + tx_json["gasLimit"] = tx_json.pop("gas") + tx_json["protected"] = False + return Transaction.model_validate(tx_json).with_signature_and_sender() + + +@pytest.mark.pre_alloc_mutable +def test_root_published_before_fork_verifies_after( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Deploy the contract and publish a root in the last pre-fork block, + then verify the root through a recent root verifier frame in the + block that activates the fork. + + The pre-fork block carries the deployment transaction itself, so the + predeploy the transition fixture places in genesis is removed first. + The write is a plain call from the pre-fork transaction types. + """ + pre[Spec.RECENT_ROOT_ADDRESS] = Account(code=b"", nonce=0, balance=0) + deploy_tx = deployment_transaction() + deployer = deploy_tx.sender + assert deployer == Spec.RECENT_ROOT_DEPLOYER + assert deploy_tx.created_contract == Spec.RECENT_ROOT_ADDRESS + gas_price = deploy_tx.gas_price + assert gas_price is not None + pre.fund_address(deployer, deploy_tx.gas_limit * gas_price) + + source = pre.fund_eoa() + sender = pre.fund_eoa() + target = pre.deploy_contract(code=Op.SSTORE(SLOT_EXECUTED, 1) + Op.STOP) + source_identifier = source_id(source, SALT) + key = storage_key(source_identifier, WRITE_SLOT) + value = int.from_bytes( + entry_hash(source_identifier, WRITE_SLOT, ROOT), "big" + ) + + publish = Transaction( + sender=source, + to=Spec.RECENT_ROOT_ADDRESS, + data=write_calldata(SALT, ROOT), + ) + verify = Transaction( + sender=sender, + frames=[ + recent_root_frame( + validation_tuple(source_identifier, WRITE_SLOT, ROOT) + ), + verify_frame(), + sender_frame(target=target), + ], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt( + status=FrameSpec.STATUS_SUCCESS, + gas_used=validation_gas( + fork.fork_at(timestamp=FORK_TIMESTAMP), + tuples=1, + cold_keys=1, + ), + state_gas_used=0, + ), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + ], + ), + ) + + blocks = [ + Block( + timestamp=FORK_TIMESTAMP - 1, + slot_number=WRITE_SLOT, + txs=[deploy_tx, publish], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + Spec.RECENT_ROOT_ADDRESS: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=key, + slot_changes=[ + BalStorageChange( + block_access_index=2, + post_value=value, + ) + ], + ) + ], + ), + } + ), + ), + Block( + timestamp=FORK_TIMESTAMP, + slot_number=WRITE_SLOT + 1, + txs=[verify], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + Spec.RECENT_ROOT_ADDRESS: BalAccountExpectation( + storage_changes=[], + storage_reads=[key], + ), + } + ), + ), + ] + post = { + deployer: Account(nonce=1), + Spec.RECENT_ROOT_ADDRESS: Account( + nonce=Spec.RECENT_ROOT_NONCE, + code=Spec.RECENT_ROOT_CODE, + storage={key: value}, + ), + target: Account(storage={SLOT_EXECUTED: 1}), + } + + blockchain_test(pre=pre, blocks=blocks, post=post) diff --git a/tests/bogota/eip8272_recent_roots/test_reference_vector.py b/tests/bogota/eip8272_recent_roots/test_reference_vector.py new file mode 100644 index 00000000000..4c56cadf3b8 --- /dev/null +++ b/tests/bogota/eip8272_recent_roots/test_reference_vector.py @@ -0,0 +1,120 @@ +""" +Tests pinning the recent root code and the EIP's reference vector for +[EIP-8272: Recent Roots for Frame Transactions](https://eips.ethereum.org/EIPS/eip-8272). +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Environment, + Fork, + FrameReceipt, + Op, + StateTestFiller, + Transaction, + TransactionException, + TransactionReceipt, + keccak256, +) + +from ..eip8141_frame_transactions.helpers import sender_frame, verify_frame +from ..eip8141_frame_transactions.spec import Spec as FrameSpec +from .helpers import recent_root_frame, validation_gas +from .spec import Spec, ref_spec_8272, validation_tuple + +REFERENCE_SPEC_GIT_PATH = ref_spec_8272.git_path +REFERENCE_SPEC_VERSION = ref_spec_8272.version + +pytestmark = pytest.mark.valid_from("Bogota") + +SLOT_EXECUTED = 0x01 +"""Storage slot used by target contracts to record execution.""" + +RECENT_ROOT_CODE_HASH = bytes.fromhex( + "da160390a838ee04013b2ff3abf4decc9aa3cc6c2f59dd90ca176c2b850be4e3" +) +""" +Keccak-256 of the canonical runtime, the code hash the deployed account +carries. +""" + + +@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize( + "root,error", + [ + pytest.param(Spec.VECTOR_ROOT, None, id="reference_vector"), + pytest.param( + (3).to_bytes(32, "big"), + TransactionException.TYPE_6_INVALID_FRAME_EXECUTION, + id="reference_vector_wrong_root", + marks=pytest.mark.exception_test, + ), + ], +) +def test_reference_vector( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + root: bytes, + error: TransactionException | None, +) -> None: + """ + Verify the EIP's reference vector through a recent root verifier + frame. + + The code the testing framework pre-allocates must be the canonical + runtime. With the vector's entry hash stored under its storage key, + the 72-byte tuple validates at `current_slot = 2`; any other root + reverts, which for a `VERIFY` frame invalidates the transaction. + """ + installed = fork.pre_allocation()[ + int.from_bytes(Spec.RECENT_ROOT_ADDRESS, "big") + ] + assert bytes(installed["code"]) == Spec.RECENT_ROOT_CODE + assert keccak256(Spec.RECENT_ROOT_CODE) == RECENT_ROOT_CODE_HASH + + sender = pre.fund_eoa() + target = pre.deploy_contract(code=Op.SSTORE(SLOT_EXECUTED, 1) + Op.STOP) + pre[Spec.RECENT_ROOT_ADDRESS] = Account( + nonce=Spec.RECENT_ROOT_NONCE, + code=Spec.RECENT_ROOT_CODE, + storage={ + int.from_bytes(Spec.VECTOR_STORAGE_KEY, "big"): int.from_bytes( + Spec.VECTOR_ENTRY_HASH, "big" + ) + }, + ) + verifier = recent_root_frame( + validation_tuple(Spec.VECTOR_SOURCE_ID, Spec.VECTOR_SLOT, root) + ) + + expected_receipt = None + if error is None: + expected_receipt = TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt( + status=FrameSpec.STATUS_SUCCESS, + gas_used=validation_gas(fork, tuples=1, cold_keys=1), + state_gas_used=0, + ), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + ], + ) + + state_test( + env=Environment(slot_number=Spec.VECTOR_CURRENT_SLOT), + pre=pre, + tx=Transaction( + sender=sender, + frames=[verifier, verify_frame(), sender_frame(target=target)], + error=error, + expected_receipt=expected_receipt, + ), + post={ + target: Account(storage={SLOT_EXECUTED: 0 if error else 1}), + }, + ) diff --git a/tests/bogota/eip8272_recent_roots/test_validation.py b/tests/bogota/eip8272_recent_roots/test_validation.py new file mode 100644 index 00000000000..ef245c83728 --- /dev/null +++ b/tests/bogota/eip8272_recent_roots/test_validation.py @@ -0,0 +1,813 @@ +""" +Tests for the validation operation of +[EIP-8272: Recent Roots for Frame Transactions](https://eips.ethereum.org/EIPS/eip-8272). + +A recent root verifier frame is a `VERIFY` frame targeting the recent root +contract with one to sixteen 72-byte tuples. The contract checks every +tuple's slot against the current slot and its entry hash against storage; +a failing check reverts, which for a `VERIFY` frame invalidates the whole +transaction. Every case seeds the contract's storage directly, so the +checks are exercised against known entries. +""" + +from typing import Dict, List, Sequence + +import pytest +from execution_testing import ( + Account, + Alloc, + Bytecode, + Environment, + Fork, + Frame, + FrameReceipt, + Op, + StateTestFiller, + Storage, + Transaction, + TransactionException, + TransactionReceipt, +) + +from ..eip8141_frame_transactions.helpers import ( + expiry_frame, + sender_frame, + verify_frame, +) +from ..eip8141_frame_transactions.spec import Spec as FrameSpec +from .helpers import recent_root_frame, validation_gas +from .spec import ( + Spec, + entry_hash, + ref_spec_8272, + source_id, + storage_key, + validation_tuple, +) + +REFERENCE_SPEC_GIT_PATH = ref_spec_8272.git_path +REFERENCE_SPEC_VERSION = ref_spec_8272.version + +pytestmark = pytest.mark.valid_from("Bogota") + +SLOT_EXECUTED = 0x01 +"""Storage slot used by target contracts to record execution.""" + +CURRENT_SLOT = 10_000 +"""Slot of the block executing the frame transaction.""" + +SALT = bytes(32) +"""Salt of every root source in these tests.""" + +INVALID_FRAME_EXECUTION = TransactionException.TYPE_6_INVALID_FRAME_EXECUTION + + +def seeded_entries( + references: Sequence[tuple[bytes, int, bytes]], +) -> Dict[int, int]: + """ + Return the storage seeding the entries of `(source_id, slot, root)` + references at the recent root contract. + """ + return { + storage_key(source, slot): int.from_bytes( + entry_hash(source, slot, root), "big" + ) + for source, slot, root in references + } + + +def install_entries(pre: Alloc, storage: Dict[int, int]) -> None: + """Seed the recent root contract's storage with `storage`.""" + pre[Spec.RECENT_ROOT_ADDRESS] = Account( + nonce=Spec.RECENT_ROOT_NONCE, + code=Spec.RECENT_ROOT_CODE, + storage=storage, + ) + + +def root_of(index: int) -> bytes: + """Return a distinct 32-byte root for `index`.""" + return (0xA000 + index).to_bytes(32, "big") + + +def run_verifier( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + *, + frames: List[Frame], + verifier_gas: int | None, + error: TransactionException | None = None, + verifier_index: int = 0, + current_slot: int = CURRENT_SLOT, +) -> None: + """ + Execute `frames` followed by an approving frame and a sender frame + that records execution, pinning the verifier frame's gas when the + transaction is valid. + """ + sender = pre.fund_eoa() + target = pre.deploy_contract(code=Op.SSTORE(SLOT_EXECUTED, 1) + Op.STOP) + all_frames = frames + [verify_frame(), sender_frame(target=target)] + + expected_receipt = None + if error is None: + frame_receipts = [ + FrameReceipt(status=FrameSpec.STATUS_SUCCESS) for _ in all_frames + ] + if verifier_gas is not None: + frame_receipts[verifier_index] = FrameReceipt( + status=FrameSpec.STATUS_SUCCESS, + gas_used=verifier_gas, + state_gas_used=0, + ) + expected_receipt = TransactionReceipt( + payer=sender, frame_receipts=frame_receipts + ) + + state_test( + env=Environment(slot_number=current_slot), + pre=pre, + tx=Transaction( + sender=sender, + frames=all_frames, + error=error, + expected_receipt=expected_receipt, + ), + post={ + target: Account(storage={SLOT_EXECUTED: 0 if error else 1}), + }, + ) + + +@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize( + "sources,repeats", + [ + pytest.param(1, 1, id="one_tuple"), + pytest.param(16, 1, id="sixteen_distinct_tuples"), + pytest.param(1, 2, id="duplicate_tuple"), + pytest.param(1, 16, id="sixteen_copies_of_one_tuple"), + pytest.param(8, 2, id="eight_tuples_each_twice"), + ], +) +def test_valid_references( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + sources: int, + repeats: int, +) -> None: + """ + Verify valid references from the previous slot: distinct sources read + distinct cold storage keys, and repeated tuples are checked again + against keys that are then warm. + """ + slot = CURRENT_SLOT - 1 + references = [ + (source_id(pre.fund_eoa(amount=0), SALT), slot, root_of(index)) + for index in range(sources) + ] + install_entries(pre, seeded_entries(references)) + tuples = [ + validation_tuple(*reference) + for reference in references + for _ in range(repeats) + ] + + run_verifier( + state_test, + pre, + fork, + frames=[recent_root_frame(tuples)], + verifier_gas=validation_gas( + fork, tuples=len(tuples), cold_keys=sources + ), + ) + + +@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize( + "seeded_slot,referenced_slot,root_offset,source_offset", + [ + pytest.param( + CURRENT_SLOT - 1, CURRENT_SLOT - 1, 1, 0, id="wrong_root" + ), + pytest.param( + CURRENT_SLOT - 1, CURRENT_SLOT - 1, 0, 1, id="wrong_source" + ), + pytest.param( + CURRENT_SLOT - 2, CURRENT_SLOT - 1, 0, 0, id="wrong_slot" + ), + pytest.param( + CURRENT_SLOT - 1 - Spec.RECENT_ROOT_LENGTH, + CURRENT_SLOT - 1, + 0, + 0, + id="stale_occupant_of_same_ring_index", + ), + pytest.param(CURRENT_SLOT, CURRENT_SLOT, 0, 0, id="current_slot"), + pytest.param( + CURRENT_SLOT + 1, CURRENT_SLOT + 1, 0, 0, id="future_slot" + ), + pytest.param( + CURRENT_SLOT - Spec.RECENT_ROOT_LENGTH, + CURRENT_SLOT - Spec.RECENT_ROOT_LENGTH, + 0, + 0, + id="expired_reference", + ), + ], +) +@pytest.mark.exception_test +def test_invalid_reference( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + seeded_slot: int, + referenced_slot: int, + root_offset: int, + source_offset: int, +) -> None: + """ + Reject a reference whose tuple does not match a stored entry, whose + slot is the current or a future slot, or whose root is + `RECENT_ROOT_LENGTH` slots old. + + The current, future and expired cases store their entries, so only + the slot checks reject them; the stale occupant case stores an entry + under the key the reference maps to, so only the slot committed in + the entry hash rejects it. + """ + source_address = pre.fund_eoa(amount=0) + other_address = pre.fund_eoa(amount=0) + source = source_id(source_address, SALT) + referenced_source = source_id( + other_address if source_offset else source_address, SALT + ) + root = root_of(0) + install_entries(pre, seeded_entries([(source, seeded_slot, root)])) + reference = validation_tuple( + referenced_source, referenced_slot, root_of(root_offset) + ) + + run_verifier( + state_test, + pre, + fork, + frames=[recent_root_frame(reference)], + verifier_gas=None, + error=INVALID_FRAME_EXECUTION, + ) + + +@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize( + "age,error", + [ + pytest.param( + Spec.RECENT_ROOT_USABLE_WINDOW, None, id="usable_window_edge" + ), + pytest.param( + Spec.RECENT_ROOT_USABLE_WINDOW + 1, + INVALID_FRAME_EXECUTION, + id="one_slot_past_window", + marks=pytest.mark.exception_test, + ), + ], +) +def test_age_window( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + age: int, + error: TransactionException | None, +) -> None: + """ + Accept a root exactly `RECENT_ROOT_USABLE_WINDOW` slots old and + reject one slot older, with the entry stored in both cases. + """ + slot = CURRENT_SLOT - age + source = source_id(pre.fund_eoa(amount=0), SALT) + root = root_of(0) + install_entries(pre, seeded_entries([(source, slot, root)])) + + run_verifier( + state_test, + pre, + fork, + frames=[recent_root_frame(validation_tuple(source, slot, root))], + verifier_gas=validation_gas(fork, tuples=1, cold_keys=1), + error=error, + ) + + +@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize( + "data_length", + [ + pytest.param(0, id="empty_data"), + pytest.param(Spec.RECENT_ROOT_TUPLE_BYTES - 1, id="71_bytes"), + pytest.param(Spec.RECENT_ROOT_TUPLE_BYTES + 1, id="73_bytes"), + pytest.param( + (Spec.MAX_RECENT_ROOT_REFERENCES + 1) + * Spec.RECENT_ROOT_TUPLE_BYTES, + id="seventeen_tuples", + ), + ], +) +@pytest.mark.exception_test +def test_invalid_data_length( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + data_length: int, +) -> None: + """ + Reject validation calldata that is empty, not a whole number of + tuples, or more than sixteen tuples, even when every whole tuple in + it would validate. + """ + slot = CURRENT_SLOT - 1 + source = source_id(pre.fund_eoa(amount=0), SALT) + root = root_of(0) + install_entries(pre, seeded_entries([(source, slot, root)])) + tuple_bytes = validation_tuple(source, slot, root) + repeated = tuple_bytes * (data_length // Spec.RECENT_ROOT_TUPLE_BYTES + 1) + + run_verifier( + state_test, + pre, + fork, + frames=[recent_root_frame(repeated[:data_length])], + verifier_gas=None, + error=INVALID_FRAME_EXECUTION, + ) + + +@pytest.mark.pre_alloc_mutable +def test_validation_call_with_value_reverts( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + A call carrying value reverts before the calldata is inspected, even + with a valid tuple: the sender frame fails and the transaction stays + valid. + """ + slot = CURRENT_SLOT - 1 + source = source_id(pre.fund_eoa(amount=0), SALT) + root = root_of(0) + install_entries(pre, seeded_entries([(source, slot, root)])) + sender = pre.fund_eoa() + + state_test( + env=Environment(slot_number=CURRENT_SLOT), + pre=pre, + tx=Transaction( + sender=sender, + frames=[ + verify_frame(), + sender_frame( + target=Spec.RECENT_ROOT_ADDRESS, + data=validation_tuple(source, slot, root), + value=1, + ), + ], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt(status=FrameSpec.STATUS_FAILURE), + ], + ), + ), + post={Spec.RECENT_ROOT_ADDRESS: Account(balance=0)}, + ) + + +@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize( + "overrides,error", + [ + pytest.param( + {"flags": FrameSpec.APPROVE_PAYMENT}, None, id="payment_flag" + ), + pytest.param( + {"state_gas_limit": 1_000}, None, id="nonzero_state_gas_limit" + ), + pytest.param( + {"flags": FrameSpec.ATOMIC_BATCH_FLAG}, + TransactionException.TYPE_6_INVALID_FRAME_FORMAT, + id="atomic_batch_flag", + marks=pytest.mark.exception_test, + ), + pytest.param( + {"value": 1}, + TransactionException.TYPE_6_INVALID_FRAME_FORMAT, + id="nonzero_value", + marks=pytest.mark.exception_test, + ), + ], +) +def test_frame_shape_variants( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + overrides: Dict[str, int], + error: TransactionException | None, +) -> None: + """ + Frames that differ from the canonical verifier frame in one field. + + A payment approval flag or a nonzero state gas budget leaves block + validity untouched: the contract executes normally and succeeds (the + public mempool would not classify such a frame as a recent root + verifier). A `VERIFY` frame carrying value is statically invalid + under EIP-8141 before any frame runs. + """ + slot = CURRENT_SLOT - 1 + source = source_id(pre.fund_eoa(amount=0), SALT) + root = root_of(0) + install_entries(pre, seeded_entries([(source, slot, root)])) + verifier = recent_root_frame( + validation_tuple(source, slot, root), **overrides + ) + + run_verifier( + state_test, + pre, + fork, + frames=[verifier], + verifier_gas=validation_gas(fork, tuples=1, cold_keys=1), + error=error, + ) + + +@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize( + "gas_offset,error", + [ + pytest.param(0, None, id="exact_gas"), + pytest.param( + -1, + INVALID_FRAME_EXECUTION, + id="one_gas_short", + marks=pytest.mark.exception_test, + ), + ], +) +@pytest.mark.parametrize( + "sources,repeats", + [ + (1, 1), + (Spec.MAX_RECENT_ROOT_REFERENCES, 1), + (1, Spec.MAX_RECENT_ROOT_REFERENCES), + ], + ids=["one_tuple", "distinct_tuples", "duplicate_tuples"], +) +@pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) +def test_execution_gas_boundary( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + gas_offset: int, + error: TransactionException | None, + sources: int, + repeats: int, + warm: bool, +) -> None: + """Pin exact and one-short gas with cold, repeated and warm reads.""" + slot = CURRENT_SLOT - 1 + references = [ + (source_id(pre.fund_eoa(amount=0), SALT), slot, root_of(index)) + for index in range(sources) + ] + install_entries(pre, seeded_entries(references)) + tuples = [ + validation_tuple(*reference) for reference in references + ] * repeats + required = validation_gas( + fork, + tuples=len(tuples), + cold_keys=0 if warm else sources, + target_warm=warm, + ) + verifier = recent_root_frame(tuples, gas_limit=required + gas_offset) + # A preceding verifier warms both the target and every referenced key. + # Multiple matching verifier frames are valid in a block. + frames = [recent_root_frame(tuples), verifier] if warm else [verifier] + + run_verifier( + state_test, + pre, + fork, + frames=frames, + verifier_gas=required, + verifier_index=1 if warm else 0, + error=error, + ) + + +@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize( + "position", + [ + pytest.param("after_expiry", id="after_expiry_verifier"), + pytest.param("last", id="after_account_validation"), + ], +) +def test_frame_position( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + position: str, +) -> None: + """ + The verifier frame executes the same wherever it sits in the frame + list: after an expiry verifier frame in the public mempool's + canonical order, or after account validation and execution, where + the public mempool would reject the shape but a block accepts it. + """ + slot = CURRENT_SLOT - 1 + source = source_id(pre.fund_eoa(amount=0), SALT) + root = root_of(0) + install_entries(pre, seeded_entries([(source, slot, root)])) + verifier = recent_root_frame(validation_tuple(source, slot, root)) + verifier_gas = validation_gas(fork, tuples=1, cold_keys=1) + + if position == "after_expiry": + run_verifier( + state_test, + pre, + fork, + frames=[expiry_frame(), verifier], + verifier_gas=verifier_gas, + verifier_index=1, + ) + elif position == "last": + sender = pre.fund_eoa() + target = pre.deploy_contract( + code=Op.SSTORE(SLOT_EXECUTED, 1) + Op.STOP + ) + state_test( + env=Environment(slot_number=CURRENT_SLOT), + pre=pre, + tx=Transaction( + sender=sender, + frames=[verify_frame(), sender_frame(target=target), verifier], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt( + status=FrameSpec.STATUS_SUCCESS, + gas_used=verifier_gas, + state_gas_used=0, + ), + ], + ), + ), + post={target: Account(storage={SLOT_EXECUTED: 1})}, + ) + else: + raise ValueError(f"unknown position {position}") + + +@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize("bad_index", [0, 7, 15]) +@pytest.mark.exception_test +def test_invalid_tuple_in_batch( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + bad_index: int, +) -> None: + """Reject the whole verification even when earlier tuples passed.""" + source = source_id(pre.fund_eoa(amount=0), SALT) + slot = CURRENT_SLOT - 1 + root = root_of(0) + install_entries(pre, seeded_entries([(source, slot, root)])) + tuples = [ + validation_tuple(source, slot, root) + ] * Spec.MAX_RECENT_ROOT_REFERENCES + tuples[bad_index] = validation_tuple(source, slot, root_of(1)) + run_verifier( + state_test, + pre, + fork, + frames=[recent_root_frame(tuples)], + verifier_gas=None, + error=INVALID_FRAME_EXECUTION, + ) + + +@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize("slot", [0, 2**32, 2**64 - 2]) +def test_full_width_slot_and_zero_root( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + slot: int, +) -> None: + """Validate the full uint64 slot encoding and an opaque zero root.""" + source = source_id(pre.fund_eoa(amount=0), SALT) + root = bytes(32) + install_entries(pre, seeded_entries([(source, slot, root)])) + run_verifier( + state_test, + pre, + fork, + frames=[recent_root_frame(validation_tuple(source, slot, root))], + verifier_gas=validation_gas(fork, tuples=1, cold_keys=1), + current_slot=slot + 1, + ) + + +@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize( + "tuples", + [ + pytest.param(1, id="one_tuple"), + pytest.param(Spec.MAX_RECENT_ROOT_REFERENCES, id="sixteen_tuples"), + ], +) +def test_later_frame_reads_verifier_frame( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + tuples: int, +) -> None: + """ + A later frame reads the completed verifier frame's defining fields + through `FRAMEPARAM` and its last tuple through `FRAMEDATALOAD`, as + application validation does. + """ + slot = CURRENT_SLOT - 1 + references = [ + (source_id(pre.fund_eoa(amount=0), SALT), slot, root_of(index)) + for index in range(tuples) + ] + install_entries(pre, seeded_entries(references)) + last_source, last_slot, last_root = references[-1] + last_offset = (tuples - 1) * Spec.RECENT_ROOT_TUPLE_BYTES + + sender = pre.fund_eoa() + storage = Storage() + reads: List[tuple[int | bytes, Bytecode]] = [ + ( + Spec.RECENT_ROOT_ADDRESS, + Op.FRAMEPARAM(0, FrameSpec.FRAMEPARAM_TARGET), + ), + (FrameSpec.MODE_VERIFY, Op.FRAMEPARAM(0, FrameSpec.FRAMEPARAM_MODE)), + ( + FrameSpec.APPROVE_NONE, + Op.FRAMEPARAM(0, FrameSpec.FRAMEPARAM_FLAGS), + ), + (0, Op.FRAMEPARAM(0, FrameSpec.FRAMEPARAM_STATE_GAS_LIMIT)), + ( + tuples * Spec.RECENT_ROOT_TUPLE_BYTES, + Op.FRAMEPARAM(0, FrameSpec.FRAMEPARAM_DATA_LENGTH), + ), + ( + FrameSpec.STATUS_SUCCESS, + Op.FRAMEPARAM(0, FrameSpec.FRAMEPARAM_STATUS), + ), + (last_source, Op.FRAMEDATALOAD(last_offset, 0)), + (last_slot, Op.SHR(192, Op.FRAMEDATALOAD(last_offset + 32, 0))), + (last_root, Op.FRAMEDATALOAD(last_offset + 40, 0)), + ] + reader_code = ( + sum( + ( + Op.SSTORE(storage.store_next(expected), value) + for expected, value in reads + ), + Bytecode(), + ) + + Op.STOP + ) + reader = pre.deploy_contract(code=reader_code) + tuple_data = [validation_tuple(*reference) for reference in references] + + state_test( + env=Environment(slot_number=CURRENT_SLOT), + pre=pre, + tx=Transaction( + sender=sender, + frames=[ + recent_root_frame(tuple_data), + verify_frame(), + sender_frame( + target=reader, + gas_limit=fork.frame_entry_gas_calculator()() + + reader_code.execution_cost(fork), + state_gas_limit=reader_code.state_cost(fork), + ), + ], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt( + status=FrameSpec.STATUS_SUCCESS, + gas_used=validation_gas( + fork, tuples=tuples, cold_keys=tuples + ), + state_gas_used=0, + ), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + ], + ), + ), + post={reader: Account(storage=storage)}, + ) + + +@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize( + "call_opcode,in_recent_root_context", + [ + pytest.param(Op.CALL, True, id="call"), + pytest.param(Op.STATICCALL, True, id="staticcall"), + pytest.param(Op.DELEGATECALL, False, id="delegatecall"), + pytest.param(Op.CALLCODE, False, id="callcode"), + ], +) +@pytest.mark.parametrize( + "valid", [True, False], ids=["valid_tuple", "wrong_root"] +) +def test_validation_called_from_contract( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + call_opcode: Op, + in_recent_root_context: bool, + valid: bool, +) -> None: + """ + A contract calling the validation operation sees a success flag and + no return data, and a failing tuple fails only that call. Under + `DELEGATECALL` and `CALLCODE` the code reads the caller's storage, + which holds no entries. + """ + slot = CURRENT_SLOT - 1 + source = source_id(pre.fund_eoa(amount=0), SALT) + root = root_of(0) + install_entries(pre, seeded_entries([(source, slot, root)])) + reference = validation_tuple(source, slot, root if valid else root_of(1)) + + call = ( + call_opcode( + gas=Op.GAS, + address=Spec.RECENT_ROOT_ADDRESS, + value=0, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0, + ret_size=0, + ) + if call_opcode in (Op.CALL, Op.CALLCODE) + else call_opcode( + gas=Op.GAS, + address=Spec.RECENT_ROOT_ADDRESS, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0, + ret_size=0, + ) + ) + storage = Storage() + caller = pre.deploy_contract( + code=Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.SSTORE( + storage.store_next(int(valid and in_recent_root_context)), + call, + ) + + Op.SSTORE(storage.store_next(0), Op.RETURNDATASIZE) + + Op.STOP + ) + sender = pre.fund_eoa() + + state_test( + env=Environment(slot_number=CURRENT_SLOT), + pre=pre, + tx=Transaction( + sender=sender, + frames=[ + verify_frame(), + sender_frame(target=caller, data=reference), + ], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + ], + ), + ), + post={caller: Account(storage=storage)}, + ) diff --git a/tests/bogota/eip8272_recent_roots/test_write.py b/tests/bogota/eip8272_recent_roots/test_write.py new file mode 100644 index 00000000000..c40230cfd03 --- /dev/null +++ b/tests/bogota/eip8272_recent_roots/test_write.py @@ -0,0 +1,766 @@ +""" +Tests for the write operation of +[EIP-8272: Recent Roots for Frame Transactions](https://eips.ethereum.org/EIPS/eip-8272). + +A call to the recent root contract with 64 bytes of calldata publishes a +root for the caller's source under the current slot; the root becomes +referenceable from the next slot on. Publishing is ordinary execution: +value reverts, a static context fails, and delegated code writes the +delegating account's storage instead. +""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + BalAccountExpectation, + BalStorageChange, + BalStorageSlot, + Block, + BlockAccessListExpectation, + BlockchainTestFiller, + Environment, + Fork, + FrameReceipt, + Op, + StateTestFiller, + Transaction, + TransactionException, + TransactionReceipt, +) + +from ..eip8141_frame_transactions.helpers import ( + default_frame, + sender_frame, + verify_frame, +) +from ..eip8141_frame_transactions.spec import Spec as FrameSpec +from .helpers import ( + recent_root_frame, + validation_gas, + write_frame, + write_state_gas, +) +from .spec import ( + Spec, + entry_hash, + ref_spec_8272, + source_id, + storage_key, + validation_tuple, + write_calldata, +) + +REFERENCE_SPEC_GIT_PATH = ref_spec_8272.git_path +REFERENCE_SPEC_VERSION = ref_spec_8272.version + +pytestmark = pytest.mark.valid_from("Bogota") + +SLOT_EXECUTED = 0x01 +"""Storage slot used by target contracts to record execution.""" + +WRITE_SLOT = 100 +"""Slot in which roots are published.""" + +SALT = (0x5A17).to_bytes(32, "big") +"""Salt of the root sources in these tests.""" + +OTHER_SALT = (0x5A18).to_bytes(32, "big") +"""Second salt, giving the same address a second root source.""" + +ROOT_A = (0xA1).to_bytes(32, "big") +ROOT_B = (0xB2).to_bytes(32, "big") + + +def entry_value(source: bytes, slot: int, root: bytes) -> int: + """Return the stored entry hash as a storage value.""" + return int.from_bytes(entry_hash(source, slot, root), "big") + + +def relay_contract(pre: Alloc, opcode: Op) -> Address: + """ + Deploy a contract forwarding its calldata to the recent root contract + with `opcode` (`CALL`, `DELEGATECALL` or `CALLCODE`), recording the + call's success at `SLOT_EXECUTED`. + """ + call = ( + opcode( + gas=Op.GAS, + address=Spec.RECENT_ROOT_ADDRESS, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0, + ret_size=0, + ) + if opcode == Op.DELEGATECALL + else opcode( + gas=Op.GAS, + address=Spec.RECENT_ROOT_ADDRESS, + value=0, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0, + ret_size=0, + ) + ) + return pre.deploy_contract( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.SSTORE(SLOT_EXECUTED, call) + + Op.STOP + ) + + +@pytest.mark.parametrize("write_slot", [WRITE_SLOT, 8192, 2**64 - 2]) +def test_publish_then_verify( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + write_slot: int, +) -> None: + """ + Publish a root from a sender frame in one slot and verify it from the + next slot on. + + The write appears in the first block's access list as a storage + change at the recent root contract; the verification appears in the + second block's as a storage read of the same key. + """ + sender = pre.fund_eoa() + target = pre.deploy_contract(code=Op.SSTORE(SLOT_EXECUTED, 1) + Op.STOP) + source = source_id(sender, SALT) + key = storage_key(source, write_slot) + value = entry_value(source, write_slot, ROOT_A) + + publish = Transaction( + sender=sender, + frames=[verify_frame(), write_frame(fork, SALT, ROOT_A)], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + ], + ), + ) + verify = Transaction( + sender=sender, + frames=[ + recent_root_frame(validation_tuple(source, write_slot, ROOT_A)), + verify_frame(), + sender_frame(target=target), + ], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt( + status=FrameSpec.STATUS_SUCCESS, + gas_used=validation_gas(fork, tuples=1, cold_keys=1), + state_gas_used=0, + ), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + ], + ), + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + slot_number=write_slot, + txs=[publish], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + Spec.RECENT_ROOT_ADDRESS: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=key, + slot_changes=[ + BalStorageChange( + block_access_index=1, + post_value=value, + ) + ], + ) + ], + ), + } + ), + ), + Block( + slot_number=write_slot + 1, + txs=[verify], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + Spec.RECENT_ROOT_ADDRESS: BalAccountExpectation( + storage_changes=[], + storage_reads=[key], + ), + } + ), + ), + ], + post={ + Spec.RECENT_ROOT_ADDRESS: Account( + nonce=Spec.RECENT_ROOT_NONCE, + code=Spec.RECENT_ROOT_CODE, + storage={key: value}, + ), + target: Account(storage={SLOT_EXECUTED: 1}), + }, + ) + + +@pytest.mark.parametrize( + "separate_transactions", + [False, True], + ids=["one_transaction", "two_transactions"], +) +def test_last_write_in_slot_wins( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + separate_transactions: bool, +) -> None: + """ + Two writes by the same source in one slot target the same storage + key: the later one overwrites the earlier, within one transaction or + across two, and only its root verifies in the next slot. + """ + sender = pre.fund_eoa() + target = pre.deploy_contract(code=Op.SSTORE(SLOT_EXECUTED, 1) + Op.STOP) + source = source_id(sender, SALT) + key = storage_key(source, WRITE_SLOT) + + writes = [write_frame(fork, SALT, ROOT_A), write_frame(fork, SALT, ROOT_B)] + if separate_transactions: + publish = [ + Transaction(sender=sender, frames=[verify_frame(), write]) + for write in writes + ] + else: + publish = [ + Transaction(sender=sender, frames=[verify_frame(), *writes]) + ] + verify_last = Transaction( + sender=sender, + frames=[ + recent_root_frame(validation_tuple(source, WRITE_SLOT, ROOT_B)), + verify_frame(), + sender_frame(target=target), + ], + ) + + blockchain_test( + pre=pre, + blocks=[ + Block(slot_number=WRITE_SLOT, txs=publish), + Block(slot_number=WRITE_SLOT + 1, txs=[verify_last]), + ], + post={ + Spec.RECENT_ROOT_ADDRESS: Account( + storage={key: entry_value(source, WRITE_SLOT, ROOT_B)}, + ), + target: Account(storage={SLOT_EXECUTED: 1}), + }, + ) + + +def test_ring_overwrite( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + A write `RECENT_ROOT_LENGTH` slots after an earlier one lands on the + same ring buffer key and replaces the earlier entry. The new root + verifies in the following slot. + """ + sender = pre.fund_eoa() + target = pre.deploy_contract(code=Op.SSTORE(SLOT_EXECUTED, 1) + Op.STOP) + source = source_id(sender, SALT) + later_slot = WRITE_SLOT + Spec.RECENT_ROOT_LENGTH + key = storage_key(source, WRITE_SLOT) + assert key == storage_key(source, later_slot) + + # Transactions are built in execution order: the sender's nonce is + # assigned at construction. + publish_first = Transaction( + sender=sender, + frames=[verify_frame(), write_frame(fork, SALT, ROOT_A)], + ) + publish_again = Transaction( + sender=sender, + frames=[verify_frame(), write_frame(fork, SALT, ROOT_B)], + ) + verify_new = Transaction( + sender=sender, + frames=[ + recent_root_frame(validation_tuple(source, later_slot, ROOT_B)), + verify_frame(), + sender_frame(target=target), + ], + ) + + blockchain_test( + pre=pre, + blocks=[ + Block(slot_number=WRITE_SLOT, txs=[publish_first]), + Block(slot_number=later_slot, txs=[publish_again]), + Block(slot_number=later_slot + 1, txs=[verify_new]), + ], + post={ + Spec.RECENT_ROOT_ADDRESS: Account( + storage={key: entry_value(source, later_slot, ROOT_B)}, + ), + target: Account(storage={SLOT_EXECUTED: 1}), + }, + ) + + +def test_publish_from_contract( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + A contract calling the recent root contract is the root source: the + entry is keyed by the contract's address, not by the transaction + sender's, and a reference naming the contract verifies. + """ + sender = pre.fund_eoa() + target = pre.deploy_contract(code=Op.SSTORE(SLOT_EXECUTED, 1) + Op.STOP) + relay = relay_contract(pre, Op.CALL) + source = source_id(relay, SALT) + key = storage_key(source, WRITE_SLOT) + + publish = Transaction( + sender=sender, + frames=[ + verify_frame(), + sender_frame( + target=relay, + data=write_calldata(SALT, ROOT_A), + state_gas_limit=write_state_gas(fork, writes=2), + ), + ], + ) + verify = Transaction( + sender=sender, + frames=[ + recent_root_frame(validation_tuple(source, WRITE_SLOT, ROOT_A)), + verify_frame(), + sender_frame(target=target), + ], + ) + + blockchain_test( + pre=pre, + blocks=[ + Block(slot_number=WRITE_SLOT, txs=[publish]), + Block(slot_number=WRITE_SLOT + 1, txs=[verify]), + ], + post={ + relay: Account(storage={SLOT_EXECUTED: 1}), + Spec.RECENT_ROOT_ADDRESS: Account( + storage={key: entry_value(source, WRITE_SLOT, ROOT_A)}, + ), + target: Account(storage={SLOT_EXECUTED: 1}), + }, + ) + + +@pytest.mark.parametrize( + "opcode", + [ + pytest.param(Op.DELEGATECALL, id="delegatecall"), + pytest.param(Op.CALLCODE, id="callcode"), + ], +) +def test_delegated_write_uses_calling_storage( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + opcode: Op, +) -> None: + """ + Running the recent root code in another account's storage context + writes that account's storage, not the recent root contract's. + + Under `DELEGATECALL` the source is the relay's caller, the sender; + under `CALLCODE` it is the relay itself. Either way the entry lands + in the relay's storage and the recent root contract's stays empty. + """ + sender = pre.fund_eoa() + relay = relay_contract(pre, opcode) + writer = sender if opcode == Op.DELEGATECALL else relay + source = source_id(writer, SALT) + key = storage_key(source, WRITE_SLOT) + + state_test( + env=Environment(slot_number=WRITE_SLOT), + pre=pre, + tx=Transaction( + sender=sender, + frames=[ + verify_frame(), + sender_frame( + target=relay, + data=write_calldata(SALT, ROOT_A), + state_gas_limit=write_state_gas(fork, writes=2), + ), + ], + ), + post={ + relay: Account( + storage={ + SLOT_EXECUTED: 1, + key: entry_value(source, WRITE_SLOT, ROOT_A), + } + ), + Spec.RECENT_ROOT_ADDRESS: Account( + nonce=Spec.RECENT_ROOT_NONCE, + code=Spec.RECENT_ROOT_CODE, + storage={key: 0}, + ), + }, + ) + + +def test_write_with_value_reverts( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + A write call carrying value reverts and stores nothing; the sender + frame fails without invalidating the transaction. + """ + sender = pre.fund_eoa() + source = source_id(sender, SALT) + key = storage_key(source, WRITE_SLOT) + + state_test( + env=Environment(slot_number=WRITE_SLOT), + pre=pre, + tx=Transaction( + sender=sender, + frames=[verify_frame(), write_frame(fork, SALT, ROOT_A, value=1)], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt(status=FrameSpec.STATUS_FAILURE), + ], + ), + ), + post={ + Spec.RECENT_ROOT_ADDRESS: Account( + nonce=Spec.RECENT_ROOT_NONCE, + code=Spec.RECENT_ROOT_CODE, + balance=0, + storage={key: 0}, + ), + }, + ) + + +@pytest.mark.parametrize( + "data_length", + [ + pytest.param(Spec.RECENT_ROOT_WRITE_BYTES - 1, id="63_bytes"), + pytest.param(Spec.RECENT_ROOT_WRITE_BYTES + 1, id="65_bytes"), + ], +) +def test_write_with_wrong_length_reverts( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + data_length: int, +) -> None: + """ + Calldata one byte shorter or longer than the write encoding is + neither a write nor a whole number of validation tuples: the call + reverts and stores nothing. + """ + sender = pre.fund_eoa() + source = source_id(sender, SALT) + key = storage_key(source, WRITE_SLOT) + data = (write_calldata(SALT, ROOT_A) + b"\x00")[:data_length] + + state_test( + env=Environment(slot_number=WRITE_SLOT), + pre=pre, + tx=Transaction( + sender=sender, + frames=[ + verify_frame(), + write_frame(fork, SALT, ROOT_A, data=data), + ], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt(status=FrameSpec.STATUS_FAILURE), + ], + ), + ), + post={ + Spec.RECENT_ROOT_ADDRESS: Account( + nonce=Spec.RECENT_ROOT_NONCE, + code=Spec.RECENT_ROOT_CODE, + storage={key: 0}, + ), + }, + ) + + +@pytest.mark.exception_test +def test_static_write_fails( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + A `VERIFY` frame carrying the 64-byte write encoding runs the write + in a static context: the store fails, the frame halts, and the + transaction is invalid with storage unchanged. + """ + sender = pre.fund_eoa() + source = source_id(sender, SALT) + key = storage_key(source, WRITE_SLOT) + + state_test( + env=Environment(slot_number=WRITE_SLOT), + pre=pre, + tx=Transaction( + sender=sender, + frames=[ + recent_root_frame(write_calldata(SALT, ROOT_A)), + verify_frame(), + ], + error=TransactionException.TYPE_6_INVALID_FRAME_EXECUTION, + ), + post={ + Spec.RECENT_ROOT_ADDRESS: Account( + nonce=Spec.RECENT_ROOT_NONCE, + code=Spec.RECENT_ROOT_CODE, + storage={key: 0}, + ), + }, + ) + + +def test_delegated_eoa_write_uses_authority_storage( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """Keep an EIP-7702 delegated write in the authority's storage.""" + sender = pre.fund_eoa() + authority = pre.fund_eoa(delegation=Spec.RECENT_ROOT_ADDRESS) + source = source_id(sender, SALT) + key = storage_key(source, WRITE_SLOT) + state_test( + env=Environment(slot_number=WRITE_SLOT), + pre=pre, + tx=Transaction( + sender=sender, + frames=[ + verify_frame(), + sender_frame( + target=authority, + data=write_calldata(SALT, ROOT_A), + state_gas_limit=write_state_gas(fork), + ), + ], + expected_receipt=TransactionReceipt( + frame_receipts=[ + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + ], + ), + ), + post={ + authority: Account( + storage={key: entry_value(source, WRITE_SLOT, ROOT_A)} + ), + Spec.RECENT_ROOT_ADDRESS: Account(storage={key: 0}), + }, + ) + + +def test_sources_by_salt( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + One address publishing under two salts in the same slot owns two + root sources, and both roots verify together in the next slot. + """ + sender = pre.fund_eoa() + target = pre.deploy_contract(code=Op.SSTORE(SLOT_EXECUTED, 1) + Op.STOP) + published = [ + (source_id(sender, SALT), ROOT_A), + (source_id(sender, OTHER_SALT), ROOT_B), + ] + + publish = Transaction( + sender=sender, + frames=[ + verify_frame(), + write_frame(fork, SALT, ROOT_A), + write_frame(fork, OTHER_SALT, ROOT_B), + ], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS, logs=[]), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS, logs=[]), + ], + ), + ) + verify = Transaction( + sender=sender, + frames=[ + recent_root_frame( + [ + validation_tuple(source, WRITE_SLOT, root) + for source, root in published + ] + ), + verify_frame(), + sender_frame(target=target), + ], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt( + status=FrameSpec.STATUS_SUCCESS, + gas_used=validation_gas(fork, tuples=2, cold_keys=2), + state_gas_used=0, + ), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + ], + ), + ) + + blockchain_test( + pre=pre, + blocks=[ + Block(slot_number=WRITE_SLOT, txs=[publish]), + Block(slot_number=WRITE_SLOT + 1, txs=[verify]), + ], + post={ + Spec.RECENT_ROOT_ADDRESS: Account( + storage={ + storage_key(source, WRITE_SLOT): entry_value( + source, WRITE_SLOT, root + ) + for source, root in published + }, + ), + target: Account(storage={SLOT_EXECUTED: 1}), + }, + ) + + +def test_default_frame_write_uses_entry_point_source( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + A `DEFAULT` frame calls as the frame entry point, so its write lands + under the entry point's source rather than the sender's. + """ + sender = pre.fund_eoa() + entry_point_source = source_id(FrameSpec.ENTRY_POINT, SALT) + + state_test( + env=Environment(slot_number=WRITE_SLOT), + pre=pre, + tx=Transaction( + sender=sender, + frames=[ + verify_frame(), + default_frame( + target=Spec.RECENT_ROOT_ADDRESS, + data=write_calldata(SALT, ROOT_A), + state_gas_limit=write_state_gas(fork), + ), + ], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS, logs=[]), + ], + ), + ), + post={ + Spec.RECENT_ROOT_ADDRESS: Account( + storage={ + storage_key(entry_point_source, WRITE_SLOT): entry_value( + entry_point_source, WRITE_SLOT, ROOT_A + ), + storage_key(source_id(sender, SALT), WRITE_SLOT): 0, + }, + ), + }, + ) + + +def test_static_call_write_fails( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + A write reached through `STATICCALL` fails and stores nothing, while + the calling frame carries on and the transaction stays valid. + """ + sender = pre.fund_eoa() + relay = pre.deploy_contract( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.POP( + Op.STATICCALL( + gas=Op.GAS, + address=Spec.RECENT_ROOT_ADDRESS, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0, + ret_size=0, + ) + ) + + Op.STOP + ) + key = storage_key(source_id(relay, SALT), WRITE_SLOT) + + state_test( + env=Environment(slot_number=WRITE_SLOT), + pre=pre, + tx=Transaction( + sender=sender, + frames=[ + verify_frame(), + sender_frame( + target=relay, + data=write_calldata(SALT, ROOT_A), + state_gas_limit=write_state_gas(fork), + ), + ], + expected_receipt=TransactionReceipt( + payer=sender, + frame_receipts=[ + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + FrameReceipt(status=FrameSpec.STATUS_SUCCESS), + ], + ), + ), + post={Spec.RECENT_ROOT_ADDRESS: Account(storage={key: 0})}, + ) diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 2e7df944c64..57ab7405231 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -11,6 +11,7 @@ from ethereum.ethash import * from ethereum.fork_criteria import Unscheduled from ethereum.forks.bogota.transactions.frame_transaction import ( + RECENT_ROOT_ADDRESS, FrameMode, ) from ethereum.trace import EvmTracer @@ -46,6 +47,8 @@ # src/ethereum/forks/bogota/transactions/frame_transaction.py - # constructed while decoding transactions, never compared explicitly FrameMode.DEFAULT +# Documents the EIP-8272 recent root contract; nothing in `src/` calls it. +RECENT_ROOT_ADDRESS # src/ethereum/ethash.py ethash.generate_dataset