From 600a12087e9763d47f7bf62966196024f7e3ad30 Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Tue, 18 Aug 2026 21:10:04 -0600 Subject: [PATCH 01/59] bug(test-base-types): Conversion methods use `ValueError` (#3394) --- .../src/execution_testing/base_types/conversions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/testing/src/execution_testing/base_types/conversions.py b/packages/testing/src/execution_testing/base_types/conversions.py index d7d409d41d2..5ec8157c9ec 100644 --- a/packages/testing/src/execution_testing/base_types/conversions.py +++ b/packages/testing/src/execution_testing/base_types/conversions.py @@ -12,7 +12,7 @@ def to_bytes(input_bytes: BytesConvertible) -> bytes: """Convert multiple types into bytes.""" if input_bytes is None: - raise Exception("Cannot convert `None` input to bytes") + raise ValueError("Cannot convert `None` input to bytes") if isinstance(input_bytes, str): # We can have a hex representation of bytes with spaces for readability @@ -52,7 +52,7 @@ def to_fixed_size_bytes( ) input_bytes = to_bytes(input_bytes) if len(input_bytes) > size: - raise Exception( + raise ValueError( f"input is too large for fixed size bytes: {input_bytes.hex()}, " f" {len(input_bytes)} > {size}" ) @@ -61,7 +61,7 @@ def to_fixed_size_bytes( return input_bytes.rjust(size, b"\x00") if right_padding: return input_bytes.ljust(size, b"\x00") - raise Exception( + raise ValueError( f"input is too small for fixed size bytes: " f"{len(input_bytes)} < {size}\n" "Use `left_padding=True` or `right_padding=True` to allow padding." @@ -84,4 +84,4 @@ def to_number(input_number: NumberConvertible) -> int: input_number, SupportsBytes ): return int.from_bytes(input_number, byteorder="big") - raise Exception("invalid type for `number`") + raise ValueError("invalid type for `number`") From 89f2f0499648532760c9893ffd34659517e95a87 Mon Sep 17 00:00:00 2001 From: spencer Date: Wed, 19 Aug 2026 05:29:00 +0200 Subject: [PATCH 02/59] feat(tests): add BAL storage slot numeric ordering test (#3382) --- .../test_block_access_lists.py | 115 ++++++++++++++++++ .../test_cases.md | 1 + 2 files changed, 116 insertions(+) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index eb728087828..882b700a880 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -3684,6 +3684,121 @@ def test_bal_lexicographic_address_ordering( ) +def test_bal_storage_slot_numeric_ordering( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL sorts storage slots as fixed-width 32-byte keys (numeric + order), not by their minimal-length RLP encodings. + + Slots with 1, 2 and 3 byte minimal encodings are accessed in + reverse numeric order; byte-prefix comparison of the stripped keys + would order e.g. 0x0100 before 0x02. + """ + alice = pre.fund_eoa() + + # Written slots span minimal-encoding widths so that stripped-key + # byte-prefix comparison yields [0x00, 0x0100, 0x010000, 0x02, 0xFF] + # instead of the numeric [0x00, 0x02, 0xFF, 0x0100, 0x010000]. + # Read slots are disjoint from written ones so they stay in + # storage_reads. Distinct stored values tie each change to its slot. + contract_code = ( + # SSTORE in reverse numeric slot order + Op.SSTORE(0x010000, 0x0E) + + Op.SSTORE(0x0100, 0x0D) + + Op.SSTORE(0xFF, 0x0C) + + Op.SSTORE(0x02, 0x0B) + + Op.SSTORE(0x00, 0x0A) + # SLOAD empty slots in reverse numeric order + + Op.SLOAD(0x0200) + + Op.POP + + Op.SLOAD(0x03) + + Op.POP + + Op.STOP + ) + + contract = pre.deploy_contract(code=contract_code) + + tx = Transaction(sender=alice, to=contract) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + contract: BalAccountExpectation( + # Numeric slot order, regardless of access order + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0x0A + ) + ], + ), + BalStorageSlot( + slot=0x02, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0x0B + ) + ], + ), + BalStorageSlot( + slot=0xFF, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0x0C + ) + ], + ), + BalStorageSlot( + slot=0x0100, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0x0D + ) + ], + ), + BalStorageSlot( + slot=0x010000, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0x0E + ) + ], + ), + ], + storage_reads=[0x03, 0x0200], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + contract: Account( + storage={ + 0x00: 0x0A, + 0x02: 0x0B, + 0xFF: 0x0C, + 0x0100: 0x0D, + 0x010000: 0x0E, + } + ), + }, + ) + + @EIPChecklist.BlockLevelConstraint.Test.Boundary.Under() @EIPChecklist.BlockLevelConstraint.Test.Boundary.Exact() @EIPChecklist.BlockLevelConstraint.Test.Boundary.Over() diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 6990ea51c8b..f8533d8b1df 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -135,6 +135,7 @@ | `test_bal_selfdestruct_to_7702_delegation` | Ensure BAL correctly handles SELFDESTRUCT to a 7702 delegated account (no code execution on recipient) | Tx1: Alice authorizes delegation to Oracle (sets code to `0xef0100\|\|Oracle`). Tx2: Victim contract (balance=100) executes `SELFDESTRUCT(Alice)`. Two separate transactions in same block. Note: Alice starts with initial balance which accumulates with selfdestruct. | BAL **MUST** include: (1) Alice at block_access_index=1 with `code_changes` (delegation), `nonce_changes`. (2) Alice at block_access_index=2 with `balance_changes` (receives selfdestruct). (3) Victim at block_access_index=2 with `balance_changes` (100→0). **Oracle MUST NOT appear in tx2** - per EVM spec, SELFDESTRUCT transfers balance without executing recipient code, so delegation target is never accessed. | ✅ Completed | | `test_bal_call_revert_insufficient_funds` | Ensure BAL handles value-transferring call failure due to insufficient balance (not OOG), with and without 7702 delegation | Caller contract (balance=100, storage slot 0x02=0xDEAD) executes: `SLOAD(0x01), call_opcode(target, value=1000), SSTORE(0x02, result)`. The call fails because 1000 > 100. Parametrized: (1) `call_opcode` over CALL and CALLCODE via `with_all_call_opcodes(selector=...)`, (2) `delegated` (target is plain EOA vs. 7702-delegated EOA pointing to `delegation_target`=STOP), (3) `target_is_warm` (cold/warm via EIP-2930 access list), (4) `delegation_is_warm` (only when delegated). | BAL **MUST** include: (1) Caller with `storage_reads` for slot 0x01, `storage_changes` for slot 0x02 (value=0, call returned failure). (2) Target with empty changes — accessed before the balance check fails. (3) When delegated: `delegation_target` **MUST NOT** appear in the BAL — the balance check fails before `generic_call` runs, so the delegation target's account is never read. Access-list warming does NOT add to BAL on its own, so the BAL is identical across warm/cold variants. | ✅ Completed | | `test_bal_lexicographic_address_ordering` | Ensure BAL enforces strict lexicographic byte-wise ordering | Pre-fund three addresses with specific byte patterns: `addr_low = 0x0000...0001`, `addr_mid = 0x0000...0100`, `addr_high = 0x0100...0000`. Contract touches them in reverse order: `BALANCE(addr_high), BALANCE(addr_low), BALANCE(addr_mid)`. Additionally, include two endian-trap addresses that are byte-reversals of each other: `addr_endian_low = 0x0100000000000000000000000000000000000002`, `addr_endian_high = 0x0200000000000000000000000000000000000001`. Note: `reverse(addr_endian_low) = addr_endian_high`. Correct lexicographic order: `addr_endian_low < addr_endian_high` (0x01 < 0x02 at byte 0). If implementation incorrectly reverses bytes before comparing, it would get `addr_endian_low > addr_endian_high` (wrong). | BAL account list **MUST** be sorted lexicographically by address bytes: `addr_low` < `addr_mid` < `addr_high` < `addr_endian_low` < `addr_endian_high`, regardless of access order. The endian-trap addresses specifically catch byte-reversal bugs where addresses are compared with wrong byte order. Complements `test_bal_invalid_account_order` which tests rejection; this tests correct generation. | ✅ Completed | +| `test_bal_storage_slot_numeric_ordering` | Ensure BAL sorts storage slots as fixed-width 32-byte keys (numeric order), not by their minimal-length RLP encodings | Contract SSTOREs slots `0x010000`, `0x0100`, `0xFF`, `0x02`, `0x00` (reverse numeric order) and SLOADs empty slots `0x0200`, `0x03`. Slot widths mix 1, 2 and 3 byte minimal encodings: byte-prefix comparison of the stripped keys would order `0x0100` and `0x010000` before `0x02` and `0xFF`. | `storage_changes` **MUST** be ordered `0x00` < `0x02` < `0xFF` < `0x0100` < `0x010000` and `storage_reads` `0x03` < `0x0200`, regardless of access order. Catches clients comparing minimal-length key encodings instead of the full 32-byte (numeric) value (e.g. ethereumjs-monorepo#4341). Storage-slot analogue of `test_bal_lexicographic_address_ordering`. | ✅ Completed | | `test_bal_gas_limit_boundary` | Ensure the BAL max-items cap is enforced on the **final** BAL — including pre-tx system work, user txs, and post-tx system work. Parametrized on two orthogonal axes: `with_tx ∈ {False, True}` × `with_cl_withdrawal ∈ {False, True}` × `boundary_offset ∈ {at, below}`. The `with_cl_withdrawal` axis exercises the EIP-4895 withdrawals path (processed between txs and `process_general_purpose_requests`); combined with `with_tx` it catches clients that validate the cap before `process_withdrawals` runs. | Baseline: 15 system items. `with_tx`: alice → bob value=1 adds 3 items (alice + bob + coinbase warmed via EIP-3651). `with_cl_withdrawal`: one EIP-4895 withdrawal to charlie adds 1 item at `block_access_index = N+1`. Gas limit set to `total_items * BLOCK_ACCESS_LIST_ITEM + boundary_offset`. | At boundary: block **MUST** be accepted; BAL includes alice's nonce_change, bob's balance_change (when `with_tx`), charlie's balance_change at the post-tx index (when `with_cl_withdrawal`). Below boundary: block **MUST** be rejected with `BLOCK_ACCESS_LIST_GAS_LIMIT_EXCEEDED`. | ✅ Completed | | `test_bal_transient_storage_not_tracked` | Ensure BAL excludes EIP-1153 transient storage operations | Contract executes: `TSTORE(0x01, 0x42)` (transient write), `TLOAD(0x01)` (transient read), `SSTORE(0x02, result)` (persistent write using transient value). | BAL **MUST** include slot 0x02 in `storage_changes` (persistent storage was modified). BAL **MUST NOT** include slot 0x01 in `storage_reads` or `storage_changes` (transient storage is not persisted, not needed for stateless execution). This verifies TSTORE/TLOAD don't pollute BAL. | ✅ Completed | | `test_bal_withdrawal_to_7702_delegation` | Ensure BAL correctly handles withdrawal to a 7702 delegated account (no code execution on recipient) | Tx1: Alice authorizes delegation to Oracle (sets code to `0xef0100\|\|Oracle`). Withdrawal: 10 gwei sent to Alice. Single block with tx + withdrawal. | BAL **MUST** include: (1) Alice at block_access_index=1 with `code_changes` (delegation), `nonce_changes`. (2) Alice at block_access_index=2 with `balance_changes` (receives withdrawal). **Oracle MUST NOT appear** - withdrawals credit balance without executing recipient code, so delegation target is never accessed. This complements `test_bal_selfdestruct_to_7702_delegation` (selfdestruct) and `test_bal_withdrawal_no_evm_execution` (withdrawal to contract). | ✅ Completed | From ce0509a90bdf7bdff68a90ba00b4ce956144e450 Mon Sep 17 00:00:00 2001 From: Matthias Seitz Date: Wed, 19 Aug 2026 05:29:32 +0200 Subject: [PATCH 03/59] fix(consume): map reth BAL slot-miss rejection (#3392) --- .../testing/src/execution_testing/client_clis/clis/reth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/execution_testing/client_clis/clis/reth.py b/packages/testing/src/execution_testing/client_clis/clis/reth.py index 0b62494fc30..5fca3242ba4 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/reth.py +++ b/packages/testing/src/execution_testing/client_clis/clis/reth.py @@ -120,7 +120,8 @@ class RethExceptionMapper(ExceptionMapper): BlockException.INVALID_BLOCK_ACCESS_LIST: ( r"block access list hash mismatch|" r"BAL rejection: FinalHashMismatch|" - r"Bal error: Account .* not found in BAL" + r"Bal error: Account .* not found in BAL|" + r"Bal error: Slot .* not found in BAL for account .*" ), BlockException.BLOCK_ACCESS_LIST_GAS_LIMIT_EXCEEDED: ( r"block access list item cost exceeds gas limit" From 3d473e865a6ff78321e8b7b015b0e9a4bb8da892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Thu, 20 Aug 2026 04:45:51 +0200 Subject: [PATCH 04/59] feat(tests): EIP-7928 - union reverted storage reads across transactions (#3399) Add `test_bal_cross_tx_reverted_storage_reads`: two transactions each `SSTORE` a different slot on the same account in a frame that reverts, so the account's block-level `storage_reads` must hold both slots. Reported in https://github.com/erigontech/erigon/issues/23407. --- .../test_block_access_lists.py | 71 +++++++++++++++++++ .../test_cases.md | 1 + 2 files changed, 72 insertions(+) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index 882b700a880..565869afdd0 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -2490,6 +2490,77 @@ def test_bal_cross_tx_storage_write( ) +def test_bal_cross_tx_reverted_storage_reads( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Reverted `SSTORE`s from two transactions accumulate in one account's + `storage_reads`. + + Each transaction succeeds while the frame holding its `SSTORE` reverts, + so both demoted writes must survive their transaction boundary and the + block-level list must hold the union of the two slots. Reported in + https://github.com/erigontech/erigon/issues/23407. + """ + alice = pre.fund_eoa() + slots = [0x01, 0x02] # one per transaction + pre_value = 0xDEAD + + reverting_writer = pre.deploy_contract( + code=Op.SSTORE(Op.CALLDATALOAD(0), 0x42) + Op.REVERT(0, 0), + storage=dict.fromkeys(slots, pre_value), + ) + # Ignores the failed call so the transaction itself succeeds and only + # `reverting_writer`'s frame is rolled back. + caller = pre.deploy_contract( + code=Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.POP( + Op.CALL( + gas=Op.GAS, + address=reverting_writer, + args_offset=0, + args_size=Op.CALLDATASIZE, + ) + ) + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[ + Transaction(sender=alice, to=caller, data=Hash(slot)) + for slot in slots + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=1, post_nonce=1 + ), + BalNonceChange( + block_access_index=2, post_nonce=2 + ), + ], + ), + caller: BalAccountExpectation.empty(), + reverting_writer: BalAccountExpectation( + storage_changes=[], + storage_reads=slots, + ), + } + ), + ) + ], + post={ + alice: Account(nonce=2), + reverting_writer: Account(storage=dict.fromkeys(slots, pre_value)), + }, + ) + + def test_bal_cross_tx_storage_chain( pre: Alloc, blockchain_test: BlockchainTestFiller, diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index f8533d8b1df..cb4462ae31e 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -83,6 +83,7 @@ | `test_bal_multiple_storage_writes_same_slot` | Ensure BAL tracks multiple writes to same storage slot across transactions | Alice calls contract 3 times in same block. Contract increments slot 1 on each call: 0 → 1 → 2 → 3 | BAL **MUST** include contract with slot 1 having three `slot_changes`: txIndex=1 (value 1), txIndex=2 (value 2), txIndex=3 (value 3). Each transaction's write must be recorded separately. | ✅ Completed | | `test_bal_nested_delegatecall_storage_writes_net_zero` | Ensure BAL correctly filters net-zero storage changes across nested DELEGATECALL frames | Parametrized by nesting depth (1-3). Root contract has slot 0 = 1. Each frame writes a different intermediate value via DELEGATECALL chain, deepest frame writes back to original value (1). Example depth=2: 1 → 2 → 3 → 1 | BAL **MUST** include root contract with `storage_reads` for slot 0 but **MUST NOT** include `storage_changes` (net-zero). All delegate contracts **MUST** have empty changes. Tests that frame merging correctly removes parent's intermediate writes when child reverts to pre-tx value. | ✅ Completed | | `test_bal_cross_tx_storage_write` | Ensure storage changes behave as expected across transaction boundaries | Tx1 writes a non-zero value to an empty slot; tx2 either writes zero (back to pre-block) or rewrites the same value (no-op vs post-tx1). | Tx1's change always appears at index 1. The revert case adds tx2's change at index 2 (must not be filtered as net-zero). The same-value case adds nothing and the slot **MUST NOT** appear in storage_reads (uniqueness rule). | ✅ Completed | +| `test_bal_cross_tx_reverted_storage_reads` | Ensure an account's block-level `storage_reads` is the union of reverted writes contributed by two different transactions. Regression for [erigon#23407](https://github.com/erigontech/erigon/issues/23407). | Two txs from Alice call a caller contract that forwards a slot number to a writer contract; the writer `SSTORE`s `0x42` to that slot and `REVERT`s, so each tx succeeds while the writing frame is rolled back. Tx1 targets slot `0x01`, tx2 slot `0x02`; both are pre-set to `0xDEAD`. | BAL **MUST** include the writer with `storage_reads` for slots `0x01` and `0x02` and empty `storage_changes`. Post-state: both slots still hold `0xDEAD`. | ✅ Completed | | `test_bal_cross_tx_storage_chain` | Verify clients apply BAL state changes from prior transactions before executing later transactions in the same block. Each later Tx depends on the two preceding writes (Fibonacci-style), so any tx skipped or run against pre-block state cascades into a wrong slot value and a different state root. | Fixed `chain_length=8`. Single branching contract: Tx i with `i<2` seeds slot i with `1`; Tx i with `i>=2` writes `slot[i] = SLOAD(i-1) + SLOAD(i-2)`. | BAL **MUST** include the contract with `storage_changes` for each slot i (post=`fib(i)` at `block_access_index=i+1`). Post-state: slots 0-7 equal `[1, 1, 2, 3, 5, 8, 13, 21]`. | ✅ Completed | | `test_bal_cross_tx_deploy_then_call` | Verify clients apply Tx1's CREATE to their state view before executing Tx2's CALL in the same block. A client that parallelizes Tx2 without applying Tx1's `code_changes` would hit an empty account, the CALL would no-op, and slot 0 would remain 0. Parametrized over `@pytest.mark.with_all_create_opcodes` (CREATE and CREATE2). | Tx1 (Alice) calls a factory which CREATE/CREATE2s a contract whose runtime is `SSTORE(0, 0x42) + STOP` at a deterministic address. Tx2 (Bob) CALLs that address directly. | BAL **MUST** include the target contract with `nonce_changes` and `code_changes` at `block_access_index=1` (the deployment) and `storage_changes` for slot 0 (post=`0x42`) at `block_access_index=2` (Tx2's CALL through the deployed runtime). Post-state: target contract has runtime code and `slot[0] == 0x42`. | ✅ Completed | | `test_bal_cross_tx_factory_nonce_create_chain` | Verify clients propagate `factory.nonce_changes` across txs when later CREATE addresses derive from the factory's current nonce. The cross-tx dependency signal is solely `nonce_changes` — no storage or balance mutations exist anywhere. A scheduler that deprioritizes nonce_changes (or schedules CREATE-family txs by their resulting distinct addresses) would speculatively derive `addr(factory, N+1)` for every tx and produce only one successful deployment. Parametrized: `failure_mode ∈ ["none", "collision", "oog"]` — `collision` pre-populates the mid-chain target (factory.nonce still bumps; chain slides forward); `oog` gives the mid-chain tx `intrinsic+1` gas so CREATE never fires (factory.nonce does not bump; chain slides backward, reusing the slot). | 8 txs from 8 distinct senders each call a shared factory that does CREATE with identical minimal initcode (deploys `Op.STOP` as runtime). | `none`: factory has 8 sequential `nonce_changes` (post=`N+1`..`N+8`); each address has `nonce_changes`/`code_changes` at its `block_access_index`. `collision`: factory still has 8 sequential `nonce_changes`; the colliding target appears with `BalAccountExpectation.empty()` (accessed under EIP-684, no state change) and its pre-state code is preserved. `oog`: factory has only 7 `nonce_changes` (the OOG tx contributes none); subsequent post_nonce values are shifted -1; the OOG'd tx's would-be address slot is filled by the next tx; the final chain address is never touched (`NONEXISTENT`). Post-state: senders all `nonce=1`; factory `nonce=N+(7 or 8)` depending on mode. | ✅ Completed | From ac0bf4fbb0232a0f40eab6110e503b8e5c64b147 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Sat, 22 Aug 2026 11:22:53 +0200 Subject: [PATCH 05/59] fix(evm-tools): restore state test compatibility (#3409) --- .../evm_tools/statetest/__init__.py | 15 +- .../evm_tools/tests/test_statetest.py | 138 ++++++++++++++++++ 2 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 packages/testing/src/execution_testing/evm_tools/tests/test_statetest.py diff --git a/packages/testing/src/execution_testing/evm_tools/statetest/__init__.py b/packages/testing/src/execution_testing/evm_tools/statetest/__init__.py index 51c6192a90e..6028745f81e 100644 --- a/packages/testing/src/execution_testing/evm_tools/statetest/__init__.py +++ b/packages/testing/src/execution_testing/evm_tools/statetest/__init__.py @@ -108,10 +108,10 @@ def run_test_case( from .. import create_parser env = deepcopy(test_case.env) - try: - env["blockHashes"] = {"0": env["previousHash"]} - except KeyError: - env["blockHashes"] = {} + previous_hash = env.pop("previousHash", None) + env["blockHashes"] = ( + {"0": previous_hash} if previous_hash is not None else {} + ) env["withdrawals"] = [] alloc = deepcopy(test_case.pre) @@ -276,25 +276,26 @@ def run_one(self, path: str, fork_cache: ForkCache) -> int: t8n_extra=t8n_extra, output_basedir=sys.stderr, ) + state_root = result.state_root.hex() # Always output the state root on stderr (even with tracing # disabled) for the holiman/goevmlab integration. json.dump( - {"stateRoot": "0x" + result.state_root.hex()}, + {"stateRoot": state_root}, sys.stderr, ) sys.stderr.write("\n") passed = hex_to_bytes(test_case.post["hash"]) == result.state_root result_dict = { - "stateRoot": "0x" + result.state_root.hex(), + "stateRoot": state_root, "fork": test_case.fork_name, "name": test_case.key, "pass": passed, } if not passed: - actual = result.state_root.hex() + actual = state_root[2:] expected = test_case.post["hash"][2:] result_dict["error"] = ( f"post state root mismatch: got {actual}, want {expected}" diff --git a/packages/testing/src/execution_testing/evm_tools/tests/test_statetest.py b/packages/testing/src/execution_testing/evm_tools/tests/test_statetest.py new file mode 100644 index 00000000000..5a33eae9dae --- /dev/null +++ b/packages/testing/src/execution_testing/evm_tools/tests/test_statetest.py @@ -0,0 +1,138 @@ +"""Tests for general state test execution.""" + +import argparse +import json +from io import StringIO +from types import SimpleNamespace +from typing import Any + +import pytest + +from execution_testing.base_types import Hash +from execution_testing.evm_tools import statetest +from execution_testing.evm_tools.statetest import ( + StateTest, + run_test_case, +) +from execution_testing.evm_tools.statetest import ( + TestCase as StateTestCase, +) +from execution_testing.evm_tools.t8n import ForkCache +from execution_testing.test_types import Environment + +pytestmark = pytest.mark.evm_tools + + +def _test_case(*, env: dict[str, Any], post_hash: str) -> StateTestCase: + """Create a minimal state test case.""" + return StateTestCase( + path="test.json", + key="test_case", + index=0, + fork_name="Shanghai", + post={ + "hash": post_hash, + "indexes": {"data": 0, "gas": 0, "value": 0}, + }, + pre={}, + env=env, + transaction={ + "data": ["0x"], + "gasLimit": ["0x5208"], + "value": ["0x0"], + }, + ) + + +def test_run_test_case_translates_previous_hash( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Translate legacy `previousHash` without passing it to t8n.""" + previous_hash = Hash(1).hex() + test_case = _test_case(env={"previousHash": previous_hash}, post_hash="0x") + captured_input: dict[str, Any] = {} + expected_result = object() + + def fake_build_t8n( + options: argparse.Namespace, + in_file: StringIO, + fork_cache: ForkCache, + ) -> SimpleNamespace: + captured_input.update(json.load(in_file)) + Environment.model_validate(captured_input["env"]) + return SimpleNamespace( + run_state_test=lambda: None, + result=expected_result, + ) + + monkeypatch.setattr( + statetest, + "build_t8n_from_cli_options", + fake_build_t8n, + ) + + with ForkCache() as fork_cache: + result = run_test_case(test_case, fork_cache) + + assert result is expected_result + assert captured_input["env"] == { + "blockHashes": {"0": previous_hash}, + "withdrawals": [], + } + + +def test_run_one_formats_state_root_once( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Emit one `0x` prefix while keeping mismatch hashes unprefixed.""" + state_root = Hash("0x" + "11" * 32) + expected_root = "0x" + "00" * 32 + test_case = _test_case(env={}, post_hash=expected_root) + out_file = StringIO() + state_test = StateTest( + argparse.Namespace( + file=None, + json=False, + memory=True, + stack=True, + return_data=True, + ), + out_file, + StringIO(), + ) + state_test.supported_forks = ("shanghai",) + + def fake_read_test_cases(_path: str) -> list[StateTestCase]: + return [test_case] + + def fake_run_test_case(*_args: Any, **_kwargs: Any) -> SimpleNamespace: + return SimpleNamespace(state_root=state_root) + + monkeypatch.setattr( + statetest, + "read_test_cases", + fake_read_test_cases, + ) + monkeypatch.setattr( + statetest, + "run_test_case", + fake_run_test_case, + ) + + assert state_test.run_one(test_case.path, ForkCache()) == 0 + + assert json.loads(capsys.readouterr().err) == { + "stateRoot": state_root.hex() + } + assert json.loads(out_file.getvalue()) == [ + { + "stateRoot": state_root.hex(), + "fork": "Shanghai", + "name": "test_case", + "pass": False, + "error": ( + f"post state root mismatch: got {'11' * 32}, want {'00' * 32}" + ), + } + ] From 78d8b0db6070c4962f9544277876b080b137c984 Mon Sep 17 00:00:00 2001 From: Tamaghna Choudhuri Date: Sat, 22 Aug 2026 18:56:49 +0530 Subject: [PATCH 06/59] feat(test-specs): add ssz model into the engine payload class (#3294) --- .../src/execution_testing/base_types/ssz.py | 57 +- .../execution_testing/fixtures/blockchain.py | 123 +++- .../fixtures/tests/test_blockchain_ssz.py | 595 ++++++++++++++++++ .../src/execution_testing/forks/__init__.py | 2 + .../src/execution_testing/forks/helpers.py | 23 + .../test_types/block_types.py | 9 +- 6 files changed, 767 insertions(+), 42 deletions(-) create mode 100644 packages/testing/src/execution_testing/fixtures/tests/test_blockchain_ssz.py diff --git a/packages/testing/src/execution_testing/base_types/ssz.py b/packages/testing/src/execution_testing/base_types/ssz.py index 2759a3371a2..f2f662d9bf8 100644 --- a/packages/testing/src/execution_testing/base_types/ssz.py +++ b/packages/testing/src/execution_testing/base_types/ssz.py @@ -35,6 +35,7 @@ from typing import ( Any, ClassVar, + Hashable, List, Mapping, Optional, @@ -72,6 +73,9 @@ from .base_types import Bytes, FixedSizeBytes, HexNumber from .pydantic import CamelModel +ForkKey = Hashable +"""An opaque fork-schema key; base_types knows nothing about forks.""" + _UINTS = { 8: uint8, 16: uint16, @@ -181,18 +185,19 @@ class SSZForkSchema: base_fork; appended maps each later fork (in order) to the fields it adds, which must be declared Optional (T | None) on the model. - Fork keys are opaque strings: base_types knows nothing about forks; + Fork keys are opaque hashable values (e.g. fork classes): base_types + knows nothing about forks; """ - base_fork: str + base_fork: ForkKey base: Tuple[str, ...] - appended: Mapping[str, Tuple[str, ...]] + appended: Mapping[ForkKey, Tuple[str, ...]] - def forks(self) -> Tuple[str, ...]: + def forks(self) -> Tuple[ForkKey, ...]: """Every known fork key, oldest first.""" return (self.base_fork, *self.appended) - def fields_at(self, fork: str) -> Tuple[str, ...]: + def fields_at(self, fork: ForkKey) -> Tuple[str, ...]: """The SSZ field names of fork, in canonical order.""" if fork == self.base_fork: return self.base @@ -547,7 +552,7 @@ def spec_of(model_cls: Type["SSZModel"], name: str) -> SSZType: return _resolve(_marker_in(field.metadata), annotation) -def _rmk_type(spec: SSZType, fork: Optional[str] = None) -> Type[View]: +def _rmk_type(spec: SSZType, fork: Optional[ForkKey] = None) -> Type[View]: if isinstance(spec, SSZUint): return _UINTS[spec.bits] if isinstance(spec, SSZByteVector): @@ -580,8 +585,8 @@ def _active_fields(model_cls: Type["SSZModel"]) -> Sequence[int]: def _nested_fork( - model_cls: Type["SSZModel"], fork: Optional[str] -) -> Optional[str]: + model_cls: Type["SSZModel"], fork: Optional[ForkKey] +) -> Optional[ForkKey]: """ The fork a nested container is projected at. @@ -594,7 +599,7 @@ def _nested_fork( def _schema_fields( - model_cls: Type["SSZModel"], fork: Optional[str] + model_cls: Type["SSZModel"], fork: Optional[ForkKey] ) -> Tuple[str, ...]: """ The SSZ field names of model_cls, in canonical order. @@ -619,7 +624,7 @@ def _schema_fields( def ssz_fields( - model_cls: Type["SSZModel"], fork: Optional[str] = None + model_cls: Type["SSZModel"], fork: Optional[ForkKey] = None ) -> Tuple[str, ...]: """ The SSZ field names of model_cls, in canonical (wire) order. @@ -632,7 +637,7 @@ def ssz_fields( def _check_populated( - model: "SSZModel", names: Tuple[str, ...], fork: str + model: "SSZModel", names: Tuple[str, ...], fork: ForkKey ) -> None: """Raise unless the populated fields exactly match the fork schema.""" missing = [n for n in names if getattr(model, n) is None] @@ -650,7 +655,7 @@ def _check_populated( def build_ssz_type( - model_cls: Type["SSZModel"], fork: Optional[str] = None + model_cls: Type["SSZModel"], fork: Optional[ForkKey] = None ) -> Type[Container]: """ Build the remerkleable container type mirroring model_cls. @@ -664,7 +669,7 @@ def build_ssz_type( @lru_cache(maxsize=None) def _build_ssz_type( - model_cls: Type["SSZModel"], fork: Optional[str] + model_cls: Type["SSZModel"], fork: Optional[ForkKey] ) -> Type[Container]: names = _schema_fields(model_cls, fork) anns = {name: _rmk_type(spec_of(model_cls, name), fork) for name in names} @@ -674,11 +679,11 @@ def _build_ssz_type( ) else: base = Container - cls_name = model_cls.__name__ + (fork if fork else "") + cls_name = model_cls.__name__ + (str(fork) if fork else "") return type(cls_name, (base,), {"__annotations__": anns}) -def _to_rmk(spec: SSZType, value: Any, fork: Optional[str] = None) -> Any: +def _to_rmk(spec: SSZType, value: Any, fork: Optional[ForkKey] = None) -> Any: if isinstance(spec, (SSZContainer, SSZProgressiveContainer)): return _rmk_instance(value, _nested_fork(spec.model, fork)) if isinstance(spec, (SSZList, SSZVector, SSZProgressiveList)): @@ -688,7 +693,9 @@ def _to_rmk(spec: SSZType, value: Any, fork: Optional[str] = None) -> Any: return value # scalar / byte-vector / byte-list: remerkleable coerces -def _rmk_instance(model: "SSZModel", fork: Optional[str] = None) -> Container: +def _rmk_instance( + model: "SSZModel", fork: Optional[ForkKey] = None +) -> Container: model_cls: Type[SSZModel] = type(model) names = _schema_fields(model_cls, fork) if fork is not None: @@ -701,7 +708,7 @@ def _rmk_instance(model: "SSZModel", fork: Optional[str] = None) -> Container: return container(**values) -def _to_py(spec: SSZType, value: Any, fork: Optional[str] = None) -> Any: +def _to_py(spec: SSZType, value: Any, fork: Optional[ForkKey] = None) -> Any: if isinstance(spec, (SSZContainer, SSZProgressiveContainer)): nested = _nested_fork(spec.model, fork) return _view_to_model( @@ -724,7 +731,7 @@ def _view_to_model( model_cls: Type[_M], view: Container, names: Optional[Tuple[str, ...]] = None, - fork: Optional[str] = None, + fork: Optional[ForkKey] = None, ) -> _M: if names is None: names = _included_fields(model_cls) @@ -737,7 +744,7 @@ def _view_to_model( ) -def default_value(spec: SSZType, fork: Optional[str] = None) -> Any: +def default_value(spec: SSZType, fork: Optional[ForkKey] = None) -> Any: """Return the SSZ default (zero) value for spec as a pydantic value.""" if isinstance(spec, SSZUint): return 0 @@ -763,7 +770,7 @@ def default_value(spec: SSZType, fork: Optional[str] = None) -> Any: raise TypeError(f"no default for SSZ type {spec!r}") -def ssz_default(model_cls: Type[_M], fork: Optional[str] = None) -> _M: +def ssz_default(model_cls: Type[_M], fork: Optional[ForkKey] = None) -> _M: """ Build the SSZ default (all-zero) instance of model_cls. @@ -807,7 +814,7 @@ def describe_type(spec: SSZType) -> str: def describe_schema( - model_cls: Type["SSZModel"], fork: Optional[str] = None + model_cls: Type["SSZModel"], fork: Optional[ForkKey] = None ) -> str: """ Render the resolved SSZ layout, one 'field: type' line per field. @@ -821,7 +828,7 @@ def describe_schema( return "\n".join(lines) -def encode(model: "SSZModel", fork: Optional[str] = None) -> bytes: +def encode(model: "SSZModel", fork: Optional[ForkKey] = None) -> bytes: """ Return the SSZ wire bytes of model. @@ -831,7 +838,7 @@ def encode(model: "SSZModel", fork: Optional[str] = None) -> bytes: return _rmk_instance(model, fork).encode_bytes() -def hash_tree_root(model: "SSZModel", fork: Optional[str] = None) -> bytes: +def hash_tree_root(model: "SSZModel", fork: Optional[ForkKey] = None) -> bytes: """ Return the 32-byte SSZ hash_tree_root of model. @@ -840,7 +847,9 @@ def hash_tree_root(model: "SSZModel", fork: Optional[str] = None) -> bytes: return bytes(_rmk_instance(model, fork).hash_tree_root()) -def decode(model_cls: Type[_M], data: bytes, fork: Optional[str] = None) -> _M: +def decode( + model_cls: Type[_M], data: bytes, fork: Optional[ForkKey] = None +) -> _M: """ Decode SSZ data into an instance of model_cls. diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index a7331378786..f50856597ec 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -47,13 +47,30 @@ HexNumber, Number, ZeroPaddedHexNumber, + ssz, unwrap_annotation, ) +from execution_testing.base_types.ssz import ( + SSZForkSchema, + SSZModel, + Uint64, + Uint256, + byte_list, + ssz_list, +) from execution_testing.exceptions import ( EngineAPIError, ExceptionInstanceOrList, ) -from execution_testing.forks import Fork, Paris, TransitionFork +from execution_testing.forks import ( + Amsterdam, + Cancun, + Fork, + Paris, + Shanghai, + TransitionFork, + ssz_schema_fork_key, +) from execution_testing.test_types import ( BlockAccessList, Environment, @@ -385,7 +402,52 @@ def genesis(cls, fork: Fork, env: Environment, state_root: Hash) -> Self: return cls(**environment_values, **extras) -class FixtureExecutionPayload(CamelModel): +MAX_EXTRA_DATA_BYTES = 2**5 +"""Maximum ``extra_data`` length in the SSZ execution payload.""" + +MAX_BYTES_PER_TRANSACTION = 2**30 +"""Maximum encoded size of a single transaction.""" + +MAX_TRANSACTIONS_PER_PAYLOAD = 2**20 +"""Maximum transaction count per payload.""" + +MAX_WITHDRAWALS_PER_PAYLOAD = 2**4 +"""Maximum withdrawal count per payload.""" + +# TODO: EIP-8146 defines this as 2**23 for RLP-encoded block access +# lists; revisit once the value is finalized and verified. +MAX_BLOCK_ACCESS_LIST_BYTES = 2**23 +"""Placeholder cap for the RLP-encoded block access list byte list.""" + + +class ForkScopedSSZModel(SSZModel): + """SSZ model whose methods resolve ``Fork`` classes to schema keys.""" + + @classmethod + def ssz_fork_key(cls, fork: Fork) -> Fork: + """Return this model's SSZ schema key for ``fork``.""" + schema = cls.__ssz_schema__ + if schema is None: + raise TypeError( + f"{cls.__name__} does not declare an SSZ fork schema" + ) + return ssz_schema_fork_key(schema, fork) + + def ssz_encode(self, fork: Fork) -> Bytes: + """Return the SSZ encoding of this model at ``fork``.""" + return Bytes(ssz.encode(self, self.ssz_fork_key(fork))) + + def ssz_hash_tree_root(self, fork: Fork) -> Hash: + """Return the SSZ hash tree root of this model at ``fork``.""" + return Hash(ssz.hash_tree_root(self, self.ssz_fork_key(fork))) + + @classmethod + def ssz_decode(cls, data: bytes, fork: Fork) -> Self: + """Decode an SSZ-encoded instance of this model at ``fork``.""" + return ssz.decode(cls, data, cls.ssz_fork_key(fork)) + + +class FixtureExecutionPayload(ForkScopedSSZModel): """ Representation of an Ethereum execution payload within a test Fixture. """ @@ -401,26 +463,57 @@ class FixtureExecutionPayload(CamelModel): receipts_root: Hash logs_bloom: Bloom - number: HexNumber = Field(..., alias="blockNumber") - gas_limit: HexNumber - gas_used: HexNumber - timestamp: HexNumber - extra_data: Bytes + number: Uint64 = Field(..., alias="blockNumber") + gas_limit: Uint64 + gas_used: Uint64 + timestamp: Uint64 + extra_data: Annotated[Bytes, byte_list(MAX_EXTRA_DATA_BYTES)] prev_randao: Hash - base_fee_per_gas: HexNumber - blob_gas_used: HexNumber | None = Field(None) - excess_blob_gas: HexNumber | None = Field(None) + base_fee_per_gas: Uint256 + blob_gas_used: Uint64 | None = Field(None) + excess_blob_gas: Uint64 | None = Field(None) block_hash: Hash - transactions: List[Bytes] - withdrawals: List[Withdrawal] | None = None + transactions: Annotated[ + List[Annotated[Bytes, byte_list(MAX_BYTES_PER_TRANSACTION)]], + ssz_list(MAX_TRANSACTIONS_PER_PAYLOAD), + ] + withdrawals: ( + Annotated[List[Withdrawal], ssz_list(MAX_WITHDRAWALS_PER_PAYLOAD)] + | None + ) = None - block_access_list: Bytes | None = Field( - None, description="RLP-serialized EIP-7928 Block Access List" + block_access_list: ( + Annotated[Bytes, byte_list(MAX_BLOCK_ACCESS_LIST_BYTES)] | None + ) = Field(None, description="RLP-serialized EIP-7928 Block Access List") + slot_number: Uint64 | None = Field(None) + + __ssz_schema__ = SSZForkSchema( + base_fork=Paris, + base=( + "parent_hash", + "fee_recipient", + "state_root", + "receipts_root", + "logs_bloom", + "prev_randao", + "number", + "gas_limit", + "gas_used", + "timestamp", + "extra_data", + "base_fee_per_gas", + "block_hash", + "transactions", + ), + appended={ + Shanghai: ("withdrawals",), + Cancun: ("blob_gas_used", "excess_blob_gas"), + Amsterdam: ("block_access_list", "slot_number"), + }, ) - slot_number: HexNumber | None = Field(None) @classmethod def from_fixture_header( diff --git a/packages/testing/src/execution_testing/fixtures/tests/test_blockchain_ssz.py b/packages/testing/src/execution_testing/fixtures/tests/test_blockchain_ssz.py new file mode 100644 index 00000000000..8e7c37aed74 --- /dev/null +++ b/packages/testing/src/execution_testing/fixtures/tests/test_blockchain_ssz.py @@ -0,0 +1,595 @@ +""" +Tests for the SSZ behavior of the engine execution payload fixture. +""" + +from typing import Any, Dict, Optional, Tuple, Type + +import pytest +from pydantic import ValidationError +from remerkleable.basic import uint64, uint256 +from remerkleable.byte_arrays import ByteList, ByteVector +from remerkleable.complex import Container +from remerkleable.complex import List as RmkList + +from execution_testing.base_types import ( + Address, + Bloom, + Bytes, + Hash, + HeaderNonce, + ssz, + to_json, +) +from execution_testing.base_types.ssz import ( + SSZForkSchema, + Uint64, + Uint256, +) +from execution_testing.forks import ( + BPO1, + Amsterdam, + BPO2ToAmsterdamAtTime15k, + Cancun, + Fork, + London, + Osaka, + Paris, + Prague, + Shanghai, + ShanghaiToCancunAtTime15k, + ssz_schema_fork_key, +) +from execution_testing.test_types import Withdrawal + +from ..blockchain import ( + MAX_BLOCK_ACCESS_LIST_BYTES, + MAX_BYTES_PER_TRANSACTION, + MAX_EXTRA_DATA_BYTES, + MAX_TRANSACTIONS_PER_PAYLOAD, + MAX_WITHDRAWALS_PER_PAYLOAD, + FixtureEngineNewPayload, + FixtureExecutionPayload, + FixtureExecutionPayloadModifier, + FixtureHeader, + ForkScopedSSZModel, +) + +BASE_FIELDS = ( + "parent_hash", + "fee_recipient", + "state_root", + "receipts_root", + "logs_bloom", + "prev_randao", + "number", + "gas_limit", + "gas_used", + "timestamp", + "extra_data", + "base_fee_per_gas", + "block_hash", + "transactions", +) + + +class RefWithdrawal(Container): + """Hand-written twin of Withdrawal.""" + + index: uint64 + validator_index: uint64 + address: ByteVector[20] + amount: uint64 + + +class RefPayloadParis(Container): + """Hand-written twin of FixtureExecutionPayload at Paris.""" + + parent_hash: ByteVector[32] + fee_recipient: ByteVector[20] + state_root: ByteVector[32] + receipts_root: ByteVector[32] + logs_bloom: ByteVector[256] + prev_randao: ByteVector[32] + block_number: uint64 + gas_limit: uint64 + gas_used: uint64 + timestamp: uint64 + extra_data: ByteList[MAX_EXTRA_DATA_BYTES] + base_fee_per_gas: uint256 + block_hash: ByteVector[32] + transactions: RmkList[ + ByteList[MAX_BYTES_PER_TRANSACTION], + MAX_TRANSACTIONS_PER_PAYLOAD, + ] + + +class RefPayloadShanghai(Container): + """Hand-written twin of FixtureExecutionPayload at Shanghai.""" + + parent_hash: ByteVector[32] + fee_recipient: ByteVector[20] + state_root: ByteVector[32] + receipts_root: ByteVector[32] + logs_bloom: ByteVector[256] + prev_randao: ByteVector[32] + block_number: uint64 + gas_limit: uint64 + gas_used: uint64 + timestamp: uint64 + extra_data: ByteList[MAX_EXTRA_DATA_BYTES] + base_fee_per_gas: uint256 + block_hash: ByteVector[32] + transactions: RmkList[ + ByteList[MAX_BYTES_PER_TRANSACTION], + MAX_TRANSACTIONS_PER_PAYLOAD, + ] + withdrawals: RmkList[RefWithdrawal, MAX_WITHDRAWALS_PER_PAYLOAD] + + +class RefPayloadCancun(Container): + """Hand-written twin of FixtureExecutionPayload at Cancun.""" + + parent_hash: ByteVector[32] + fee_recipient: ByteVector[20] + state_root: ByteVector[32] + receipts_root: ByteVector[32] + logs_bloom: ByteVector[256] + prev_randao: ByteVector[32] + block_number: uint64 + gas_limit: uint64 + gas_used: uint64 + timestamp: uint64 + extra_data: ByteList[MAX_EXTRA_DATA_BYTES] + base_fee_per_gas: uint256 + block_hash: ByteVector[32] + transactions: RmkList[ + ByteList[MAX_BYTES_PER_TRANSACTION], + MAX_TRANSACTIONS_PER_PAYLOAD, + ] + withdrawals: RmkList[RefWithdrawal, MAX_WITHDRAWALS_PER_PAYLOAD] + blob_gas_used: uint64 + excess_blob_gas: uint64 + + +class RefPayloadAmsterdam(Container): + """Hand-written twin of FixtureExecutionPayload at Amsterdam.""" + + parent_hash: ByteVector[32] + fee_recipient: ByteVector[20] + state_root: ByteVector[32] + receipts_root: ByteVector[32] + logs_bloom: ByteVector[256] + prev_randao: ByteVector[32] + block_number: uint64 + gas_limit: uint64 + gas_used: uint64 + timestamp: uint64 + extra_data: ByteList[MAX_EXTRA_DATA_BYTES] + base_fee_per_gas: uint256 + block_hash: ByteVector[32] + transactions: RmkList[ + ByteList[MAX_BYTES_PER_TRANSACTION], + MAX_TRANSACTIONS_PER_PAYLOAD, + ] + withdrawals: RmkList[RefWithdrawal, MAX_WITHDRAWALS_PER_PAYLOAD] + blob_gas_used: uint64 + excess_blob_gas: uint64 + block_access_list: ByteList[MAX_BLOCK_ACCESS_LIST_BYTES] + slot_number: uint64 + + +REF_PAYLOAD_CLASSES: Dict[str, Type[Container]] = { + "Paris": RefPayloadParis, + "Shanghai": RefPayloadShanghai, + "Cancun": RefPayloadCancun, + "Amsterdam": RefPayloadAmsterdam, +} + +FORK_BY_KEY: Dict[str, Fork] = { + "Paris": Paris, + "Shanghai": Shanghai, + "Cancun": Cancun, + "Amsterdam": Amsterdam, +} + + +def _withdrawal(i: int) -> Withdrawal: + """Build a distinctive withdrawal for index ``i``.""" + return Withdrawal( + index=i, + validator_index=i + 1, + address=Address(bytes([0x11 + i]) * 20), + amount=32_000_000_000 + i, + ) + + +def _ref_withdrawal(withdrawal: Withdrawal) -> Container: + """Build the remerkleable twin of ``withdrawal``.""" + return RefWithdrawal( + index=int(withdrawal.index), + validator_index=int(withdrawal.validator_index), + address=bytes(withdrawal.address), + amount=int(withdrawal.amount), + ) + + +def _payload_kwargs(fork_key: str) -> Dict[str, Any]: + """Build constructor kwargs populated to exactly ``fork_key``.""" + kwargs: Dict[str, Any] = dict( + parent_hash=Hash(b"\xaa" * 32), + fee_recipient=Address(b"\xbb" * 20), + state_root=Hash(b"\xcc" * 32), + receipts_root=Hash(b"\xdd" * 32), + logs_bloom=Bloom(b"\x00" * 256), + number=1, + gas_limit=30_000_000, + gas_used=21_000, + timestamp=1_700_000_000, + extra_data=Bytes(b"\xde\xad"), + prev_randao=Hash(b"\xee" * 32), + base_fee_per_gas=10**18, + block_hash=Hash(b"\xff" * 32), + transactions=[Bytes(b"\x02\xf8"), Bytes(b"\x03" * 5)], + ) + if fork_key in ("Shanghai", "Cancun", "Amsterdam"): + kwargs["withdrawals"] = [_withdrawal(0), _withdrawal(1)] + if fork_key in ("Cancun", "Amsterdam"): + kwargs["blob_gas_used"] = 131_072 + kwargs["excess_blob_gas"] = 262_144 + if fork_key == "Amsterdam": + kwargs["block_access_list"] = Bytes(b"\xc0") + kwargs["slot_number"] = 12 + return kwargs + + +def _payload(fork_key: str) -> FixtureExecutionPayload: + """Build a payload populated to exactly ``fork_key``'s fields.""" + return FixtureExecutionPayload(**_payload_kwargs(fork_key)) + + +def _ref_payload(payload: FixtureExecutionPayload, fork_key: str) -> Container: + """Build the remerkleable twin of ``payload`` at ``fork_key``.""" + kwargs: Dict[str, Any] = dict( + parent_hash=bytes(payload.parent_hash), + fee_recipient=bytes(payload.fee_recipient), + state_root=bytes(payload.state_root), + receipts_root=bytes(payload.receipts_root), + logs_bloom=bytes(payload.logs_bloom), + prev_randao=bytes(payload.prev_randao), + block_number=int(payload.number), + gas_limit=int(payload.gas_limit), + gas_used=int(payload.gas_used), + timestamp=int(payload.timestamp), + extra_data=bytes(payload.extra_data), + base_fee_per_gas=int(payload.base_fee_per_gas), + block_hash=bytes(payload.block_hash), + transactions=[bytes(tx) for tx in payload.transactions], + ) + if fork_key in ("Shanghai", "Cancun", "Amsterdam"): + assert payload.withdrawals is not None + kwargs["withdrawals"] = [ + _ref_withdrawal(w) for w in payload.withdrawals + ] + if fork_key in ("Cancun", "Amsterdam"): + kwargs["blob_gas_used"] = int(payload.blob_gas_used or 0) + kwargs["excess_blob_gas"] = int(payload.excess_blob_gas or 0) + if fork_key == "Amsterdam": + kwargs["block_access_list"] = bytes(payload.block_access_list or b"") + kwargs["slot_number"] = int(payload.slot_number or 0) + return REF_PAYLOAD_CLASSES[fork_key](**kwargs) + + +def assert_matches_reference( + model: ssz.SSZModel, ref: Container, fork_key: Optional[str] = None +) -> None: + """ + Compare the engine against a hand-written remerkleable twin. + + The twin is the ground truth: wire bytes, merkle root, decode + round-trip, and the zero value must all agree. + """ + fork = FORK_BY_KEY[fork_key] if fork_key is not None else None + model_cls = type(model) + ref_cls = type(ref) + raw = ssz.encode(model, fork) + assert raw == ref.encode_bytes() + assert ssz.hash_tree_root(model, fork) == bytes(ref.hash_tree_root()) + restored = ssz.decode(model_cls, raw, fork) + assert ssz.encode(restored, fork) == raw + assert restored == model + zero = ssz.ssz_default(model_cls, fork) + assert ssz.encode(zero, fork) == ref_cls().encode_bytes() + assert ssz.hash_tree_root(zero, fork) == bytes(ref_cls().hash_tree_root()) + + +def test_withdrawal_matches_reference() -> None: + """The withdrawal container is byte-identical to its twin.""" + assert_matches_reference(_withdrawal(0), _ref_withdrawal(_withdrawal(0))) + + +def test_withdrawal_json_unchanged() -> None: + """The withdrawal keeps its camelCase un-padded hex JSON shape.""" + withdrawal = Withdrawal( + index=0, + validator_index=1, + address=0x1234, + amount=2, + ) + json_repr = { + "index": "0x0", + "validatorIndex": "0x1", + "address": "0x0000000000000000000000000000000000001234", + "amount": "0x2", + } + assert to_json(withdrawal) == json_repr + assert Withdrawal(**json_repr) == withdrawal + + +def test_withdrawal_width_enforced() -> None: + """A withdrawal amount beyond 64 bits fails validation.""" + with pytest.raises(ValidationError): + Withdrawal( + index=0, + validator_index=0, + address=Address(b"\x11" * 20), + amount=2**64, + ) + + +@pytest.mark.parametrize( + "fork_key", ["Paris", "Shanghai", "Cancun", "Amsterdam"] +) +def test_payload_matches_reference(fork_key: str) -> None: + """Every fork's payload is byte-identical to its twin.""" + payload = _payload(fork_key) + assert_matches_reference( + payload, _ref_payload(payload, fork_key), fork_key + ) + + +@pytest.mark.parametrize( + "fork_key,expected", + [ + pytest.param("Paris", BASE_FIELDS, id="Paris"), + pytest.param("Shanghai", (*BASE_FIELDS, "withdrawals"), id="Shanghai"), + pytest.param( + "Cancun", + ( + *BASE_FIELDS, + "withdrawals", + "blob_gas_used", + "excess_blob_gas", + ), + id="Cancun", + ), + pytest.param( + "Amsterdam", + ( + *BASE_FIELDS, + "withdrawals", + "blob_gas_used", + "excess_blob_gas", + "block_access_list", + "slot_number", + ), + id="Amsterdam", + ), + ], +) +def test_payload_ssz_field_order( + fork_key: str, expected: Tuple[str, ...] +) -> None: + """The canonical wire order is pinned per fork.""" + assert ( + ssz.ssz_fields(FixtureExecutionPayload, FORK_BY_KEY[fork_key]) + == expected + ) + + +@pytest.mark.parametrize( + "fork,expected_key", + [ + pytest.param(Paris, Paris, id="Paris"), + pytest.param(Shanghai, Shanghai, id="Shanghai"), + pytest.param(Cancun, Cancun, id="Cancun"), + pytest.param(Prague, Cancun, id="Prague"), + pytest.param(Osaka, Cancun, id="Osaka"), + pytest.param(BPO1, Cancun, id="BPO1"), + pytest.param(Amsterdam, Amsterdam, id="Amsterdam"), + pytest.param( + ShanghaiToCancunAtTime15k, + Cancun, + id="ShanghaiToCancunAtTime15k", + ), + pytest.param( + BPO2ToAmsterdamAtTime15k, + Amsterdam, + id="BPO2ToAmsterdamAtTime15k", + ), + ], +) +def test_fork_key_resolution(fork: Fork, expected_key: Fork) -> None: + """Payload-neutral forks resolve to the nearest earlier key.""" + assert FixtureExecutionPayload.ssz_fork_key(fork) == expected_key + + +def test_fork_key_resolution_pre_base_raises() -> None: + """A fork older than the schema's base fork is rejected.""" + with pytest.raises(ValueError, match="predates"): + FixtureExecutionPayload.ssz_fork_key(London) + + +def test_payload_ssz_methods_accept_fork_classes() -> None: + """The mixin's SSZ methods take Fork classes, not key strings.""" + payload = _payload("Cancun") + wire = payload.ssz_encode(Prague) + assert bytes(wire) == ssz.encode(payload, Cancun) + assert FixtureExecutionPayload.ssz_decode(bytes(wire), Prague) == payload + amsterdam_payload = _payload("Amsterdam") + root = amsterdam_payload.ssz_hash_tree_root(Amsterdam) + assert isinstance(root, Hash) + assert len(root) == 32 + assert bytes(root) == ssz.hash_tree_root(amsterdam_payload, Amsterdam) + + +def test_payload_json_unchanged() -> None: + """The payload keeps its camelCase un-padded hex JSON shape.""" + payload = _payload("Amsterdam") + json_repr = { + "parentHash": "0x" + "aa" * 32, + "feeRecipient": "0x" + "bb" * 20, + "stateRoot": "0x" + "cc" * 32, + "receiptsRoot": "0x" + "dd" * 32, + "logsBloom": "0x" + "00" * 256, + "blockNumber": "0x1", + "gasLimit": "0x1c9c380", + "gasUsed": "0x5208", + "timestamp": "0x6553f100", + "extraData": "0xdead", + "prevRandao": "0x" + "ee" * 32, + "baseFeePerGas": "0xde0b6b3a7640000", + "blobGasUsed": "0x20000", + "excessBlobGas": "0x40000", + "blockHash": "0x" + "ff" * 32, + "transactions": ["0x02f8", "0x0303030303"], + "withdrawals": [ + { + "index": "0x0", + "validatorIndex": "0x1", + "address": "0x" + "11" * 20, + "amount": "0x773594000", + }, + { + "index": "0x1", + "validatorIndex": "0x2", + "address": "0x" + "12" * 20, + "amount": "0x773594001", + }, + ], + "blockAccessList": "0xc0", + "slotNumber": "0xc", + } + assert to_json(payload) == json_repr + assert FixtureExecutionPayload(**json_repr) == payload + cancun_json = to_json(_payload("Cancun")) + assert "blockAccessList" not in cancun_json + assert "slotNumber" not in cancun_json + + +def test_payload_uint_width_enforced() -> None: + """A payload gas limit beyond 64 bits fails validation.""" + kwargs = _payload_kwargs("Paris") + kwargs["gas_limit"] = 2**64 + with pytest.raises(ValidationError): + FixtureExecutionPayload(**kwargs) + + +def _amsterdam_header() -> FixtureHeader: + """Build a fully-populated Amsterdam header.""" + return FixtureHeader( + parent_hash=Hash(0), + ommers_hash=Hash(1), + fee_recipient=Address(2), + state_root=Hash(3), + transactions_trie=Hash(4), + receipts_root=Hash(5), + logs_bloom=Bloom(6), + difficulty=7, + number=1, + gas_limit=9, + gas_used=10, + timestamp=11, + extra_data=Bytes([12]), + prev_randao=Hash(13), + nonce=HeaderNonce(14), + base_fee_per_gas=15, + withdrawals_root=Hash(16), + blob_gas_used=17, + excess_blob_gas=18, + parent_beacon_block_root=19, + requests_hash=20, + block_access_list_hash=Hash(21), + slot_number=22, + ) + + +def test_from_fixture_header_round_trip() -> None: + """A payload built through the fill pipeline SSZ round-trips.""" + new_payload = FixtureEngineNewPayload.from_fixture_header( + fork=Amsterdam, + header=_amsterdam_header(), + transactions=[], + withdrawals=[], + requests=[], + block_access_list=Bytes(b"\xc0"), + ) + payload = new_payload.params[0] + assert isinstance(payload.number, Uint64) + assert isinstance(payload.base_fee_per_gas, Uint256) + wire = payload.ssz_encode(Amsterdam) + restored = FixtureExecutionPayload.ssz_decode(bytes(wire), Amsterdam) + assert restored == payload + ref = _ref_payload(payload, "Amsterdam") + assert bytes(payload.ssz_hash_tree_root(Amsterdam)) == bytes( + ref.hash_tree_root() + ) + + +def test_encode_wrong_fork_refuses_to_drop_data() -> None: + """Encoding at a fork that does not fit the populated fields fails.""" + with pytest.raises(TypeError, match="unexpected"): + ssz.encode(_payload("Amsterdam"), Cancun) + with pytest.raises(TypeError, match="missing"): + ssz.encode(_payload("Cancun"), Amsterdam) + + +def test_decode_older_fork_leaves_future_fields_none() -> None: + """Decoding Paris wire bytes leaves post-Paris fields as None.""" + wire = ssz.encode(_payload("Paris"), Paris) + restored = ssz.decode(FixtureExecutionPayload, wire, Paris) + assert restored.withdrawals is None + assert restored.blob_gas_used is None + assert restored.excess_blob_gas is None + assert restored.block_access_list is None + assert restored.slot_number is None + + +def test_modifier_removed_fields_encode_at_earlier_fork() -> None: + """REMOVE_FIELD strips the Amsterdam tail so Cancun encoding fits.""" + modifier = FixtureExecutionPayloadModifier( + block_access_list=FixtureExecutionPayloadModifier.REMOVE_FIELD, + slot_number=FixtureExecutionPayloadModifier.REMOVE_FIELD, + ) + result = modifier.apply(_payload("Amsterdam")) + cancun_payload = _payload("Cancun") + assert ssz.encode(result, Cancun) == ssz.encode(cancun_payload, Cancun) + with pytest.raises(TypeError, match="missing"): + ssz.encode(result, Amsterdam) + + +def test_mixin_without_schema_raises() -> None: + """A mixin subclass without a schema cannot resolve fork keys.""" + + class NoSchema(ForkScopedSSZModel): + """A fork-scoped mixin subclass missing its schema.""" + + with pytest.raises(TypeError, match="does not declare"): + NoSchema.ssz_fork_key(Paris) + + +def test_unknown_schema_fork_key_raises() -> None: + """A schema key that is not a fork name is rejected.""" + schema = SSZForkSchema(base_fork="NotAFork", base=(), appended={}) + with pytest.raises(ValueError, match="not a fork class"): + ssz_schema_fork_key(schema, Paris) + + +def test_describe_schema_amsterdam() -> None: + """The Amsterdam schema description ends with the new fields.""" + description = ssz.describe_schema(FixtureExecutionPayload, Amsterdam) + assert "Amsterdam" in description + assert "block_access_list" in description + assert "slot_number" in description + assert description.index("block_access_list") < description.index( + "slot_number" + ) diff --git a/packages/testing/src/execution_testing/forks/__init__.py b/packages/testing/src/execution_testing/forks/__init__.py index cd333c559e8..95f54ab7dcc 100644 --- a/packages/testing/src/execution_testing/forks/__init__.py +++ b/packages/testing/src/execution_testing/forks/__init__.py @@ -71,6 +71,7 @@ get_transition_fork_predecessor, get_transition_fork_successor, get_transition_forks, + ssz_schema_fork_key, transition_fork_from_to, transition_fork_to, ) @@ -141,6 +142,7 @@ "get_from_until_fork_set", "get_last_descendants", "get_selected_fork_set", + "ssz_schema_fork_key", "transition_fork_from_to", "transition_fork_to", "GasCosts", diff --git a/packages/testing/src/execution_testing/forks/helpers.py b/packages/testing/src/execution_testing/forks/helpers.py index ba643146bf4..93c45739233 100644 --- a/packages/testing/src/execution_testing/forks/helpers.py +++ b/packages/testing/src/execution_testing/forks/helpers.py @@ -23,6 +23,8 @@ model_validator, ) +from execution_testing.base_types.ssz import SSZForkSchema + from .base_fork import BaseFork from .forks import eips, forks, transition from .transition_base_fork import TransitionBaseClass @@ -388,6 +390,27 @@ def get_fork_by_name(fork_name: str) -> Type[BaseFork] | None: return None +def ssz_schema_fork_key( + schema: SSZForkSchema, fork: Type[BaseFork] +) -> Type[BaseFork]: + """ + Return the newest schema fork at or before ``fork``. + + Schema keys are the fork classes themselves. Transition forks + compare as their destination fork. + """ + for key in reversed(schema.forks()): + if not (isinstance(key, type) and issubclass(key, BaseFork)): + raise ValueError( + f"SSZ schema fork key {key!r} is not a fork class" + ) + if fork >= key: + return key + raise ValueError( + f"{fork.name()} predates the SSZ schema base fork {schema.base_fork!r}" + ) + + class ForkRangeDescriptor(BaseModel): """ Fork descriptor parsed from string normally contained in ethereum/tests diff --git a/packages/testing/src/execution_testing/test_types/block_types.py b/packages/testing/src/execution_testing/test_types/block_types.py index 89e1ce88eca..b6fb5d06206 100644 --- a/packages/testing/src/execution_testing/test_types/block_types.py +++ b/packages/testing/src/execution_testing/test_types/block_types.py @@ -20,6 +20,7 @@ NumberBoundTypeVar, ZeroPaddedHexNumber, ) +from execution_testing.base_types.ssz import SSZModel, Uint64 from execution_testing.forks import Fork DEFAULT_BASE_FEE = 7 @@ -72,10 +73,12 @@ def list_root(withdrawals: Sequence["WithdrawalGeneric"]) -> bytes: return t.root_hash -class Withdrawal(WithdrawalGeneric[HexNumber]): - """Withdrawal type.""" +class Withdrawal(WithdrawalGeneric[HexNumber], SSZModel): + """Withdrawal type; also the consensus-layer SSZ container.""" - pass + index: Uint64 + validator_index: Uint64 + amount: Uint64 class EnvironmentGeneric(CamelModel, Generic[NumberBoundTypeVar]): From 7ae05827b0fc919377e90a5a1a98b99db9d0bef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Mon, 24 Aug 2026 07:28:13 +0200 Subject: [PATCH 07/59] feat(tests): EIP-7702 - keep authority storage when clearing a delegation (#3421) Clearing a delegation resets the authority's code and must leave its storage untouched. Every existing fixture that clears a delegation has an authority with empty storage, so the storage half of the rule was never asserted. Put both the delegation designation and a non-empty slot in the pre-state, so the delegation is older than the block being executed, and assert the slot survives three ways: on its own, after clearing and re-delegating in one authorization list, and when read back by a later transaction of the same block. The read-back cases pin more than the post state: touching the authority's storage in the same block as the clear forces the live slot set to be consulted, and writing a previously untouched slot forces the storage root to be recomputed from that set. --- .../eip7702_set_code_tx/test_set_code_txs.py | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/tests/prague/eip7702_set_code_tx/test_set_code_txs.py b/tests/prague/eip7702_set_code_tx/test_set_code_txs.py index 1649b3ee1ba..a4f0a681b94 100644 --- a/tests/prague/eip7702_set_code_tx/test_set_code_txs.py +++ b/tests/prague/eip7702_set_code_tx/test_set_code_txs.py @@ -3680,6 +3680,188 @@ def test_delegation_clearing( ) +@pytest.mark.parametrize( + "self_sponsored", + [ + pytest.param(False, id="not_self_sponsored"), + pytest.param(True, id="self_sponsored"), + ], +) +def test_delegation_clearing_preserves_storage( + state_test: StateTestFiller, + pre: Alloc, + self_sponsored: bool, +) -> None: + """ + Test that clearing the delegation of an account that already carries a + delegation in the pre-state resets its code but leaves its storage + untouched. + """ + slot_preserved = 1 + + delegation_address = pre.deploy_contract(Op.STOP) + storage = Storage({slot_preserved: 0x2A}) # type: ignore[dict-item] + + auth_signer = pre.fund_eoa(delegation=delegation_address, storage=storage) + + authorization = AuthorizationTuple( + address=Spec.RESET_DELEGATION_ADDRESS, + nonce=auth_signer.nonce + (1 if self_sponsored else 0), + signer=auth_signer, + ) + + tx = Transaction( + to=pre.deploy_contract(Op.STOP), + value=0, + authorization_list=[authorization], + sender=auth_signer if self_sponsored else pre.fund_eoa(), + ) + + state_test( + env=Environment(), + pre=pre, + tx=tx, + post={ + auth_signer: Account( + nonce=auth_signer.nonce + 1, + code=b"", + storage=storage, + ), + }, + ) + + +def test_delegation_clearing_and_set_preserves_storage( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Test that clearing a pre-state delegation and setting a new one in the + same authorization list keeps the authority's storage intact and + readable. + """ + slot_preserved = 1 + slot_read_back = 2 + slot_marker = 3 + + auth_signer = pre.fund_eoa( + 0, + delegation=pre.deploy_contract(Op.STOP), + storage=Storage({slot_preserved: 0x2A}), # type: ignore[dict-item] + ) + reader = pre.deploy_contract( + Op.SSTORE(slot_read_back, Op.SLOAD(slot_preserved)) + # The write to an untouched slot forces the storage root to be + # recomputed from the whole slot set. + + Op.SSTORE(slot_marker, 1) + + Op.STOP + ) + + tx = Transaction( + to=auth_signer, + value=0, + authorization_list=[ + AuthorizationTuple( + address=Spec.RESET_DELEGATION_ADDRESS, # Reset + nonce=auth_signer.nonce, + signer=auth_signer, + ), + AuthorizationTuple( + address=reader, + nonce=auth_signer.nonce + 1, + signer=auth_signer, + ), + ], + sender=pre.fund_eoa(), + ) + + state_test( + env=Environment(), + pre=pre, + tx=tx, + post={ + auth_signer: Account( + nonce=auth_signer.nonce + 2, + code=Spec.delegation_designation(reader), + storage={ + slot_preserved: 0x2A, + slot_read_back: 0x2A, + slot_marker: 1, + }, + ), + }, + ) + + +def test_delegation_clearing_storage_readable_in_later_tx( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + Test that the storage of an authority whose pre-state delegation is + cleared is still readable by a later transaction of the same block. + """ + slot_preserved = 1 + slot_read_back = 2 + slot_marker = 3 + + auth_signer = pre.fund_eoa( + 0, + delegation=pre.deploy_contract(Op.STOP), + storage=Storage({slot_preserved: 0x2A}), # type: ignore[dict-item] + ) + reader = pre.deploy_contract( + Op.SSTORE(slot_read_back, Op.SLOAD(slot_preserved)) + # The write to an untouched slot forces the storage root to be + # recomputed from the whole slot set. + + Op.SSTORE(slot_marker, 1) + + Op.STOP + ) + + sender = pre.fund_eoa() + + tx_1 = Transaction( + to=auth_signer, + value=0, + authorization_list=[ + AuthorizationTuple( + address=Spec.RESET_DELEGATION_ADDRESS, # Reset + nonce=auth_signer.nonce, + signer=auth_signer, + ), + ], + sender=sender, + ) + tx_2 = Transaction( + to=auth_signer, + value=0, + authorization_list=[ + AuthorizationTuple( + address=reader, + nonce=auth_signer.nonce + 1, + signer=auth_signer, + ), + ], + sender=sender, + ) + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx_1, tx_2])], + post={ + auth_signer: Account( + nonce=auth_signer.nonce + 2, + code=Spec.delegation_designation(reader), + storage={ + slot_preserved: 0x2A, + slot_read_back: 0x2A, + slot_marker: 1, + }, + ), + }, + ) + + @pytest.mark.parametrize( "self_sponsored", [ From df93c004ab19c3f04350c5b29dc29df15cf5f26e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Mon, 24 Aug 2026 07:30:39 +0200 Subject: [PATCH 08/59] feat(tests): EIP-7702 - precompile calls from a delegated frame (#3420) Parametrize the direct precompile call made by a frame that was entered through a 7702 delegation over CALL, CALLCODE, DELEGATECALL and STATICCALL; only the CALL variant was covered before. The frame calls the identity precompile and stores the call result, RETURNDATASIZE and the echoed word, so the precompile's execution is pinned by its output rather than only by a gas difference, which keeps the observable meaningful under STATICCALL. --- .../test_set_code_txs_2.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/prague/eip7702_set_code_tx/test_set_code_txs_2.py b/tests/prague/eip7702_set_code_tx/test_set_code_txs_2.py index 11720ceed64..36b548eec4c 100644 --- a/tests/prague/eip7702_set_code_tx/test_set_code_txs_2.py +++ b/tests/prague/eip7702_set_code_tx/test_set_code_txs_2.py @@ -519,6 +519,73 @@ def test_call_to_precompile_in_pointer_context( ) +@pytest.mark.with_all_call_opcodes +@pytest.mark.valid_from("Prague") +def test_precompile_call_from_delegated_frame( + state_test: StateTestFiller, + pre: Alloc, + call_opcode: Op, +) -> None: + """ + Tx -> entry contract -> delegated authority -> precompile. + + Being reached through a delegation must not stop the frame from calling a + precompile directly, for any of the call opcodes. + """ + storage: Storage = Storage() + + identity_precompile = 0x04 + # Identity echoes its input, so a precompile that did not run is visible + # as empty return data instead of only as a gas difference. + precompile_input = 0xC0FFEE + + delegation_target = pre.deploy_contract( + code=Op.MSTORE(0, precompile_input) + + Op.SSTORE( + storage.store_next(1, "call_result"), + call_opcode( + address=identity_precompile, + args_offset=0, + args_size=32, + ret_offset=32, + ret_size=32, + ), + ) + + Op.SSTORE( + storage.store_next(32, "returndatasize"), Op.RETURNDATASIZE + ) + + Op.SSTORE( + storage.store_next(precompile_input, "returned_data"), + Op.MLOAD(32), + ) + + Op.STOP + ) + + authority = pre.fund_eoa() + entry_contract = pre.deploy_contract( + code=Op.CALL(address=authority) + Op.STOP + ) + + tx = Transaction( + to=entry_contract, + sender=pre.fund_eoa(), + authorization_list=[ + AuthorizationTuple( + address=delegation_target, + nonce=0, + signer=authority, + ) + ], + ) + + post = {authority: Account(storage=storage)} + state_test( + pre=pre, + post=post, + tx=tx, + ) + + @pytest.mark.with_all_precompiles @pytest.mark.valid_from("Prague") @pytest.mark.parametrize("sender_delegated", [True, False]) From 263606b3cd0a352112697870c2c878e2423546b7 Mon Sep 17 00:00:00 2001 From: shubham shinde Date: Mon, 24 Aug 2026 15:34:29 +0530 Subject: [PATCH 09/59] test(spec-specs): add unit tests for get_last_256_block_hashes (#3397) Co-authored-by: spencer-tb --- src/ethereum/forks/amsterdam/fork.py | 1 - src/ethereum/forks/arrow_glacier/fork.py | 1 - src/ethereum/forks/berlin/fork.py | 1 - src/ethereum/forks/bpo1/fork.py | 1 - src/ethereum/forks/bpo2/fork.py | 1 - src/ethereum/forks/bpo3/fork.py | 1 - src/ethereum/forks/bpo4/fork.py | 1 - src/ethereum/forks/bpo5/fork.py | 1 - src/ethereum/forks/byzantium/fork.py | 1 - src/ethereum/forks/cancun/fork.py | 1 - src/ethereum/forks/constantinople/fork.py | 1 - src/ethereum/forks/dao_fork/fork.py | 1 - src/ethereum/forks/frontier/fork.py | 1 - src/ethereum/forks/gray_glacier/fork.py | 1 - src/ethereum/forks/homestead/fork.py | 1 - src/ethereum/forks/istanbul/fork.py | 1 - src/ethereum/forks/london/fork.py | 1 - src/ethereum/forks/muir_glacier/fork.py | 1 - src/ethereum/forks/osaka/fork.py | 1 - src/ethereum/forks/paris/fork.py | 1 - src/ethereum/forks/prague/fork.py | 1 - src/ethereum/forks/shanghai/fork.py | 1 - src/ethereum/forks/spurious_dragon/fork.py | 1 - src/ethereum/forks/tangerine_whistle/fork.py | 1 - .../test_get_last_256_block_hashes.py | 89 +++++++++++++++++++ 25 files changed, 89 insertions(+), 24 deletions(-) create mode 100644 tests/json_loader/test_get_last_256_block_hashes.py diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 6d536a0efe4..5c4a10809cc 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -214,7 +214,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/arrow_glacier/fork.py b/src/ethereum/forks/arrow_glacier/fork.py index fd4cd99a3c5..ed8b9fce579 100644 --- a/src/ethereum/forks/arrow_glacier/fork.py +++ b/src/ethereum/forks/arrow_glacier/fork.py @@ -134,7 +134,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/berlin/fork.py b/src/ethereum/forks/berlin/fork.py index e2ee2e0d3cb..26efb7e3d3e 100644 --- a/src/ethereum/forks/berlin/fork.py +++ b/src/ethereum/forks/berlin/fork.py @@ -128,7 +128,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/bpo1/fork.py b/src/ethereum/forks/bpo1/fork.py index 159f7369075..e1a484a2643 100644 --- a/src/ethereum/forks/bpo1/fork.py +++ b/src/ethereum/forks/bpo1/fork.py @@ -177,7 +177,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/bpo2/fork.py b/src/ethereum/forks/bpo2/fork.py index 159f7369075..e1a484a2643 100644 --- a/src/ethereum/forks/bpo2/fork.py +++ b/src/ethereum/forks/bpo2/fork.py @@ -177,7 +177,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/bpo3/fork.py b/src/ethereum/forks/bpo3/fork.py index 159f7369075..e1a484a2643 100644 --- a/src/ethereum/forks/bpo3/fork.py +++ b/src/ethereum/forks/bpo3/fork.py @@ -177,7 +177,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/bpo4/fork.py b/src/ethereum/forks/bpo4/fork.py index 159f7369075..e1a484a2643 100644 --- a/src/ethereum/forks/bpo4/fork.py +++ b/src/ethereum/forks/bpo4/fork.py @@ -177,7 +177,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/bpo5/fork.py b/src/ethereum/forks/bpo5/fork.py index 159f7369075..e1a484a2643 100644 --- a/src/ethereum/forks/bpo5/fork.py +++ b/src/ethereum/forks/bpo5/fork.py @@ -177,7 +177,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/byzantium/fork.py b/src/ethereum/forks/byzantium/fork.py index 6d0d1b461b2..2b6b0ab9f9f 100644 --- a/src/ethereum/forks/byzantium/fork.py +++ b/src/ethereum/forks/byzantium/fork.py @@ -123,7 +123,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/cancun/fork.py b/src/ethereum/forks/cancun/fork.py index 9cf2d1860af..43674b3e875 100644 --- a/src/ethereum/forks/cancun/fork.py +++ b/src/ethereum/forks/cancun/fork.py @@ -151,7 +151,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/constantinople/fork.py b/src/ethereum/forks/constantinople/fork.py index 46a71bb0e36..dab76b6f6c8 100644 --- a/src/ethereum/forks/constantinople/fork.py +++ b/src/ethereum/forks/constantinople/fork.py @@ -123,7 +123,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/dao_fork/fork.py b/src/ethereum/forks/dao_fork/fork.py index c3a1c327e59..8a7f76ab59d 100644 --- a/src/ethereum/forks/dao_fork/fork.py +++ b/src/ethereum/forks/dao_fork/fork.py @@ -129,7 +129,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/frontier/fork.py b/src/ethereum/forks/frontier/fork.py index f2cd3ca61b7..5624cafc273 100644 --- a/src/ethereum/forks/frontier/fork.py +++ b/src/ethereum/forks/frontier/fork.py @@ -117,7 +117,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/gray_glacier/fork.py b/src/ethereum/forks/gray_glacier/fork.py index 4a1fcd6491e..cfdc6189705 100644 --- a/src/ethereum/forks/gray_glacier/fork.py +++ b/src/ethereum/forks/gray_glacier/fork.py @@ -134,7 +134,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/homestead/fork.py b/src/ethereum/forks/homestead/fork.py index 64bb280734a..7e0285749b8 100644 --- a/src/ethereum/forks/homestead/fork.py +++ b/src/ethereum/forks/homestead/fork.py @@ -117,7 +117,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/istanbul/fork.py b/src/ethereum/forks/istanbul/fork.py index a1bfce15ab8..29431c2a1ae 100644 --- a/src/ethereum/forks/istanbul/fork.py +++ b/src/ethereum/forks/istanbul/fork.py @@ -123,7 +123,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/london/fork.py b/src/ethereum/forks/london/fork.py index cc40a2237cf..8e981ee1bbe 100644 --- a/src/ethereum/forks/london/fork.py +++ b/src/ethereum/forks/london/fork.py @@ -136,7 +136,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/muir_glacier/fork.py b/src/ethereum/forks/muir_glacier/fork.py index 580281b7905..f29b0bc99de 100644 --- a/src/ethereum/forks/muir_glacier/fork.py +++ b/src/ethereum/forks/muir_glacier/fork.py @@ -123,7 +123,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/osaka/fork.py b/src/ethereum/forks/osaka/fork.py index 159f7369075..e1a484a2643 100644 --- a/src/ethereum/forks/osaka/fork.py +++ b/src/ethereum/forks/osaka/fork.py @@ -177,7 +177,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/paris/fork.py b/src/ethereum/forks/paris/fork.py index ba727222f3e..96ebe339735 100644 --- a/src/ethereum/forks/paris/fork.py +++ b/src/ethereum/forks/paris/fork.py @@ -127,7 +127,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/prague/fork.py b/src/ethereum/forks/prague/fork.py index 2c6c333a086..2878868961e 100644 --- a/src/ethereum/forks/prague/fork.py +++ b/src/ethereum/forks/prague/fork.py @@ -170,7 +170,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/shanghai/fork.py b/src/ethereum/forks/shanghai/fork.py index dd899f1ab30..1969ae9abad 100644 --- a/src/ethereum/forks/shanghai/fork.py +++ b/src/ethereum/forks/shanghai/fork.py @@ -127,7 +127,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/spurious_dragon/fork.py b/src/ethereum/forks/spurious_dragon/fork.py index f04455028e9..8aebb1b24cd 100644 --- a/src/ethereum/forks/spurious_dragon/fork.py +++ b/src/ethereum/forks/spurious_dragon/fork.py @@ -121,7 +121,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/tangerine_whistle/fork.py b/src/ethereum/forks/tangerine_whistle/fork.py index 64bb280734a..7e0285749b8 100644 --- a/src/ethereum/forks/tangerine_whistle/fork.py +++ b/src/ethereum/forks/tangerine_whistle/fork.py @@ -117,7 +117,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/tests/json_loader/test_get_last_256_block_hashes.py b/tests/json_loader/test_get_last_256_block_hashes.py new file mode 100644 index 00000000000..163bc34d84a --- /dev/null +++ b/tests/json_loader/test_get_last_256_block_hashes.py @@ -0,0 +1,89 @@ +"""Unit tests for Frontier ``get_last_256_block_hashes``.""" + +from typing import List, Tuple + +import pytest +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes8, Bytes32 +from ethereum_types.numeric import U64, U256, Uint + +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.forks.frontier.blocks import Block, Header +from ethereum.forks.frontier.fork import BlockChain, get_last_256_block_hashes +from ethereum.forks.frontier.fork_types import Bloom +from ethereum.state import Address +from ethereum.state_mpt import State + +ZERO_HASH = Hash32(b"\0" * 32) +EMPTY_BLOOM = Bloom(b"\0" * 256) +EMPTY_OMMERS_HASH = keccak256(rlp.encode(())) + + +def _header_hash(header: Header) -> Hash32: + """Return ``keccak256(rlp(header))``.""" + return Hash32(keccak256(rlp.encode(header))) + + +def _make_header(parent_hash: Hash32, number: int) -> Header: + """Return a dummy Frontier header unique for ``number``.""" + return Header( + parent_hash=parent_hash, + ommers_hash=EMPTY_OMMERS_HASH, + coinbase=Address(b"\0" * 20), + state_root=ZERO_HASH, + transactions_root=ZERO_HASH, + receipt_root=ZERO_HASH, + bloom=EMPTY_BLOOM, + difficulty=Uint(1), + number=Uint(number), + gas_limit=Uint(1), + gas_used=Uint(0), + timestamp=U256(number), + extra_data=number.to_bytes(2, "big"), + mix_digest=Bytes32(b"\0" * 32), + nonce=Bytes8(b"\0" * 8), + ) + + +def _make_chain(length: int) -> Tuple[BlockChain, List[Hash32]]: + """Build a linked dummy chain of ``length`` blocks.""" + blocks: List[Block] = [] + hashes: List[Hash32] = [] + parent_hash = ZERO_HASH + for number in range(length): + header = _make_header(parent_hash, number) + block_hash = _header_hash(header) + blocks.append(Block(header=header, transactions=(), ommers=())) + hashes.append(block_hash) + parent_hash = block_hash + chain = BlockChain(blocks=blocks, state=State(), chain_id=U64(1)) + return chain, hashes + + +def _expected_hashes(block_hashes: List[Hash32]) -> List[Hash32]: + """Return the expected window: oldest first, at most 256 hashes.""" + count = len(block_hashes) + if count == 0: + return [] + if count < 256: + return [ZERO_HASH, *block_hashes] + return block_hashes[-256:] + + +@pytest.mark.parametrize( + "length", + [ + pytest.param(0, id="empty_chain"), + pytest.param(1, id="one_block"), + pytest.param(255, id="last_untruncated_length"), + pytest.param(256, id="first_truncation"), + pytest.param(257, id="genesis_hash_dropped"), + ], +) +def test_hash_window(length: int) -> None: + """Match the expected window and the keccak of the latest header.""" + chain, hashes = _make_chain(length) + result = get_last_256_block_hashes(chain) + assert result == _expected_hashes(hashes) + if length > 0: + assert result[-1] == _header_hash(chain.blocks[-1].header) From b6209f28e4254bbea71fc339e93d6c79b5e20c34 Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Mon, 24 Aug 2026 14:40:43 +0200 Subject: [PATCH 10/59] perf(test-client-cli): skip redundant BAL hash (#3430) * perf(test-client-clis,test-specs): don't verify the BAL hash against itself * chore: clean up comment --------- Co-authored-by: LouisTsai --- .../client_clis/client_backend.py | 9 +++++---- .../client_clis/filler_backend.py | 10 +++++++++- .../client_clis/transition_tool.py | 1 + .../src/execution_testing/specs/blockchain.py | 19 ++++++++++--------- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/testing/src/execution_testing/client_clis/client_backend.py b/packages/testing/src/execution_testing/client_clis/client_backend.py index 0b42b3d216d..c13a841483a 100644 --- a/packages/testing/src/execution_testing/client_clis/client_backend.py +++ b/packages/testing/src/execution_testing/client_clis/client_backend.py @@ -41,7 +41,6 @@ Transaction, Withdrawal, ) -from execution_testing.test_types.block_access_list import BlockAccessList from execution_testing.test_types.receipt_types import TransactionReceipt from .cli_types import ( @@ -168,6 +167,8 @@ class ClientBackend: start_block: Dict[str, Any] | None """Client head after global pre-run setup; per-test chains off this.""" + attests_block_access_list_hash: ClassVar[bool] = False + # t8n-compatibility stubs — fill's filler reads these on the backend. opcode_count: OpcodeCount | None = None opcode_count_per_block: List[OpcodeCount] | None = None @@ -513,9 +514,9 @@ def _build_result( block_access_list_hash: Hash | None = None bal_rlp = getattr(built_payload, "block_access_list", None) if bal_rlp is not None: - block_access_list_hash = Hash( - BlockAccessList.from_rlp(bal_rlp).rlp.keccak256() - ) + # Hash the client's own bytes: a re-encode of the decoded BAL + # would diverge whenever the client's RLP is non-canonical. + block_access_list_hash = Hash(bal_rlp.keccak256()) return Result( state_root=built_payload.state_root, diff --git a/packages/testing/src/execution_testing/client_clis/filler_backend.py b/packages/testing/src/execution_testing/client_clis/filler_backend.py index 4ba91323506..30559cf4c7f 100644 --- a/packages/testing/src/execution_testing/client_clis/filler_backend.py +++ b/packages/testing/src/execution_testing/client_clis/filler_backend.py @@ -18,7 +18,7 @@ callers continue to work unchanged. """ -from typing import List, Protocol, runtime_checkable +from typing import ClassVar, List, Protocol, runtime_checkable from execution_testing.exceptions import ExceptionMapper @@ -44,6 +44,14 @@ class FillerBackend(Protocol): for test assertions (t8n: True; live-client: typically False). """ + attests_block_access_list_hash: ClassVar[bool] + """ + Whether ``Result.block_access_list_hash`` is computed by the backend + rather than derived by EEST from the BAL body the backend returned + (t8n: True; live-client: False, since an engine ``ExecutionPayload`` + carries the body but no hash). + """ + def evaluate( self, *, diff --git a/packages/testing/src/execution_testing/client_clis/transition_tool.py b/packages/testing/src/execution_testing/client_clis/transition_tool.py index 4417d8f7682..2a425e926dd 100644 --- a/packages/testing/src/execution_testing/client_clis/transition_tool.py +++ b/packages/testing/src/execution_testing/client_clis/transition_tool.py @@ -207,6 +207,7 @@ class TransitionTool(EthereumCLI): supports_opcode_count: ClassVar[bool] = False supports_xdist: ClassVar[bool] = True supports_blob_params: ClassVar[bool] = False + attests_block_access_list_hash: ClassVar[bool] = True fork_name_map: ClassVar[Dict[str, str]] = {} @abstractmethod diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 225f96a6012..b04ad806940 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -1016,15 +1016,16 @@ def generate_block_data( "provided by the transition tool" ) - computed_block_access_list_hash = Hash(t8n_bal.rlp.keccak256()) - assert ( - computed_block_access_list_hash - == header.block_access_list_hash - ), ( - "Block access list hash in header does not match the " - f"computed hash from BAL: {header.block_access_list_hash} " - f"!= {computed_block_access_list_hash}" - ) + if t8n.attests_block_access_list_hash: + computed_block_access_list_hash = Hash(t8n_bal.rlp.keccak256()) + assert ( + computed_block_access_list_hash + == header.block_access_list_hash + ), ( + "Block access list hash in header does not match the " + f"computed hash from BAL: {header.block_access_list_hash} " + f"!= {computed_block_access_list_hash}" + ) if block.rlp_modifier is not None: # Modify any parameter specified in the `rlp_modifier` after From 786c0d4a29c04ce68fc0216dded088d21970be79 Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Mon, 24 Aug 2026 14:55:36 +0200 Subject: [PATCH 11/59] perf(test-type): cache ecrecover and optimize function calls to ecrecover (#3431) * feat(tests): ensure pubkey isn't calculated twice * feat: signing optimization * test: add key, addr mismatch scenario --------- Co-authored-by: LouisTsai --- .../test_types/tests/test_transactions.py | 93 ++++++++++++++++++- .../test_types/transaction_types.py | 25 +++-- 2 files changed, 107 insertions(+), 11 deletions(-) diff --git a/packages/testing/src/execution_testing/test_types/tests/test_transactions.py b/packages/testing/src/execution_testing/test_types/tests/test_transactions.py index 2d0a9912736..455d9da94f5 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_transactions.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_transactions.py @@ -1,12 +1,35 @@ """Test suite for transaction signing and serialization.""" -from typing import Tuple +from typing import Any, Tuple import pytest +from spec256k1 import PublicKey -from execution_testing.base_types import AccessList, Hash +from execution_testing.base_types import AccessList, Address, Hash +from .. import transaction_types +from ..account_types import EOA from ..transaction_types import Transaction +from ..utils import keccak256 + +SIGNING_KEY = Hash(bytes(range(1, 33))) +OTHER_KEY = Hash(bytes(range(33, 65))) + + +def signable_transaction(**kwargs: Any) -> Transaction: + """Return a minimal legacy transaction ready to be signed.""" + return Transaction(ty=0, gas_limit=21_000, nonce=0, **kwargs) + + +def recover_sender(tx: Transaction) -> Address: + """Recover the sender of a signed transaction from its signature.""" + public_key = PublicKey.from_signature_and_message( + tx.signature_bytes, + tx.rlp_signing_bytes().keccak256(), + ) + return Address( + keccak256(public_key.format(compressed=False)[1:])[32 - 20 :] + ) @pytest.mark.parametrize( @@ -299,3 +322,69 @@ def test_gas_limit_none_alias_is_unset(alias: str) -> None: tx = Transaction.model_validate({alias: None}) assert "gas_limit" not in tx.model_fields_set assert tx.gas_limit == 21_000 + + +def test_derived_sender_matches_recovery() -> None: + """The derived sender equals the one recovered from the signature.""" + signed = signable_transaction( + secret_key=SIGNING_KEY + ).with_signature_and_sender() + assert signed.sender == recover_sender(signed) + + +def test_known_sender_skips_recovery( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A sender holding the signing key is reused as-is.""" + + def unexpected_recovery(*_args: Any, **_kwargs: Any) -> Any: + raise AssertionError( + "recovered the public key even though the sender was known" + ) + + monkeypatch.setattr( + transaction_types.PublicKey, + "from_signature_and_message", + unexpected_recovery, + ) + sender = EOA(key=SIGNING_KEY) + signed = signable_transaction(sender=sender).with_signature_and_sender() + assert signed.sender == sender + + +@pytest.mark.parametrize( + "sender", + [ + pytest.param(EOA(key=OTHER_KEY), id="holds_another_key"), + pytest.param(EOA(address=0x1234), id="keyless"), + ], +) +def test_sender_without_the_signing_key_is_derived(sender: EOA) -> None: + """A sender that does not hold the signing key is not trusted.""" + tx = signable_transaction(sender=sender, secret_key=SIGNING_KEY) + assert tx.with_signature_and_sender().sender == EOA(key=SIGNING_KEY) + + +def test_reassigned_sender_does_not_override_the_signer() -> None: + """ + A sender reassigned after construction is not trusted. + + `validate_assignment=True` lets `sender` be replaced while + `secret_key` keeps the original key. + """ + tx = signable_transaction(sender=EOA(key=SIGNING_KEY)) + assert tx.secret_key == SIGNING_KEY + tx.sender = EOA(key=OTHER_KEY) + assert tx.with_signature_and_sender().sender == EOA(key=SIGNING_KEY) + + +def test_mismatched_sender_address_is_trusted() -> None: + """ + An `EOA` whose address does not derive from its key is taken as-is. + """ + inconsistent = EOA(address=0x1234, key=SIGNING_KEY) + signed = signable_transaction( + sender=inconsistent + ).with_signature_and_sender() + assert signed.sender == inconsistent + assert signed.sender != recover_sender(signed) diff --git a/packages/testing/src/execution_testing/test_types/transaction_types.py b/packages/testing/src/execution_testing/test_types/transaction_types.py index 00fd02ee8bb..bfea0d6b138 100644 --- a/packages/testing/src/execution_testing/test_types/transaction_types.py +++ b/packages/testing/src/execution_testing/test_types/transaction_types.py @@ -728,15 +728,22 @@ def with_signature_and_sender( signing_hash = self.rlp_signing_bytes().keccak256() # Sign the bytes - signature_bytes = PrivateKey(self.secret_key).sign_recoverable( - signing_hash - ) - public_key = PublicKey.from_signature_and_message( - signature_bytes, signing_hash - ) - - sender = keccak256(public_key.format(compressed=False)[1:])[32 - 20 :] - updated_values["sender"] = Address(sender) + signing_key = PrivateKey(self.secret_key) + signature_bytes = signing_key.sign_recoverable(signing_hash) + + # The key comparison is what makes reusing `sender` safe: it is + # reassignable and `EOA.key` may be `None`, so a sender that does not + # hold the signing key would contradict its own signature. + if self.sender is not None and self.sender.key == self.secret_key: + updated_values["sender"] = self.sender + else: + # The address the public key recovery would return, since the + # signature was produced by this key, but derived with one point + # multiplication instead. + public_key = signing_key.public_key + updated_values["sender"] = Address( + keccak256(public_key.format(compressed=False)[1:])[32 - 20 :] + ) v, r, s = ( signature_bytes[64], From 2ce2191562f76ec6e64a82f82165d821e1c781fc Mon Sep 17 00:00:00 2001 From: spencer Date: Tue, 25 Aug 2026 08:27:19 +0200 Subject: [PATCH 12/59] chore(tests): remove EIP-7610 create collision tests (#3417) Co-authored-by: danceratopz --- docs/specs/protocol_history.md | 1 - .../test_types/account_types.py | 10 -- .../test_types/tests/test_alloc_prestate.py | 5 - src/ethereum/forks/amsterdam/state_tracker.py | 27 --- .../forks/amsterdam/vm/instructions/system.py | 6 +- .../forks/arrow_glacier/state_tracker.py | 31 ---- src/ethereum/forks/berlin/state_tracker.py | 31 ---- src/ethereum/forks/bpo1/state_tracker.py | 27 --- src/ethereum/forks/bpo2/state_tracker.py | 27 --- src/ethereum/forks/bpo3/state_tracker.py | 27 --- src/ethereum/forks/bpo4/state_tracker.py | 27 --- src/ethereum/forks/bpo5/state_tracker.py | 27 --- src/ethereum/forks/byzantium/state_tracker.py | 31 ---- src/ethereum/forks/cancun/state_tracker.py | 27 --- .../forks/constantinople/state_tracker.py | 31 ---- src/ethereum/forks/dao_fork/state_tracker.py | 31 ---- src/ethereum/forks/frontier/state_tracker.py | 31 ---- .../forks/gray_glacier/state_tracker.py | 31 ---- src/ethereum/forks/homestead/state_tracker.py | 31 ---- src/ethereum/forks/istanbul/state_tracker.py | 31 ---- src/ethereum/forks/london/state_tracker.py | 31 ---- .../forks/muir_glacier/state_tracker.py | 31 ---- src/ethereum/forks/osaka/state_tracker.py | 27 --- src/ethereum/forks/paris/state_tracker.py | 31 ---- src/ethereum/forks/prague/state_tracker.py | 27 --- src/ethereum/forks/shanghai/state_tracker.py | 31 ---- .../forks/spurious_dragon/state_tracker.py | 31 ---- .../forks/tangerine_whistle/state_tracker.py | 31 ---- src/ethereum/state.py | 8 - src/ethereum/state_mpt.py | 8 - src/ethereum_optimized/state_db.py | 15 -- .../test_transfer_logs.py | 2 +- .../test_cases.md | 2 +- .../test_state_gas_create.py | 7 +- .../test_create_gas.py | 3 +- .../test_collision_selfdestruct.py | 4 +- .../create/test_create_collision.py} | 47 ++--- .../eip7610_create_collision/__init__.py | 1 - .../test_revert_in_create.py | 167 ------------------ .../test_revert_in_create_in_init_paris.py | 73 -------- ...st_failed_create_reverts_deletion_paris.py | 73 -------- 41 files changed, 36 insertions(+), 1104 deletions(-) rename tests/{paris/eip7610_create_collision => cancun/eip6780_selfdestruct}/test_collision_selfdestruct.py (96%) rename tests/{paris/eip7610_create_collision/test_initcollision.py => frontier/create/test_create_collision.py} (82%) delete mode 100644 tests/paris/eip7610_create_collision/__init__.py delete mode 100644 tests/paris/eip7610_create_collision/test_revert_in_create.py delete mode 100644 tests/ported_static/stRevertTest/test_revert_in_create_in_init_paris.py delete mode 100644 tests/ported_static/stSpecialTest/test_failed_create_reverts_deletion_paris.py diff --git a/docs/specs/protocol_history.md b/docs/specs/protocol_history.md index f77b3a3bd98..6a7bea7ab13 100644 --- a/docs/specs/protocol_history.md +++ b/docs/specs/protocol_history.md @@ -64,4 +64,3 @@ Some clarifications were enabled without protocol releases: | [EIP-2681](https://eips.ethereum.org/EIPS/eip-2681) | 0 | | [EIP-3607](https://eips.ethereum.org/EIPS/eip-3607) | 0 | | [EIP-7523](https://eips.ethereum.org/EIPS/eip-7523) | 15537394 | -| [EIP-7610](https://eips.ethereum.org/EIPS/eip-7610) | 0 | diff --git a/packages/testing/src/execution_testing/test_types/account_types.py b/packages/testing/src/execution_testing/test_types/account_types.py index 29454e84db9..e69c66c9ccd 100644 --- a/packages/testing/src/execution_testing/test_types/account_types.py +++ b/packages/testing/src/execution_testing/test_types/account_types.py @@ -473,16 +473,6 @@ def get_code(self, code_hash: Hash32) -> Bytes: return Bytes(b"") return self._code_store[code_hash] - def account_has_storage(self, address: Bytes20) -> bool: - """ - Return whether the account at `address` has any storage slots set. - - Conforms to `ethereum.state.PreState.account_has_storage`. - """ - self._ensure_live() - account = self.root.get(Address(address)) - return account is not None and bool(account.storage.root) - def compute_state_root(self, block_diff: spec_state.BlockDiff) -> Hash32: """ Compute the state root after applying `block_diff` to the diff --git a/packages/testing/src/execution_testing/test_types/tests/test_alloc_prestate.py b/packages/testing/src/execution_testing/test_types/tests/test_alloc_prestate.py index 57b558882f3..b2a61d70698 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_alloc_prestate.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_alloc_prestate.py @@ -131,11 +131,6 @@ def test_cache_build_and_read_methods_agree_with_source() -> None: assert alloc.get_code(spec_state.EMPTY_CODE_HASH) == b"" assert alloc.get_code(keccak256(CODE)) == CODE - # account_has_storage distinguishes the contract from EOAs. - assert alloc.account_has_storage(ADDR_B) is True - assert alloc.account_has_storage(ADDR_A) is False - assert alloc.account_has_storage(ADDR_MISSING) is False - # Missing accounts return None from get_account_optional. assert alloc.get_account_optional(ADDR_MISSING) is None diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 9e0e3b24b67..472f9917ae1 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -353,36 +353,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if tx_state.parent.storage_writes.get(address): - return True - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 168cfb69240..47d2adc6f2f 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -120,12 +120,10 @@ def generic_create( create_message_gas = withhold_create_gas(evm.gas_meter) # On a collision the child's execution-gas grant is consumed and no - # account is created; a storage-only collision target is - # non-existent: charged above, refilled here. + # account is created. A collision target has code or a nonce, so + # the account-creation charge above was never taken. if not account_deployable(tx_state, contract_address): increment_nonce(tx_state, sender_address) - if new_account_charged: - credit_state_gas_refund(evm.gas_meter, StateGasCosts.NEW_ACCOUNT) push(evm.stack, U256(0)) return diff --git a/src/ethereum/forks/arrow_glacier/state_tracker.py b/src/ethereum/forks/arrow_glacier/state_tracker.py index d7a607e6436..974e25f3c27 100644 --- a/src/ethereum/forks/arrow_glacier/state_tracker.py +++ b/src/ethereum/forks/arrow_glacier/state_tracker.py @@ -284,40 +284,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if address in tx_state.storage_clears: - return False - if tx_state.parent.storage_writes.get(address): - return True - if address in tx_state.parent.storage_clears: - return False - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/berlin/state_tracker.py b/src/ethereum/forks/berlin/state_tracker.py index d7a607e6436..974e25f3c27 100644 --- a/src/ethereum/forks/berlin/state_tracker.py +++ b/src/ethereum/forks/berlin/state_tracker.py @@ -284,40 +284,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if address in tx_state.storage_clears: - return False - if tx_state.parent.storage_writes.get(address): - return True - if address in tx_state.parent.storage_clears: - return False - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/bpo1/state_tracker.py b/src/ethereum/forks/bpo1/state_tracker.py index 52d28c95b7c..6435de13297 100644 --- a/src/ethereum/forks/bpo1/state_tracker.py +++ b/src/ethereum/forks/bpo1/state_tracker.py @@ -272,36 +272,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if tx_state.parent.storage_writes.get(address): - return True - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/bpo2/state_tracker.py b/src/ethereum/forks/bpo2/state_tracker.py index 52d28c95b7c..6435de13297 100644 --- a/src/ethereum/forks/bpo2/state_tracker.py +++ b/src/ethereum/forks/bpo2/state_tracker.py @@ -272,36 +272,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if tx_state.parent.storage_writes.get(address): - return True - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/bpo3/state_tracker.py b/src/ethereum/forks/bpo3/state_tracker.py index 52d28c95b7c..6435de13297 100644 --- a/src/ethereum/forks/bpo3/state_tracker.py +++ b/src/ethereum/forks/bpo3/state_tracker.py @@ -272,36 +272,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if tx_state.parent.storage_writes.get(address): - return True - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/bpo4/state_tracker.py b/src/ethereum/forks/bpo4/state_tracker.py index 52d28c95b7c..6435de13297 100644 --- a/src/ethereum/forks/bpo4/state_tracker.py +++ b/src/ethereum/forks/bpo4/state_tracker.py @@ -272,36 +272,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if tx_state.parent.storage_writes.get(address): - return True - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/bpo5/state_tracker.py b/src/ethereum/forks/bpo5/state_tracker.py index 52d28c95b7c..6435de13297 100644 --- a/src/ethereum/forks/bpo5/state_tracker.py +++ b/src/ethereum/forks/bpo5/state_tracker.py @@ -272,36 +272,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if tx_state.parent.storage_writes.get(address): - return True - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/byzantium/state_tracker.py b/src/ethereum/forks/byzantium/state_tracker.py index d7a607e6436..974e25f3c27 100644 --- a/src/ethereum/forks/byzantium/state_tracker.py +++ b/src/ethereum/forks/byzantium/state_tracker.py @@ -284,40 +284,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if address in tx_state.storage_clears: - return False - if tx_state.parent.storage_writes.get(address): - return True - if address in tx_state.parent.storage_clears: - return False - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/cancun/state_tracker.py b/src/ethereum/forks/cancun/state_tracker.py index 52d28c95b7c..6435de13297 100644 --- a/src/ethereum/forks/cancun/state_tracker.py +++ b/src/ethereum/forks/cancun/state_tracker.py @@ -272,36 +272,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if tx_state.parent.storage_writes.get(address): - return True - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/constantinople/state_tracker.py b/src/ethereum/forks/constantinople/state_tracker.py index d7a607e6436..974e25f3c27 100644 --- a/src/ethereum/forks/constantinople/state_tracker.py +++ b/src/ethereum/forks/constantinople/state_tracker.py @@ -284,40 +284,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if address in tx_state.storage_clears: - return False - if tx_state.parent.storage_writes.get(address): - return True - if address in tx_state.parent.storage_clears: - return False - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/dao_fork/state_tracker.py b/src/ethereum/forks/dao_fork/state_tracker.py index d7a607e6436..974e25f3c27 100644 --- a/src/ethereum/forks/dao_fork/state_tracker.py +++ b/src/ethereum/forks/dao_fork/state_tracker.py @@ -284,40 +284,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if address in tx_state.storage_clears: - return False - if tx_state.parent.storage_writes.get(address): - return True - if address in tx_state.parent.storage_clears: - return False - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/frontier/state_tracker.py b/src/ethereum/forks/frontier/state_tracker.py index d7a607e6436..974e25f3c27 100644 --- a/src/ethereum/forks/frontier/state_tracker.py +++ b/src/ethereum/forks/frontier/state_tracker.py @@ -284,40 +284,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if address in tx_state.storage_clears: - return False - if tx_state.parent.storage_writes.get(address): - return True - if address in tx_state.parent.storage_clears: - return False - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/gray_glacier/state_tracker.py b/src/ethereum/forks/gray_glacier/state_tracker.py index d7a607e6436..974e25f3c27 100644 --- a/src/ethereum/forks/gray_glacier/state_tracker.py +++ b/src/ethereum/forks/gray_glacier/state_tracker.py @@ -284,40 +284,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if address in tx_state.storage_clears: - return False - if tx_state.parent.storage_writes.get(address): - return True - if address in tx_state.parent.storage_clears: - return False - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/homestead/state_tracker.py b/src/ethereum/forks/homestead/state_tracker.py index d7a607e6436..974e25f3c27 100644 --- a/src/ethereum/forks/homestead/state_tracker.py +++ b/src/ethereum/forks/homestead/state_tracker.py @@ -284,40 +284,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if address in tx_state.storage_clears: - return False - if tx_state.parent.storage_writes.get(address): - return True - if address in tx_state.parent.storage_clears: - return False - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/istanbul/state_tracker.py b/src/ethereum/forks/istanbul/state_tracker.py index d7a607e6436..974e25f3c27 100644 --- a/src/ethereum/forks/istanbul/state_tracker.py +++ b/src/ethereum/forks/istanbul/state_tracker.py @@ -284,40 +284,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if address in tx_state.storage_clears: - return False - if tx_state.parent.storage_writes.get(address): - return True - if address in tx_state.parent.storage_clears: - return False - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/london/state_tracker.py b/src/ethereum/forks/london/state_tracker.py index d7a607e6436..974e25f3c27 100644 --- a/src/ethereum/forks/london/state_tracker.py +++ b/src/ethereum/forks/london/state_tracker.py @@ -284,40 +284,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if address in tx_state.storage_clears: - return False - if tx_state.parent.storage_writes.get(address): - return True - if address in tx_state.parent.storage_clears: - return False - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/muir_glacier/state_tracker.py b/src/ethereum/forks/muir_glacier/state_tracker.py index d7a607e6436..974e25f3c27 100644 --- a/src/ethereum/forks/muir_glacier/state_tracker.py +++ b/src/ethereum/forks/muir_glacier/state_tracker.py @@ -284,40 +284,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if address in tx_state.storage_clears: - return False - if tx_state.parent.storage_writes.get(address): - return True - if address in tx_state.parent.storage_clears: - return False - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/osaka/state_tracker.py b/src/ethereum/forks/osaka/state_tracker.py index 52d28c95b7c..6435de13297 100644 --- a/src/ethereum/forks/osaka/state_tracker.py +++ b/src/ethereum/forks/osaka/state_tracker.py @@ -272,36 +272,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if tx_state.parent.storage_writes.get(address): - return True - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/paris/state_tracker.py b/src/ethereum/forks/paris/state_tracker.py index 1de225db4bc..192caf35071 100644 --- a/src/ethereum/forks/paris/state_tracker.py +++ b/src/ethereum/forks/paris/state_tracker.py @@ -284,40 +284,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if address in tx_state.storage_clears: - return False - if tx_state.parent.storage_writes.get(address): - return True - if address in tx_state.parent.storage_clears: - return False - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/prague/state_tracker.py b/src/ethereum/forks/prague/state_tracker.py index 52d28c95b7c..6435de13297 100644 --- a/src/ethereum/forks/prague/state_tracker.py +++ b/src/ethereum/forks/prague/state_tracker.py @@ -272,36 +272,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if tx_state.parent.storage_writes.get(address): - return True - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/shanghai/state_tracker.py b/src/ethereum/forks/shanghai/state_tracker.py index 1de225db4bc..192caf35071 100644 --- a/src/ethereum/forks/shanghai/state_tracker.py +++ b/src/ethereum/forks/shanghai/state_tracker.py @@ -284,40 +284,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if address in tx_state.storage_clears: - return False - if tx_state.parent.storage_writes.get(address): - return True - if address in tx_state.parent.storage_clears: - return False - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/spurious_dragon/state_tracker.py b/src/ethereum/forks/spurious_dragon/state_tracker.py index d7a607e6436..974e25f3c27 100644 --- a/src/ethereum/forks/spurious_dragon/state_tracker.py +++ b/src/ethereum/forks/spurious_dragon/state_tracker.py @@ -284,40 +284,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if address in tx_state.storage_clears: - return False - if tx_state.parent.storage_writes.get(address): - return True - if address in tx_state.parent.storage_clears: - return False - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/tangerine_whistle/state_tracker.py b/src/ethereum/forks/tangerine_whistle/state_tracker.py index d7a607e6436..974e25f3c27 100644 --- a/src/ethereum/forks/tangerine_whistle/state_tracker.py +++ b/src/ethereum/forks/tangerine_whistle/state_tracker.py @@ -284,40 +284,9 @@ def account_deployable(tx_state: TransactionState, address: Address) -> bool: if account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH: return False - if account_has_storage(tx_state, address): - return False - return True -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if address in tx_state.storage_clears: - return False - if tx_state.parent.storage_writes.get(address): - return True - if address in tx_state.parent.storage_clears: - return False - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/state.py b/src/ethereum/state.py index e7903318c83..5134e02e672 100644 --- a/src/ethereum/state.py +++ b/src/ethereum/state.py @@ -119,14 +119,6 @@ def get_code(self, code_hash: Hash32) -> Bytes: """ ... - def account_has_storage(self, address: Address) -> bool: - """ - Check whether an account has any storage. - - Only needed for EIP-7610. - """ - ... - def compute_state_root(self, block_diff: BlockDiff) -> Root: """ Compute the state root after applying `block_diff` to the diff --git a/src/ethereum/state_mpt.py b/src/ethereum/state_mpt.py index dbdd7a718e0..72f650fabd9 100644 --- a/src/ethereum/state_mpt.py +++ b/src/ethereum/state_mpt.py @@ -79,14 +79,6 @@ def get_storage(self, address: Address, key: Bytes32) -> U256: assert isinstance(value, U256) return value - def account_has_storage(self, address: Address) -> bool: - """ - Check whether an account has any storage. - - Only needed for EIP-7610. - """ - return address in self._storage_tries - def compute_state_root(self, block_diff: BlockDiff) -> Root: """ Compute the state root after applying `block_diff` to the diff --git a/src/ethereum_optimized/state_db.py b/src/ethereum_optimized/state_db.py index 785099b7a71..d25778ffde8 100644 --- a/src/ethereum_optimized/state_db.py +++ b/src/ethereum_optimized/state_db.py @@ -462,19 +462,4 @@ def mark_account_created(state: State, address: Address) -> None: """ state.created_accounts.add(address) - @add_item(patches) - def account_has_storage(state: State, address: Address) -> bool: - """ - See `state`. - """ - if address in state.dirty_storage: - for v in state.dirty_storage[address].values(): - if v != U256(0): - return True - - if state.destroyed_accounts[address]: - return False - - return state.db.has_storage(address) - return patches diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py index 70ebc714669..9f18e40df9c 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py @@ -179,7 +179,7 @@ def test_contract_creation_tx_collision( Test that a contract-creating transaction with an address collision emits no log. - Per EIP-7610, contract creation aborts when the target address already + Per EIP-684, contract creation aborts when the target address already has non-empty code or nonce. The collision check happens before any value transfer, so EIP-7708 emits no Transfer log. """ diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index cb4462ae31e..808f10a1207 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -140,7 +140,7 @@ | `test_bal_gas_limit_boundary` | Ensure the BAL max-items cap is enforced on the **final** BAL — including pre-tx system work, user txs, and post-tx system work. Parametrized on two orthogonal axes: `with_tx ∈ {False, True}` × `with_cl_withdrawal ∈ {False, True}` × `boundary_offset ∈ {at, below}`. The `with_cl_withdrawal` axis exercises the EIP-4895 withdrawals path (processed between txs and `process_general_purpose_requests`); combined with `with_tx` it catches clients that validate the cap before `process_withdrawals` runs. | Baseline: 15 system items. `with_tx`: alice → bob value=1 adds 3 items (alice + bob + coinbase warmed via EIP-3651). `with_cl_withdrawal`: one EIP-4895 withdrawal to charlie adds 1 item at `block_access_index = N+1`. Gas limit set to `total_items * BLOCK_ACCESS_LIST_ITEM + boundary_offset`. | At boundary: block **MUST** be accepted; BAL includes alice's nonce_change, bob's balance_change (when `with_tx`), charlie's balance_change at the post-tx index (when `with_cl_withdrawal`). Below boundary: block **MUST** be rejected with `BLOCK_ACCESS_LIST_GAS_LIMIT_EXCEEDED`. | ✅ Completed | | `test_bal_transient_storage_not_tracked` | Ensure BAL excludes EIP-1153 transient storage operations | Contract executes: `TSTORE(0x01, 0x42)` (transient write), `TLOAD(0x01)` (transient read), `SSTORE(0x02, result)` (persistent write using transient value). | BAL **MUST** include slot 0x02 in `storage_changes` (persistent storage was modified). BAL **MUST NOT** include slot 0x01 in `storage_reads` or `storage_changes` (transient storage is not persisted, not needed for stateless execution). This verifies TSTORE/TLOAD don't pollute BAL. | ✅ Completed | | `test_bal_withdrawal_to_7702_delegation` | Ensure BAL correctly handles withdrawal to a 7702 delegated account (no code execution on recipient) | Tx1: Alice authorizes delegation to Oracle (sets code to `0xef0100\|\|Oracle`). Withdrawal: 10 gwei sent to Alice. Single block with tx + withdrawal. | BAL **MUST** include: (1) Alice at block_access_index=1 with `code_changes` (delegation), `nonce_changes`. (2) Alice at block_access_index=2 with `balance_changes` (receives withdrawal). **Oracle MUST NOT appear** - withdrawals credit balance without executing recipient code, so delegation target is never accessed. This complements `test_bal_selfdestruct_to_7702_delegation` (selfdestruct) and `test_bal_withdrawal_no_evm_execution` (withdrawal to contract). | ✅ Completed | -| `test_init_collision_create_tx` | Ensure BAL tracks CREATE collisions correctly (pre-Amsterdam test with BAL) | CREATE transaction targeting address with existing storage aborts | BAL **MUST** show empty expectations for collision address (no changes occur due to abort) | ✅ Completed | +| `test_create_tx_collision` | Ensure BAL tracks CREATE collisions correctly (pre-Amsterdam test with BAL) | CREATE transaction targeting address with non-empty code or nonce aborts (EIP-684) | BAL **MUST** show empty expectations for collision address (no changes occur due to abort) | ✅ Completed | | `test_call_to_pre_authorized_oog` | Ensure BAL handles OOG during EIP-7702 delegation access (pre-Amsterdam test with BAL) | Call to delegated account that OOGs before accessing delegation contract | BAL **MUST** include auth_signer (code read for delegation check) but **MUST NOT** include delegation contract (OOG before access) | ✅ Completed | | `test_selfdestruct_created_in_same_tx_with_revert` | Ensure BAL tracks selfdestruct with revert correctly (pre-Amsterdam test with BAL) | Contract created and selfdestructed in same tx with nested revert | BAL **MUST** track storage reads and balance changes for selfdestruct even with reverts | ✅ Completed | | `test_value_transfer_gas_calculation` | Ensure BAL correctly tracks OOG scenarios for CALL/CALLCODE/DELEGATECALL/STATICCALL (pre-Amsterdam test with BAL) | Nested calls with precise gas limits to test OOG behavior. For CALL with OOG: target account is read. For CALLCODE/DELEGATECALL/STATICCALL with OOG: target account **NOT** read (OOG before state access) | For CALL: target in BAL even with OOG. For CALLCODE/DELEGATECALL/STATICCALL: target **NOT** in BAL when OOG (state access deferred until after gas check) | ✅ Completed | diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py index cb47916b61d..8b6370abd03 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py @@ -502,9 +502,10 @@ def test_create2_address_collision( """ Test CREATE2 returns zero on address collision. - When CREATE2 targets an address that already has code or storage, - the collision is detected early and returns zero without charging - state gas. The existing account is left unchanged. + When CREATE2 targets an address that already has code or a + non-zero nonce (EIP-684), the collision is detected early and + returns zero without charging state gas. The existing account is + left unchanged. """ gas_limit_cap = fork.transaction_gas_limit_cap() assert gas_limit_cap is not None diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_create_gas.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_create_gas.py index 313413d7964..3484dccd7e4 100644 --- a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_create_gas.py +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_create_gas.py @@ -506,7 +506,8 @@ def test_create2_to_occupied_address( # # `address=` hard-codes the occupant at the derived collision address; # it requires `pre_alloc_mutable`. This is the only way to pre-seat the - # exact CREATE2 target, mirroring the EIP-7610 collision suite. + # exact CREATE2 target, mirroring + # `tests/frontier/create/test_create_collision.py`. occupant_code = Op.SSTORE(0, 0x42) + Op.STOP occupant_storage = Storage({0x1: 0xCAFE}) # type: ignore[dict-item] pre.deploy_contract( diff --git a/tests/paris/eip7610_create_collision/test_collision_selfdestruct.py b/tests/cancun/eip6780_selfdestruct/test_collision_selfdestruct.py similarity index 96% rename from tests/paris/eip7610_create_collision/test_collision_selfdestruct.py rename to tests/cancun/eip6780_selfdestruct/test_collision_selfdestruct.py index 4bb20365f3d..5e0b9a78faf 100644 --- a/tests/paris/eip7610_create_collision/test_collision_selfdestruct.py +++ b/tests/cancun/eip6780_selfdestruct/test_collision_selfdestruct.py @@ -19,8 +19,8 @@ compute_create2_address, ) -REFERENCE_SPEC_GIT_PATH = "EIPS/eip-7610.md" -REFERENCE_SPEC_VERSION = "80ef48d0bbb5a4939ade51caaaac57b5df6acd4e" +REFERENCE_SPEC_GIT_PATH = "EIPS/eip-6780.md" +REFERENCE_SPEC_VERSION = "1b6a0e94cc47e859b9866e570391cf37dc55059a" @pytest.mark.valid_from("Cancun") diff --git a/tests/paris/eip7610_create_collision/test_initcollision.py b/tests/frontier/create/test_create_collision.py similarity index 82% rename from tests/paris/eip7610_create_collision/test_initcollision.py rename to tests/frontier/create/test_create_collision.py index 081b24e9cf2..00528a6d582 100644 --- a/tests/paris/eip7610_create_collision/test_initcollision.py +++ b/tests/frontier/create/test_create_collision.py @@ -1,8 +1,10 @@ """ -Test collision in CREATE/CREATE2 account creation, where the existing account -only has a non-zero storage slot set. +Test collision in CREATE/CREATE2 account creation, where the existing +account has non-empty code or nonce (EIP-684). """ +from typing import Dict + import pytest from execution_testing import ( Account, @@ -18,25 +20,20 @@ compute_create_address, ) -REFERENCE_SPEC_GIT_PATH = "EIPS/eip-7610.md" -REFERENCE_SPEC_VERSION = "80ef48d0bbb5a4939ade51caaaac57b5df6acd4e" - pytestmark = [ pytest.mark.valid_from("Frontier"), pytest.mark.ported_from( [ "https://github.com/ethereum/tests/blob/v13.3/src/GeneralStateTestsFiller/stSStoreTest/InitCollisionFiller.json", "https://github.com/ethereum/tests/blob/v13.3/src/GeneralStateTestsFiller/stSStoreTest/InitCollisionNonZeroNonceFiller.json", - "https://github.com/ethereum/tests/blob/v13.3/src/GeneralStateTestsFiller/stSStoreTest/InitCollisionParisFiller.json", ], pr=["https://github.com/ethereum/execution-spec-tests/pull/636"], ), pytest.mark.parametrize( - "collision_nonce,collision_balance,collision_code", + "collision_nonce,collision_code,collision_storage", [ - pytest.param(0, 0, b"\0", id="non-empty-code"), - pytest.param(0, 1, b"", id="non-empty-balance"), - pytest.param(1, 0, b"", id="non-empty-nonce"), + pytest.param(0, b"\0", {0x01: 0x01}, id="non-empty-code"), + pytest.param(1, b"", {}, id="non-empty-nonce"), ], ), pytest.mark.parametrize( @@ -62,19 +59,20 @@ @pytest.mark.with_all_contract_creating_tx_types @pytest.mark.eels_base_coverage -def test_init_collision_create_tx( +def test_create_tx_collision( state_test: StateTestFiller, pre: Alloc, tx_type: int, collision_nonce: int, - collision_balance: int, collision_code: bytes, + collision_storage: Dict[int, int], initcode: Bytecode, fork: Fork, ) -> None: """ Test that a contract creation transaction exceptionally aborts when - the target address has a non-empty storage, balance, nonce, or code. + the target address has non-empty code or nonce, leaving the existing + account untouched. """ tx = Transaction( sender=pre.fund_eoa(), @@ -88,10 +86,9 @@ def test_init_collision_create_tx( # This is the collision pre[created_contract_address] = Account( - storage={0x01: 0x01}, nonce=collision_nonce, - balance=collision_balance, code=collision_code, + storage=collision_storage, ) expected_block_access_list = None @@ -106,7 +103,9 @@ def test_init_collision_create_tx( pre=pre, post={ created_contract_address: Account( - storage={0x01: 0x01}, + nonce=collision_nonce, + code=collision_code, + storage=collision_storage, ), }, tx=tx, @@ -123,18 +122,19 @@ def test_init_collision_create_tx( ), ], ) -def test_init_collision_create_opcode( +def test_create_opcode_collision( state_test: StateTestFiller, pre: Alloc, opcode: Op, collision_nonce: int, - collision_balance: int, collision_code: bytes, + collision_storage: Dict[int, int], initcode: Bytecode, ) -> None: """ - Test that a contract creation opcode exceptionally aborts when the target - address has a non-empty storage, balance, nonce, or code. + Test that a contract creation opcode exceptionally aborts when the + target address has non-empty code or nonce, leaving the existing + account untouched. """ assert len(initcode) <= 32 contract_creator_code = ( @@ -184,17 +184,18 @@ def test_init_collision_create_opcode( ) pre[created_contract_address] = Account( - storage={0x01: 0x01}, nonce=collision_nonce, - balance=collision_balance, code=collision_code, + storage=collision_storage, ) state_test( pre=pre, post={ created_contract_address: Account( - storage={0x01: 0x01}, + nonce=collision_nonce, + code=collision_code, + storage=collision_storage, ), gas_limiter_address: Account(storage={0x01: 0x00}), }, diff --git a/tests/paris/eip7610_create_collision/__init__.py b/tests/paris/eip7610_create_collision/__init__.py deleted file mode 100644 index 6a0e54c5fd8..00000000000 --- a/tests/paris/eip7610_create_collision/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Cross-client Create Collision Tests.""" diff --git a/tests/paris/eip7610_create_collision/test_revert_in_create.py b/tests/paris/eip7610_create_collision/test_revert_in_create.py deleted file mode 100644 index d733a1bddc9..00000000000 --- a/tests/paris/eip7610_create_collision/test_revert_in_create.py +++ /dev/null @@ -1,167 +0,0 @@ -""" -Test CREATE/CREATE2 collision scenarios with pre-existing storage per EIP-7610. -""" - -import pytest -from execution_testing import ( - Account, - Alloc, - Bytecode, - Initcode, - Op, - StateTestFiller, - Transaction, - compute_create2_address, -) - -REFERENCE_SPEC_GIT_PATH = "EIPS/eip-7610.md" -REFERENCE_SPEC_VERSION = "80ef48d0bbb5a4939ade51caaaac57b5df6acd4e" - -pytestmark = [ - pytest.mark.valid_from("Paris"), - # We need to modify the pre-alloc to include the collision - pytest.mark.pre_alloc_mutable, -] - - -@pytest.mark.ported_from( - [ - "https://github.com/ethereum/tests/tree/v13.3/src/GeneralStateTestsFiller/stCreate2/RevertInCreateInInitCreate2ParisFiller.json", # noqa: E501 - ], - pr=["https://github.com/ethereum/execution-specs/pull/2031"], -) -def test_collision_with_create2_revert_in_initcode( - state_test: StateTestFiller, - pre: Alloc, -) -> None: - """ - Test that a CREATE transaction collision with pre-existing storage causes - the transaction to fail, even when the initcode would perform CREATE2 with - reverting inner initcode. - - The initcode (if it were to run) would: - 1. Execute CREATE2 with inner initcode that reverts with 32 bytes of data - 2. Store RETURNDATASIZE to storage slot 0 - 3. Copy returndata to memory and store to slot 1 - - Since there's a collision (pre-existing storage), the CREATE TX should fail - and the pre-existing account should remain unchanged. - """ - inner_initcode = Op.MSTORE(0, 0x112233) + Op.REVERT(0, 32) - - initcode = ( - Op.MSTORE(0, Op.PUSH32(bytes(inner_initcode).ljust(32, b"\0"))) - + Op.CREATE2(value=0, offset=0, size=len(inner_initcode), salt=0) - + Op.SSTORE(0, Op.RETURNDATASIZE) - + Op.RETURNDATACOPY(0, 0, 32) - + Op.SSTORE(1, Op.MLOAD(0)) - + Op.STOP - ) - - sender = pre.fund_eoa() - tx = Transaction( - sender=sender, - to=None, - data=initcode, - ) - - # Pre-existing account with storage - this causes collision per EIP-7610. - pre[tx.created_contract] = Account( - balance=10, - storage={0x00: 0x01}, - ) - - state_test( - pre=pre, - post={ - (tx.created_contract): Account( - balance=10, - nonce=0, - storage={0x00: 0x01}, - ), - }, - tx=tx, - ) - - -@pytest.mark.ported_from( - [ - "https://github.com/ethereum/tests/tree/v13.3/src/GeneralStateTestsFiller/stCreate2/create2collisionStorageParisFiller.json", # noqa: E501 - ], - pr=["https://github.com/ethereum/execution-specs/pull/2031"], -) -@pytest.mark.parametrize( - "create2_initcode", - [ - pytest.param(b"", id="empty-initcode"), - pytest.param(Op.SSTORE(1, 1), id="sstore-initcode"), - pytest.param( - Initcode(deploy_code=Op.SSTORE(1, 1)), - id="initcode-with-deploy", - ), - ], -) -@pytest.mark.eels_base_coverage -def test_create2_collision_storage( - state_test: StateTestFiller, - pre: Alloc, - create2_initcode: Bytecode, -) -> None: - """ - Test that CREATE2 fails when targeting an address with pre-existing - storage. - - A CREATE transaction deploys a contract that executes CREATE2. The CREATE2 - target address has pre-existing storage, which should cause the CREATE2 to - fail per EIP-7610. The deployer stores the CREATE2 result to slot 0 (0 on - failure). - """ - deployer_code = ( - Op.MSTORE(0, Op.PUSH32(bytes(create2_initcode).ljust(32, b"\0"))) - + Op.SSTORE( - 0, - Op.CREATE2(value=0, offset=0, size=len(create2_initcode), salt=0), - ) - + Op.STOP - ) - - sender = pre.fund_eoa() - tx = Transaction( - sender=sender, - to=None, - data=deployer_code, - value=1, - ) - - deployer_address = tx.created_contract - - create2_address = compute_create2_address( - address=deployer_address, - salt=0, - initcode=create2_initcode, - ) - - pre[create2_address] = Account( - balance=10, - storage={0x00: 0x01}, - ) - - state_test( - pre=pre, - post={ - # CREATE2 target unchanged due to collision - create2_address: Account( - balance=10, - nonce=0, - storage={0x00: 0x01}, - ), - # Deployer: nonce=2 (1 for creation + 1 for failed CREATE2 attempt) - # storage[0]=0 indicates CREATE2 returned 0 (failure) - deployer_address: Account( - balance=1, - nonce=2, - storage={0x00: 0x00}, - ), - }, - tx=tx, - ) diff --git a/tests/ported_static/stRevertTest/test_revert_in_create_in_init_paris.py b/tests/ported_static/stRevertTest/test_revert_in_create_in_init_paris.py deleted file mode 100644 index 87e09b013fb..00000000000 --- a/tests/ported_static/stRevertTest/test_revert_in_create_in_init_paris.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -Test_revert_in_create_in_init_paris. - -Ported from: -state_tests/stRevertTest/RevertInCreateInInit_ParisFiller.json - -@manually-enhanced: Do not overwrite. Explicit gas values removed. -""" - -import pytest -from execution_testing import ( - EOA, - Account, - Address, - Alloc, - Environment, - StateTestFiller, - Transaction, -) -from execution_testing.vm import Op - -REFERENCE_SPEC_GIT_PATH = "N/A" -REFERENCE_SPEC_VERSION = "N/A" - - -@pytest.mark.ported_from( - ["state_tests/stRevertTest/RevertInCreateInInit_ParisFiller.json"], -) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable -def test_revert_in_create_in_init_paris( - state_test: StateTestFiller, - pre: Alloc, -) -> None: - """Test_revert_in_create_in_init_paris.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - addr = Address(0x4757608F18B70777AE788DD4056EEED52F7AA68F) - sender = EOA( - key=0x834185262E53584684BF2B72C64E510013C235D0F45E462DB65900455DF45A35 - ) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - ) - - pre[addr] = Account(balance=10, storage={0: 1}) - pre[sender] = Account(balance=0x6400000000) - - tx = Transaction( - sender=sender, - to=None, - data=Op.POP(Op.ADDRESS) - + Op.PUSH1[0xD] - + Op.CODECOPY(dest_offset=0x0, offset=0x24, size=Op.DUP1) - + Op.PUSH1[0x0] * 2 - + Op.POP(Op.CREATE) - + Op.SSTORE(key=0x0, value=Op.RETURNDATASIZE) - + Op.RETURNDATACOPY(dest_offset=0x0, offset=0x0, size=0x20) - + Op.SSTORE(key=0x1, value=Op.MLOAD(offset=0x0)) - + Op.STOP * 2 - + Op.INVALID - + Op.MSTORE(offset=0x0, value=0x112233) - + Op.REVERT(offset=0x0, size=0x20) - + Op.STOP, - ) - - post = {addr: Account(storage={0: 1}, balance=10)} - - state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stSpecialTest/test_failed_create_reverts_deletion_paris.py b/tests/ported_static/stSpecialTest/test_failed_create_reverts_deletion_paris.py deleted file mode 100644 index 3daeed66e0a..00000000000 --- a/tests/ported_static/stSpecialTest/test_failed_create_reverts_deletion_paris.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -A modification of stRevertTests/RevertInCreateInInit. That test, for... - -Ported from: -state_tests/stSpecialTest/FailedCreateRevertsDeletionParisFiller.json -""" - -import pytest -from execution_testing import ( - EOA, - Account, - Address, - Alloc, - Environment, - Fork, - StateTestFiller, - Transaction, -) -from execution_testing.forks import Amsterdam -from execution_testing.vm import Op - -REFERENCE_SPEC_GIT_PATH = "N/A" -REFERENCE_SPEC_VERSION = "N/A" - - -@pytest.mark.ported_from( - ["state_tests/stSpecialTest/FailedCreateRevertsDeletionParisFiller.json"], -) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable -def test_failed_create_reverts_deletion_paris( - state_test: StateTestFiller, - fork: Fork, - pre: Alloc, -) -> None: - """A modification of stRevertTests/RevertInCreateInInit.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - addr = Address(0x4757608F18B70777AE788DD4056EEED52F7AA68F) - sender = EOA( - key=0x834185262E53584684BF2B72C64E510013C235D0F45E462DB65900455DF45A35 - ) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - ) - - pre[addr] = Account(balance=10, storage={0: 1}) - pre[sender] = Account(balance=0x6400000000) - - tx = Transaction( - sender=sender, - to=None, - data=Op.POP(Op.ADDRESS) - + Op.PUSH1[0xD] - + Op.CODECOPY(dest_offset=0x0, offset=0x13, size=Op.DUP1) - + Op.PUSH1[0x0] * 2 - + Op.POP(Op.CREATE) - + Op.INVALID - + Op.STOP - + Op.INVALID - + Op.MSTORE(offset=0x0, value=0x112233) - + Op.REVERT(offset=0x0, size=0x20) - + Op.STOP, - gas_limit=2100000 if fork >= Amsterdam else 100000, - ) - - post = {addr: Account(storage={0: 1}, balance=10)} - - state_test(env=env, pre=pre, post=post, tx=tx) From 74153f7093c7716834d2dac00db0087738072758 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Wed, 26 Aug 2026 10:48:37 +0200 Subject: [PATCH 13/59] fix(test-execute): ignore tests/spec_tools during collection (#3450) --- .../pytest_commands/pytest_ini_files/pytest-execute-hive.ini | 2 +- .../cli/pytest_commands/pytest_ini_files/pytest-execute.ini | 2 +- .../cli/pytest_commands/pytest_ini_files/pytest-fill.ini | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-execute-hive.ini b/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-execute-hive.ini index aeb53f6c86b..c3ef46b94fb 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-execute-hive.ini +++ b/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-execute-hive.ini @@ -21,4 +21,4 @@ addopts = --dist load --ignore tests/cancun/eip4844_blobs/point_evaluation_vectors/ --ignore tests/json_loader - --ignore tests/evm_tools \ No newline at end of file + --ignore tests/spec_tools \ No newline at end of file diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-execute.ini b/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-execute.ini index 4646c2759c3..27392f5ada1 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-execute.ini +++ b/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-execute.ini @@ -23,4 +23,4 @@ addopts = --dist load --ignore tests/cancun/eip4844_blobs/point_evaluation_vectors/ --ignore tests/json_loader - --ignore tests/evm_tools \ No newline at end of file + --ignore tests/spec_tools \ No newline at end of file diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini b/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini index e627de46c9a..6769db23eca 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini +++ b/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini @@ -20,7 +20,7 @@ addopts = --dist loadgroup --ignore tests/cancun/eip4844_blobs/point_evaluation_vectors/ --ignore tests/json_loader - --ignore tests/evm_tools + --ignore tests/spec_tools # these customizations require the pytest-custom-report plugin report_passed_verbose = FILLED report_xpassed_verbose = XFILLED From bd443d939acbefd9194ab8e6e2435b7fad152932 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Wed, 26 Aug 2026 11:29:23 +0200 Subject: [PATCH 14/59] feat(tests): expand create collision matrix and add balance-only cases (#3425) --- .../frontier/create/test_create_collision.py | 230 ++++++++++++++---- 1 file changed, 188 insertions(+), 42 deletions(-) diff --git a/tests/frontier/create/test_create_collision.py b/tests/frontier/create/test_create_collision.py index 00528a6d582..cb8bb48b901 100644 --- a/tests/frontier/create/test_create_collision.py +++ b/tests/frontier/create/test_create_collision.py @@ -1,6 +1,7 @@ """ Test collision in CREATE/CREATE2 account creation, where the existing -account has non-empty code or nonce (EIP-684). +account has non-empty code or nonce (EIP-684), and that an account +with only a balance is deployable. """ from typing import Dict @@ -22,41 +23,92 @@ pytestmark = [ pytest.mark.valid_from("Frontier"), - pytest.mark.ported_from( - [ - "https://github.com/ethereum/tests/blob/v13.3/src/GeneralStateTestsFiller/stSStoreTest/InitCollisionFiller.json", - "https://github.com/ethereum/tests/blob/v13.3/src/GeneralStateTestsFiller/stSStoreTest/InitCollisionNonZeroNonceFiller.json", - ], - pr=["https://github.com/ethereum/execution-spec-tests/pull/636"], - ), - pytest.mark.parametrize( - "collision_nonce,collision_code,collision_storage", - [ - pytest.param(0, b"\0", {0x01: 0x01}, id="non-empty-code"), - pytest.param(1, b"", {}, id="non-empty-nonce"), - ], - ), - pytest.mark.parametrize( - "initcode", - [ - pytest.param( - Initcode( - deploy_code=Op.STOP, - initcode_prefix=Op.SSTORE(0, 1) + Op.SSTORE(1, 0), - ), - id="correct-initcode", - ), - pytest.param(Op.REVERT(0, 0), id="revert-initcode"), - pytest.param( - Op.MSTORE(0xFFFFFFFFFFFFFFFFFFFFFFFFFFF, 1), id="oog-initcode" - ), - ], - ), - # We need to modify the pre-alloc to include the collision + # We need to modify the pre-alloc to include the target account pytest.mark.pre_alloc_mutable, ] +# The prefix makes any initcode execution visible in storage: it adds +# slot 0x00, the witness that creation ran in the balance-only tests, +# and zeroes slot 0x01, which the collision tests pre-seed on some +# account shapes. On a correct collision abort neither write happens. +CORRECT_INITCODE = Initcode( + deploy_code=Op.STOP, + initcode_prefix=Op.SSTORE(0, 1) + Op.SSTORE(1, 0), +) + +# Every account shape where creation must abort under EIP-684: the +# product of nonce, code, storage and balance with non-empty code or +# nonce. Cells with zero nonce and empty code are excluded: with +# non-empty storage the behavior is undefined in protocol (EIP-7610 +# was declined for inclusion in Glamsterdam), and with empty storage +# the account is deployable (see the balance-only tests). Cells with +# empty storage catch clients that incorrectly abort on storage +# instead of code or nonce; cells with non-empty storage also check +# that the aborted creation neither wipes the storage nor runs the +# initcode, which would zero slot 0x01 in the correct-initcode case. +COLLISION_ACCOUNT_CASES = [ + ( + nonce, + code, + storage, + balance, + ( + f"nonce_{nonce}-" + f"{'code' if code else 'no_code'}-" + f"{'storage' if storage else 'no_storage'}-" + f"balance_{balance}" + ), + ) + for nonce in (0, 1) + for code in (b"", b"\0") + for storage in ({}, {0x01: 0x01}) + for balance in (0, 1) + if nonce != 0 or code != b"" +] + +INITCODE_CASES = [ + (CORRECT_INITCODE, "correct-initcode", False), + (Op.REVERT(0, 0), "revert-initcode", True), + (Op.MSTORE(0xFFFFFFFFFFFFFFFFFFFFFFFFFFF, 1), "oog-initcode", True), +] + +# Preserve the reverting and out-of-gas initcode coverage for the two +# original ported account shapes. The successful initcode is the probe +# that distinguishes a missed collision for every additional shape. +ORIGINAL_COLLISION_ACCOUNT_IDS = { + "nonce_0-code-storage-balance_0", + "nonce_1-no_code-no_storage-balance_0", +} + +COLLISION_PARAMS = [ + pytest.param( + nonce, + code, + storage, + balance, + initcode, + id=f"{initcode_id}-{account_id}", + ) + for initcode, initcode_id, original_accounts_only in INITCODE_CASES + for nonce, code, storage, balance, account_id in COLLISION_ACCOUNT_CASES + if not original_accounts_only + or account_id in ORIGINAL_COLLISION_ACCOUNT_IDS +] + +PORTED_FROM = pytest.mark.ported_from( + [ + "https://github.com/ethereum/tests/blob/v13.3/src/GeneralStateTestsFiller/stSStoreTest/InitCollisionFiller.json", + "https://github.com/ethereum/tests/blob/v13.3/src/GeneralStateTestsFiller/stSStoreTest/InitCollisionNonZeroNonceFiller.json", + ], + pr=["https://github.com/ethereum/execution-spec-tests/pull/636"], +) + +@PORTED_FROM +@pytest.mark.parametrize( + "collision_nonce,collision_code,collision_storage,collision_balance,initcode", + COLLISION_PARAMS, +) @pytest.mark.with_all_contract_creating_tx_types @pytest.mark.eels_base_coverage def test_create_tx_collision( @@ -66,6 +118,7 @@ def test_create_tx_collision( collision_nonce: int, collision_code: bytes, collision_storage: Dict[int, int], + collision_balance: int, initcode: Bytecode, fork: Fork, ) -> None: @@ -89,6 +142,7 @@ def test_create_tx_collision( nonce=collision_nonce, code=collision_code, storage=collision_storage, + balance=collision_balance, ) expected_block_access_list = None @@ -106,6 +160,7 @@ def test_create_tx_collision( nonce=collision_nonce, code=collision_code, storage=collision_storage, + balance=collision_balance, ), }, tx=tx, @@ -113,22 +168,20 @@ def test_create_tx_collision( ) +@PORTED_FROM @pytest.mark.parametrize( - "opcode", - [ - Op.CREATE, - pytest.param( - Op.CREATE2, marks=pytest.mark.valid_from("Constantinople") - ), - ], + "collision_nonce,collision_code,collision_storage,collision_balance,initcode", + COLLISION_PARAMS, ) +@pytest.mark.with_all_create_opcodes def test_create_opcode_collision( state_test: StateTestFiller, pre: Alloc, - opcode: Op, + create_opcode: Op, collision_nonce: int, collision_code: bytes, collision_storage: Dict[int, int], + collision_balance: int, initcode: Bytecode, ) -> None: """ @@ -142,7 +195,9 @@ def test_create_opcode_collision( # this runs out of gas, and every other fork jumps to a non-JUMPDEST. Op.MSTORE(0, Op.PUSH32(bytes(initcode).ljust(32, b"\0"))) + Op.JUMPI( - condition=Op.ISZERO(opcode(value=0, offset=0, size=len(initcode))), + condition=Op.ISZERO( + create_opcode(value=0, offset=0, size=len(initcode)) + ), pc=0, ) + Op.STOP @@ -174,7 +229,7 @@ def test_create_opcode_collision( nonce=1, salt=0, initcode=initcode, - opcode=opcode, + opcode=create_opcode, ) tx = Transaction( @@ -187,6 +242,7 @@ def test_create_opcode_collision( nonce=collision_nonce, code=collision_code, storage=collision_storage, + balance=collision_balance, ) state_test( @@ -196,8 +252,98 @@ def test_create_opcode_collision( nonce=collision_nonce, code=collision_code, storage=collision_storage, + balance=collision_balance, ), gas_limiter_address: Account(storage={0x01: 0x00}), }, tx=tx, ) + + +@pytest.mark.with_all_contract_creating_tx_types +def test_create_tx_balance_only_target( + state_test: StateTestFiller, + pre: Alloc, + tx_type: int, +) -> None: + """ + Test that a contract creation transaction succeeds when the target + address has only a balance: an account with zero nonce, no code and + no storage is not a collision (EIP-684). + """ + tx = Transaction( + sender=pre.fund_eoa(), + ty=tx_type, + to=None, + data=CORRECT_INITCODE, + protected=False, + ) + + created_contract_address = tx.created_contract + + pre[created_contract_address] = Account(balance=1) + + state_test( + pre=pre, + post={ + created_contract_address: Account( + balance=1, + code=CORRECT_INITCODE.deploy_code, + storage={0x00: 0x01}, + ), + }, + tx=tx, + ) + + +@pytest.mark.with_all_create_opcodes +def test_create_opcode_balance_only_target( + state_test: StateTestFiller, + pre: Alloc, + create_opcode: Op, +) -> None: + """ + Test that a contract creation opcode succeeds when the target + address has only a balance: an account with zero nonce, no code and + no storage is not a collision (EIP-684). + """ + initcode = CORRECT_INITCODE + assert len(initcode) <= 32 + contract_creator_code = ( + # Stores the created address, which is non-zero on success. + Op.MSTORE(0, Op.PUSH32(bytes(initcode).ljust(32, b"\0"))) + + Op.SSTORE(0x01, create_opcode(value=0, offset=0, size=len(initcode))) + + Op.STOP + ) + contract_creator_address = pre.deploy_contract(contract_creator_code) + + created_contract_address = compute_create_address( + address=contract_creator_address, + nonce=1, + salt=0, + initcode=initcode, + opcode=create_opcode, + ) + + tx = Transaction( + sender=pre.fund_eoa(), + to=contract_creator_address, + protected=False, + ) + + pre[created_contract_address] = Account(balance=1) + + state_test( + pre=pre, + post={ + created_contract_address: Account( + balance=1, + code=CORRECT_INITCODE.deploy_code, + storage={0x00: 0x01}, + ), + contract_creator_address: Account( + storage={0x01: created_contract_address} + ), + }, + tx=tx, + ) From 6711bfd3f661a54eda1bde89757228f986b828cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 26 Aug 2026 11:37:35 +0200 Subject: [PATCH 15/59] feat(tests): EIP-8037 - consume spilled state gas along a halt chain (#3423) Co-authored-by: spencer-tb --- .../test_state_gas_reservoir.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_reservoir.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_reservoir.py index c771cf73d28..c6e042a1f5d 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_reservoir.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_reservoir.py @@ -1034,6 +1034,73 @@ def test_top_level_failure_spilled_state_gas( state_test(pre=pre, post=post, tx=tx) +@pytest.mark.parametrize( + "descent_frames", + [ + pytest.param(2, id="descent_frames_2"), + pytest.param(4, id="descent_frames_4"), + pytest.param(8, id="descent_frames_8"), + ], +) +@pytest.mark.valid_from("EIP8037") +def test_spilled_state_gas_consumed_across_halt_chain( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + descent_frames: int, +) -> None: + """ + Verify spilled state gas stays consumed along a chain of halting frames. + + A self-`DELEGATECALL`ing contract writes `NOT(storage[slot])`, so set and + clear alternate down the shared-storage call stack and the reservoir is + recycled instead of drained once. Each frame reuses the value it wrote as + a `CALL`'s `args_size`, so the frames that set the slot halt on the + memory-size overflow while the frames that cleared it re-enter the + contract, interleaving halting and surviving frames. Every halt must burn + its spill rather than credit it back to the caller's reservoir, so the + top-level halt charges the whole gas limit. + """ + slot = 0 + value_offset = 0 + code = ( + # Memory is per-frame, so each frame keeps the value it wrote + # and reuses it below as the CALL's args_size. + Op.MSTORE(value_offset, Op.NOT(Op.SLOAD(slot))) + + Op.SSTORE(slot, Op.MLOAD(value_offset)) + + Op.POP(Op.DELEGATECALL(address=Op.ADDRESS)) + # An all-ones args_size overflows the memory-size calculation + # and halts the frame. A zero one, in a frame that cleared the + # slot, re-enters the contract and spawns further frames. + + Op.POP(Op.CALL(address=Op.ADDRESS, args_size=Op.MLOAD(value_offset))) + ) + contract = pre.deploy_contract(code=code) + + # One fresh spilled set plus a descent budget in static frame + # costs. Clear credits recycle the reservoir for deeper sets, and + # warm frames cost less than the static sum, so the descent runs + # past the budget. Fork-derived so the depth regimes survive + # repricings. + gas_limit = ( + fork.transaction_intrinsic_cost_calculator()() + + Op.SSTORE(new_value=1).state_cost(fork) + + descent_frames * code.execution_cost(fork) + ) + gas_limit_cap = fork.transaction_gas_limit_cap() + assert gas_limit_cap is not None + # Below the cap, so the reservoir starts empty and every set spills. + assert gas_limit < gas_limit_cap + + tx = Transaction( + to=contract, + gas_limit=gas_limit, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt(cumulative_gas_used=gas_limit), + ) + + state_test(pre=pre, post={contract: Account(storage={})}, tx=tx) + + def _build_call_chain( pre: Alloc, frame_bodies: list[Bytecode], From e73282ee2911cc2f3d296324b1d3cb577c4a094e Mon Sep 17 00:00:00 2001 From: spencer Date: Wed, 26 Aug 2026 11:39:07 +0200 Subject: [PATCH 16/59] feat(tests): EIP-8037 - pin gas on the unasserted child-halt spill tests (#3427) --- .../test_state_gas_call.py | 30 +++++--- .../test_state_gas_create.py | 51 +++++++++--- .../test_state_gas_multi_block.py | 77 +++++++++++-------- 3 files changed, 105 insertions(+), 53 deletions(-) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_call.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_call.py index 75094848963..100afd09b41 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_call.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_call.py @@ -263,8 +263,12 @@ def test_reservoir_restored_after_child_spill_and_halt( parent; the spilled gas stays burned (re-classified as execution). The parent does two SSTOREs: the first drains the recovered reservoir, the second spills from the parent's own `gas_left`. + The receipt pins the halted child's whole budget as consumed, so + a credit of the burned spill back to the parent is caught. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() + child_budget = 500_000 # Child does two SSTOREs then halts child = pre.deploy_contract( @@ -272,15 +276,20 @@ def test_reservoir_restored_after_child_spill_and_halt( ) parent_storage = Storage() - parent = pre.deploy_contract( - code=( - Op.POP(Op.CALL(gas=500_000, address=child)) - # First SSTORE drains the recovered reservoir; second - # SSTORE spills from parent's gas_left (gas_limit_cap is - # large enough to absorb it). - + Op.SSTORE(parent_storage.store_next(1), 1) - + Op.SSTORE(parent_storage.store_next(1), 1) - ), + parent_code = ( + Op.POP(Op.CALL(gas=child_budget, address=child)) + # First SSTORE drains the recovered reservoir; second + # SSTORE spills from parent's gas_left (gas_limit_cap is + # large enough to absorb it). + + Op.SSTORE(parent_storage.store_next(1), 1) + + Op.SSTORE(parent_storage.store_next(1), 1) + ) + parent = pre.deploy_contract(code=parent_code) + + # The halted child burns its whole budget. The parent's own sets + # and their state gas are inside `gas_cost`. + expected_cumulative = ( + intrinsic_cost + parent_code.gas_cost(fork) + child_budget ) # Reservoir = 1 SSTORE's worth of state gas — child will spill @@ -288,6 +297,9 @@ def test_reservoir_restored_after_child_spill_and_halt( to=parent, state_gas_reservoir=sstore_state_gas, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative, + ), ) post = {parent: Account(storage=parent_storage)} diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py index 8b6370abd03..a2bb4448567 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py @@ -1953,12 +1953,14 @@ def test_create2_failed_deposit_refunds_storage_state_gas( """ Test a failed CREATE2 deposit refunds the init's storage-slot state gas. - Total gas used is independent of `slots`, so a client that drops the - slot refund diverges for `slots >= 1`; `slots == 0` is the negative - control. + Total state gas refunded is independent of `slots`, so a client + that drops the slot refund diverges for `slots >= 1` and + `slots == 0` is the negative control. The receipt pins the init + frame's whole 63/64 share as burned, slot spills included. """ gas_limit_cap = fork.transaction_gas_limit_cap() assert gas_limit_cap is not None + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() # init: write `slots` new storage slots, then trigger a deposit failure init_code = Bytecode() @@ -1972,21 +1974,50 @@ def test_create2_failed_deposit_refunds_storage_state_gas( init_code += Op.RETURN(0, fork.max_code_size()) mstore_value, size = init_code_at_high_bytes(init_code) + create_call = Op.CREATE2( + value=0, offset=0, size=size, salt=0, init_code_size=size + ) storage = Storage() + factory_create_code = ( + Op.MSTORE(0, mstore_value, new_memory_size=32) + create_call + ) + factory_post_create_code = ( + # Store the CREATE2 result (0 on failure): a cold 0 -> 0 no-op. + Op.PUSH1(storage.store_next(0, "create2_failed")) + + Op.SSTORE.with_metadata(original_value=0, new_value=0)( + unchecked=True + ) + ) factory = pre.deploy_contract( - code=( - Op.MSTORE(0, mstore_value) - + Op.SSTORE( - storage.store_next(0, "create2_failed"), - Op.CREATE2(value=0, offset=0, size=size, salt=0), - ) - ), + code=factory_create_code + factory_post_create_code, + ) + + # Simulate the runtime gas: the whole-cap gas limit leaves no + # reservoir, so every state charge spills from `gas_left` and the + # failed deposit burns the init frame's whole share regardless of + # `slots` or `fail_mode`. + sim_gas_left = ( + gas_limit_cap + - intrinsic_cost + - factory_create_code.execution_cost(fork) ) + # CREATE2's new-account state gas spills wholly from gas_left and + # refills there when the create fails. + new_account_state_gas = create_call.state_cost(fork) + sim_gas_left -= new_account_state_gas + # 63/64 retention: the factory keeps gas_left // 64. + sim_gas_left = sim_gas_left // 64 + sim_gas_left += new_account_state_gas + sim_gas_left -= factory_post_create_code.execution_cost(fork) + expected_cumulative = gas_limit_cap - sim_gas_left tx = Transaction( to=factory, gas_limit=gas_limit_cap, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative, + ), ) state_test( diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_multi_block.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_multi_block.py index 6b26bd0bb5d..b8d0bd9d140 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_multi_block.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_multi_block.py @@ -26,6 +26,7 @@ Op, Storage, Transaction, + TransactionReceipt, ) from .spec import ref_spec_8037 @@ -115,28 +116,30 @@ def test_multi_block_mixed_state_operations( This mixed scenario tests that `receipt_gas_used` is consistent across different state gas paths within a multi-block chain. + Every receipt pins its cumulative gas, so a mis-credited spill + on any path breaks the fill. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() + child_budget = 500_000 - reverting_child = pre.deploy_contract( - code=(Op.SSTORE(0, 1) + Op.SSTORE(1, 1) + Op.REVERT(0, 0)), - ) - halting_child = pre.deploy_contract( - code=(Op.SSTORE(0, 1) + Op.SSTORE(1, 1) + Op.INVALID), - ) + reverting_child_code = Op.SSTORE(0, 1) + Op.SSTORE(1, 1) + Op.REVERT(0, 0) + reverting_child = pre.deploy_contract(code=reverting_child_code) + halting_child_code = Op.SSTORE(0, 1) + Op.SSTORE(1, 1) + Op.INVALID + halting_child = pre.deploy_contract(code=halting_child_code) all_contracts = [] all_storages = [] # Simple SSTOREs from reservoir block1_txs = [] - for _ in range(2): + for i in range(2): storage = Storage() - contract = pre.deploy_contract( - code=(Op.SSTORE(storage.store_next(1), 1)), - ) + code = Op.SSTORE(storage.store_next(1), 1) + contract = pre.deploy_contract(code=code) all_contracts.append(contract) all_storages.append(storage) + tx_gas_used = intrinsic_cost + code.gas_cost(fork) block1_txs.append( Transaction( to=contract, @@ -144,26 +147,29 @@ def test_multi_block_mixed_state_operations( max_priority_fee_per_gas=1, max_fee_per_gas=8, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=(i + 1) * tx_gas_used, + ), ) ) # Child spill + revert block2_txs = [] - for _ in range(2): + for i in range(2): storage = Storage() - parent = pre.deploy_contract( - code=( - Op.POP( - Op.CALL( - gas=500_000, - address=reverting_child, - ) - ) - + Op.SSTORE(storage.store_next(1), 1) - ), - ) + parent_code = Op.POP( + Op.CALL(gas=child_budget, address=reverting_child) + ) + Op.SSTORE(storage.store_next(1), 1) + parent = pre.deploy_contract(code=parent_code) all_contracts.append(parent) all_storages.append(storage) + # The reverted child refunds its state gas and returns its + # unspent budget, so only its execution gas is consumed. + tx_gas_used = ( + intrinsic_cost + + parent_code.gas_cost(fork) + + reverting_child_code.execution_cost(fork) + ) block2_txs.append( Transaction( to=parent, @@ -171,26 +177,26 @@ def test_multi_block_mixed_state_operations( max_priority_fee_per_gas=1, max_fee_per_gas=8, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=(i + 1) * tx_gas_used, + ), ) ) # Child spill + exceptional halt block3_txs = [] - for _ in range(2): + for i in range(2): storage = Storage() - parent = pre.deploy_contract( - code=( - Op.POP( - Op.CALL( - gas=500_000, - address=halting_child, - ) - ) - + Op.SSTORE(storage.store_next(1), 1) - ), - ) + parent_code = Op.POP( + Op.CALL(gas=child_budget, address=halting_child) + ) + Op.SSTORE(storage.store_next(1), 1) + parent = pre.deploy_contract(code=parent_code) all_contracts.append(parent) all_storages.append(storage) + # The halted child burns its whole budget, spill included. + tx_gas_used = ( + intrinsic_cost + parent_code.gas_cost(fork) + child_budget + ) block3_txs.append( Transaction( to=parent, @@ -198,6 +204,9 @@ def test_multi_block_mixed_state_operations( max_priority_fee_per_gas=1, max_fee_per_gas=8, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=(i + 1) * tx_gas_used, + ), ) ) From 50f3df9ece9b3c448146c39e08dfadeaf21c7351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E4=BD=B3=E8=AA=A0=20Louis=20Tsai?= <72684086+LouisTsai-Csie@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:51:33 +0800 Subject: [PATCH 17/59] refactor(tests): EIP-8037 test gas calculation logic and post state verification (#3383) Co-authored-by: spencer-tb --- .../spec.py | 4 +- .../test_block_2d_gas_accounting.py | 194 ++++++++------ .../test_eip_mainnet.py | 19 +- .../test_state_gas_delegation_pointer.py | 140 ++++++++-- .../test_state_gas_fork_transition.py | 90 +++++-- .../test_state_gas_selfdestruct.py | 246 ++++++++++++------ 6 files changed, 478 insertions(+), 215 deletions(-) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/spec.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/spec.py index 5df2ff71e75..770be6cbf36 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/spec.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/spec.py @@ -22,10 +22,8 @@ class ReferenceSpec: version: str -# TODO: update version once -# https://github.com/ethereum/EIPs/pull/11328 is merged ref_spec_8037 = ReferenceSpec( - "EIPS/eip-8037.md", "a12902ae1b811c45a81b51bfce671cf7a1fb27f3" + "EIPS/eip-8037.md", "5a8c80897aeb0952322cd0dfff767c541002b8c3" ) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_block_2d_gas_accounting.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_block_2d_gas_accounting.py index 84cbb484e4e..045ca738297 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_block_2d_gas_accounting.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_block_2d_gas_accounting.py @@ -41,9 +41,10 @@ def sstore_tx_gas(fork: Fork, num_sstores: int = 1) -> tuple[int, int]: """Return (execution, state) gas for a tx with N cold SSTOREs.""" + code = Op.SSTORE(0, 1, original_value=0, new_value=1) intrinsic_gas = fork.transaction_intrinsic_cost_calculator()() - evm_total = num_sstores * Op.SSTORE(0, 1).execution_cost(fork) - state = num_sstores * Op.SSTORE(new_value=1).state_cost(fork) + evm_total = num_sstores * code.execution_cost(fork) + state = num_sstores * code.state_cost(fork) return intrinsic_gas + evm_total, state @@ -64,9 +65,9 @@ def sstore_txs( txs, post = [], {} for _ in range(n): storage = Storage() - code = Bytecode(Op.STOP) + code = Bytecode() for _ in range(num_sstores): - code = Op.SSTORE(storage.store_next(1), 1) + code + code += Op.SSTORE(storage.store_next(1), 1) contract = pre.deploy_contract(code=code) txs.append( Transaction( @@ -153,26 +154,60 @@ def test_block_gas_used_execution_dominates( fork: Fork, ) -> None: """ - Verify block.gas_used = block_execution_gas when state gas is zero. + Verify block.gas_used = block_execution_gas when execution dominates. - A block containing only STOP transactions to existing contracts - produces no state gas. The block header gas_used must equal the - sum of execution gas across all transactions, since - max(execution, 0) = execution. + The contract sets a fresh slot, then exhausts an exactly sized gas + limit on memory expansion, so the state dimension is non-zero while + the larger execution dimension sets the header. """ - num_txs = 3 - intrinsic_gas = fork.transaction_intrinsic_cost_calculator()() - txs = stop_txs(pre, fork, num_txs) + sink_memory_size = 256 * 1024 + + storage = Storage() + code = Op.SSTORE( + storage.store_next(1, "slot_set"), + 1, + # gas accounting + original_value=0, + new_value=1, + ) + Op.MSTORE8( + sink_memory_size - 1, + 0, + # gas accounting + new_memory_size=sink_memory_size, + ) + + contract = pre.deploy_contract(code=code) + + block_execution = ( + fork.transaction_intrinsic_cost_calculator()() + + code.execution_cost(fork) + ) + block_state = code.state_cost(fork) + assert block_execution > block_state, "requires execution to dominate" + + gas_limit = block_execution + block_state + gas_limit_cap = fork.transaction_gas_limit_cap() + assert gas_limit_cap is None or gas_limit <= gas_limit_cap + + tx = Transaction( + to=contract, + gas_limit=gas_limit, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt(cumulative_gas_used=gas_limit), + ) blockchain_test( pre=pre, blocks=[ Block( - txs=txs, - header_verify=Header(gas_used=num_txs * intrinsic_gas), + txs=[tx], + header_verify=Header( + gas_used=max(block_execution, block_state) + ), ) ], - post={}, + post={contract: Account(storage=storage)}, + expected_receipt_status=1, ) @@ -257,7 +292,7 @@ def test_block_gas_refund_eip7778_no_block_reduction( current_value=1, new_value=0, )(0, 0) - tx_execution = intrinsic_gas + code.gas_cost(fork) - sstore_state_gas + tx_execution = intrinsic_gas + code.execution_cost(fork) expected = num_txs * tx_execution txs = [] for _ in range(num_txs): @@ -285,7 +320,6 @@ def test_block_gas_refund_eip7778_no_block_reduction( @pytest.mark.parametrize( "num_txs,num_sstores", [ - pytest.param(1, 1, id="single_sstore_single_tx"), pytest.param(5, 1, id="single_sstore"), pytest.param(20, 1, id="single_sstore_many_txs"), pytest.param(10, 5, id="multi_sstore_many_txs"), @@ -304,37 +338,24 @@ def test_block_2d_gas_boundary_exact_fit( Clients that sum execution + state will reject this valid block. """ - block_gas_limit = 30_000_000 - while True: - # We have a circular dependency to calculate the block gas limit based - # on the transactions required gas (tx gas increments as we increase - # the block gas limit to fit). This loops tries incrementing the - # block gas limit by consistent steps in order to find the minimum gas - # allows the transactions required to fit. - env = Environment( - gas_limit=block_gas_limit, - ) - tx_execution, tx_state = sstore_tx_gas(fork, num_sstores) - intrinsic_execution = fork.transaction_intrinsic_cost_calculator()() - - tx_limit = tx_execution + tx_state + tx_execution // 10 - - # Per-tx worst-case state contribution: tx.gas - intrinsic_execution. - # The block_gas_limit must leave enough state budget for every tx. - worst_state_per_tx = tx_limit - intrinsic_execution - minimum_block_gas_limit = max( - # Execution dimension: last tx must fit. - (num_txs - 1) * tx_execution + tx_limit, - # State dimension: cumulative worst-case must fit. - num_txs * worst_state_per_tx, - ) - if block_gas_limit >= minimum_block_gas_limit: - break - block_gas_limit += 1_000_000 + tx_execution, tx_state = sstore_tx_gas(fork, num_sstores) + # No reservoir below the cap: a tx pays both dimensions out of its + # own limit, and this is exactly what it spends. + tx_limit = tx_execution + tx_state - block_execution = num_txs * tx_execution - block_state = num_txs * tx_state - expected_gas_used = max(block_execution, block_state) + block_execution, block_state = tx_execution * num_txs, tx_state * num_txs + total_cost = block_execution + block_state + header_gas_used = max(block_execution, block_state) + + # The largest limit that still traps a client billing the header as + # sum(execution, state) instead of max(execution, state): such a + # client rejects the block for outgrowing its own gas limit, and + # misses the header either way. + block_gas_limit = total_cost - 1 + + assert header_gas_used <= block_gas_limit, "the block must be valid" + + env = Environment(gas_limit=block_gas_limit) txs, post = sstore_txs( pre, @@ -351,7 +372,7 @@ def test_block_2d_gas_boundary_exact_fit( Block( txs=txs, gas_limit=block_gas_limit, - header_verify=Header(gas_used=expected_gas_used), + header_verify=Header(gas_used=header_gas_used), ) ], post=post, @@ -371,34 +392,48 @@ def test_block_gas_used_call_new_account( GAS_NEW_ACCOUNT state gas) then SSTORE. Combined with a STOP tx, the 2D max must reflect state gas from account creation. """ - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - target = pre.fund_eoa(amount=0) - call = Op.CALL( + parent_storage = Storage() + parent_code = Op.CALL( gas=100_000, address=target, value=1, + # gas accounting value_transfer=True, account_new=True, + ) + Op.SSTORE( + parent_storage.store_next(1), + 1, + # gas accounting + original_value=0, + new_value=1, ) - parent_storage = Storage() - parent = pre.deploy_contract( - code=(call + Op.SSTORE(parent_storage.store_next(1), 1)), - balance=10**18, - ) + parent = pre.deploy_contract(code=parent_code, balance=10**18) + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()() txs = [ Transaction( to=parent, - state_gas_reservoir=call.state_cost(fork) + sstore_state_gas, + state_gas_reservoir=parent_code.state_cost(fork), sender=pre.fund_eoa(), ), ] + stop_txs(pre, fork, 1) + block_execution = ( + intrinsic_gas + parent_code.execution_cost(fork) + intrinsic_gas + ) + block_state = parent_code.state_cost(fork) + assert block_state > block_execution, "requires state gas to dominate" + blockchain_test( pre=pre, - blocks=[Block(txs=txs)], + blocks=[ + Block( + txs=txs, + header_verify=Header(gas_used=block_state), + ) + ], post={parent: Account(storage=parent_storage)}, ) @@ -416,26 +451,26 @@ def test_block_gas_used_create_tx( Combined with a STOP tx, verify the 2D max is correct. """ intrinsic_calc = fork.transaction_intrinsic_cost_calculator() - create_state_gas = fork.create_state_gas(code_size=0) - init_code = bytes(Op.STOP) - create_execution = ( - intrinsic_calc( - calldata=init_code, - contract_creation=True, - ) - - create_state_gas - ) + + create_execution = intrinsic_calc( + calldata=init_code, + contract_creation=True, + ) + fork.transaction_top_frame_gas_calculator()(contract_creation=True) + create_state = fork.transaction_top_frame_state_gas(contract_creation=True) stop_execution = intrinsic_calc() - expected = max(create_execution + stop_execution, create_state_gas) + assert create_state > create_execution + stop_execution, ( + "create state should dominate" + ) + sender = pre.fund_eoa() txs = [ Transaction( to=None, data=init_code, - state_gas_reservoir=create_state_gas, - sender=pre.fund_eoa(), + state_gas_reservoir=create_state, + sender=sender, ), ] + stop_txs(pre, fork, 1) @@ -444,10 +479,14 @@ def test_block_gas_used_create_tx( blocks=[ Block( txs=txs, - header_verify=Header(gas_used=expected), + header_verify=Header(gas_used=create_state), ) ], - post={}, + post={ + compute_create_address(address=sender, nonce=0): Account( + nonce=1, code=b"" + ) + }, ) @@ -468,6 +507,11 @@ def test_multi_block_dimension_flip( intrinsic_gas = fork.transaction_intrinsic_cost_calculator()() tx_execution, tx_state = sstore_tx_gas(fork) + # Block 1 has no state gas at all, execution leads by default; + block_1_execution = n * intrinsic_gas + block_2_execution, block_2_state = n * tx_execution, n * tx_state + assert block_2_state > block_2_execution, "block 2 must lead on state" + block_1 = stop_txs(pre, fork, n) block_2, post_2 = sstore_txs(pre, fork, n) @@ -476,13 +520,11 @@ def test_multi_block_dimension_flip( blocks=[ Block( txs=block_1, - header_verify=Header(gas_used=n * intrinsic_gas), + header_verify=Header(gas_used=block_1_execution), ), Block( txs=block_2, - header_verify=Header( - gas_used=max(n * tx_execution, n * tx_state), - ), + header_verify=Header(gas_used=block_2_state), ), ], post=post_2, diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_eip_mainnet.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_eip_mainnet.py index f01b048ff3a..e6dab6255ab 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_eip_mainnet.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_eip_mainnet.py @@ -11,9 +11,10 @@ StateTestFiller, Storage, Transaction, + compute_create_address, ) -from .spec import ref_spec_8037 +from .spec import init_code_at_high_bytes, ref_spec_8037 REFERENCE_SPEC_GIT_PATH = ref_spec_8037.git_path REFERENCE_SPEC_VERSION = ref_spec_8037.version @@ -47,18 +48,15 @@ def test_create_charges_state_gas( ) -> None: """Test CREATE charges state gas for new account creation.""" init_code = Op.STOP + mstore_value, size = init_code_at_high_bytes(init_code) storage = Storage() contract = pre.deploy_contract( code=( - Op.MSTORE( - 0, - int.from_bytes(bytes(init_code), "big") - << (256 - 8 * len(init_code)), - ) + Op.MSTORE(0, mstore_value) + Op.SSTORE( storage.store_next(True), - Op.GT(Op.CREATE(0, 0, len(init_code)), 0), + Op.GT(Op.CREATE(0, 0, size), 0), ) ), ) @@ -78,11 +76,14 @@ def test_create_tx_deploys_contract( pre: Alloc, ) -> None: """Test contract creation transaction succeeds with state gas.""" + sender = pre.fund_eoa() tx = Transaction( to=None, data=Op.STOP, state_gas_reservoir=0, - sender=pre.fund_eoa(), + sender=sender, ) - state_test(pre=pre, post={}, tx=tx) + created = compute_create_address(address=sender, nonce=0) + post = {created: Account(nonce=1, code=b"")} + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_delegation_pointer.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_delegation_pointer.py index 406550445af..f5d6cdad876 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_delegation_pointer.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_delegation_pointer.py @@ -15,10 +15,13 @@ Alloc, AuthorizationTuple, Fork, + Header, Op, + RecipientType, StateTestFiller, Storage, Transaction, + TransactionReceipt, ) from .spec import ref_spec_8037 @@ -41,12 +44,9 @@ def test_sstore_via_delegation_pointer( contract code in the EOA's context. The SSTORE state gas should be charged from the reservoir just as it would for a direct call. """ - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - storage = Storage() - contract = pre.deploy_contract( - code=Op.SSTORE(storage.store_next(1), 1), - ) + contract_code = Op.SSTORE(storage.store_next(1), 1) + contract = pre.deploy_contract(code=contract_code) # EOA with pre-existing delegation to the contract delegator = pre.fund_eoa(delegation=contract) @@ -62,20 +62,51 @@ def test_sstore_via_delegation_pointer( writes_delegation=False, first_write=False, ) + intrinsic_execution = fork.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=[authorization], + recipient_type=RecipientType.DELEGATION_7702, + return_cost_deducted_prior_execution=True, + ) + top_frame_execution = fork.transaction_top_frame_gas_calculator()( + recipient_type=RecipientType.DELEGATION_7702, + delegation_warm=False, + authorizations=[authorization], + ) auth_state_gas = fork.transaction_top_frame_state_gas( - authorizations=[authorization] + recipient_type=RecipientType.DELEGATION_7702, + authorizations=[authorization], + ) + assert auth_state_gas == 0 + + block_execution = ( + intrinsic_execution + + top_frame_execution + + contract_code.execution_cost(fork) ) + block_state = auth_state_gas + contract_code.state_cost(fork) + assert block_state > block_execution + sender = pre.fund_eoa() tx = Transaction( to=delegator, - state_gas_reservoir=auth_state_gas + sstore_state_gas, + state_gas_reservoir=block_state, authorization_list=[authorization], sender=sender, + expected_receipt=TransactionReceipt( + cumulative_gas_used=block_execution + block_state, + ), ) # SSTORE writes to the delegator's storage context post = {delegator: Account(storage=storage)} - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header( + gas_used=max(block_execution, block_state) + ), + ) @pytest.mark.valid_from("EIP8037") @@ -90,22 +121,48 @@ def test_sstore_direct_call_same_contract( Baseline comparison: calling the contract directly (not via a delegation pointer) charges SSTORE state gas identically. """ - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - storage = Storage() - contract = pre.deploy_contract( - code=Op.SSTORE(storage.store_next(1), 1), + contract_code = Op.SSTORE(storage.store_next(1), 1) + contract = pre.deploy_contract(code=contract_code) + + intrinsic_execution = fork.transaction_intrinsic_cost_calculator()( + return_cost_deducted_prior_execution=True, + ) + top_frame_execution = fork.transaction_top_frame_gas_calculator()( + recipient_type=RecipientType.CONTRACT, + ) + top_frame_state = fork.transaction_top_frame_state_gas( + recipient_type=RecipientType.CONTRACT, ) + assert top_frame_state == 0 + + block_execution = ( + intrinsic_execution + + top_frame_execution + + contract_code.execution_cost(fork) + ) + block_state = top_frame_state + contract_code.state_cost(fork) + assert block_state > block_execution sender = pre.fund_eoa() tx = Transaction( to=contract, - state_gas_reservoir=sstore_state_gas, + state_gas_reservoir=block_state, sender=sender, + expected_receipt=TransactionReceipt( + cumulative_gas_used=block_execution + block_state, + ), ) post = {contract: Account(storage=storage)} - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header( + gas_used=max(block_execution, block_state) + ), + ) @pytest.mark.valid_from("EIP8037") @@ -124,18 +181,16 @@ def test_delegation_pointer_new_account_state_gas( target = pre.nonexistent_account() parent_storage = Storage() + call = Op.CALL( - gas=100_000, + gas=0, address=target, value=1, value_transfer=True, account_new=True, ) - contract = pre.deploy_contract( - code=Op.SSTORE(parent_storage.store_next(1), call), - balance=1, - ) - new_account_state_gas = call.state_cost(fork) + contract_code = Op.SSTORE(parent_storage.store_next(1), call) + contract = pre.deploy_contract(code=contract_code, balance=1) # EOA delegates to the contract delegator = pre.fund_eoa(delegation=contract, amount=1) @@ -151,18 +206,55 @@ def test_delegation_pointer_new_account_state_gas( writes_delegation=False, first_write=False, ) + intrinsic_execution = fork.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=[authorization], + recipient_type=RecipientType.DELEGATION_7702, + return_cost_deducted_prior_execution=True, + ) + top_frame_execution = fork.transaction_top_frame_gas_calculator()( + recipient_type=RecipientType.DELEGATION_7702, + delegation_warm=False, + authorizations=[authorization], + ) auth_state_gas = fork.transaction_top_frame_state_gas( - authorizations=[authorization] + recipient_type=RecipientType.DELEGATION_7702, + authorizations=[authorization], + ) + assert auth_state_gas == 0 + + # The callee leaves the value-transfer stipend unused, so it returns + # to this frame instead of being spent. + block_execution = ( + intrinsic_execution + + top_frame_execution + + contract_code.execution_cost(fork) + - fork.gas_costs().CALL_STIPEND ) + block_state = auth_state_gas + contract_code.state_cost(fork) + assert block_state > block_execution sender = pre.fund_eoa() tx = Transaction( to=delegator, - state_gas_reservoir=auth_state_gas + new_account_state_gas, + state_gas_reservoir=block_state, authorization_list=[authorization], sender=sender, + expected_receipt=TransactionReceipt( + cumulative_gas_used=block_execution + block_state, + ), ) # CALL success stored in delegator's storage context - post = {delegator: Account(storage=parent_storage)} - state_test(pre=pre, post=post, tx=tx) + post = { + delegator: Account(storage=parent_storage, balance=0), + target: Account(balance=1), + } + + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header( + gas_used=max(block_execution, block_state) + ), + ) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_fork_transition.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_fork_transition.py index 255fd52c042..9ab04ef772a 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_fork_transition.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_fork_transition.py @@ -42,40 +42,82 @@ def test_sstore_state_gas_at_transition( blockchain_test: BlockchainTestFiller, pre: Alloc, + fork: Fork, ) -> None: """ Test SSTORE state gas activates at the EIP-8037 fork boundary. - Before the fork, an SSTORE zero-to-nonzero succeeds with only - execution gas (no state gas dimension). After the fork, the same - operation requires state gas. Both blocks use TX_MAX_GAS_LIMIT - which provides enough gas in either regime. + A sub-call granted only the store's execution gas succeeds before + the fork, and after it only when a reservoir carries the new state + charge into the child frame. """ - contract_before = pre.deploy_contract( - code=Op.SSTORE(0, 1), + before_fork = fork.fork_at(timestamp=14_999) + after_fork = fork.fork_at(timestamp=15_000) + + sstore_code = Op.SSTORE(0, 1, original_value=0, new_value=1) + # Each side gets only what its own fork prices as execution gas. + before_grant = sstore_code.gas_cost(before_fork) + execution_gas = sstore_code.execution_cost(after_fork) + state_gas = sstore_code.state_cost(after_fork) + assert sstore_code.state_cost(before_fork) == 0, "no state dimension yet" + assert state_gas > 0 + + storage_before = Storage() + target_before = pre.deploy_contract(code=sstore_code) + caller_before = pre.deploy_contract( + code=Op.SSTORE( + storage_before.store_next(1, "subcall_succeeds"), + Op.CALL(gas=before_grant, address=target_before), + ), ) - contract_after = pre.deploy_contract( - code=Op.SSTORE(0, 1), + + storage_funded = Storage() + target_funded = pre.deploy_contract(code=sstore_code) + caller_funded = pre.deploy_contract( + code=Op.SSTORE( + storage_funded.store_next(1, "reservoir_pays_state_gas"), + Op.CALL(gas=execution_gas, address=target_funded), + ), + ) + + storage_starved = Storage() + target_starved = pre.deploy_contract(code=sstore_code) + caller_starved = pre.deploy_contract( + code=Op.SSTORE( + storage_starved.store_next(0, "subcall_runs_out_of_state_gas"), + Op.CALL(gas=execution_gas, address=target_starved), + ), ) blocks = [ - # Before fork: SSTORE succeeds with execution gas only + # Pre-fork: the grant is the whole price. Block( timestamp=14_999, txs=[ Transaction( - to=contract_before, + to=caller_before, state_gas_reservoir=0, sender=pre.fund_eoa(), ), ], ), - # After fork: SSTORE succeeds — state gas drawn from gas_left + # Post-fork: the reservoir pays the state charge. Block( timestamp=15_000, txs=[ Transaction( - to=contract_after, + to=caller_funded, + state_gas_reservoir=state_gas, + sender=pre.fund_eoa(), + ), + ], + ), + # Post-fork, no reservoir: the state charge halts the child. + Block( + timestamp=15_001, + txs=[ + Transaction( + to=caller_starved, state_gas_reservoir=0, sender=pre.fund_eoa(), ), @@ -84,8 +126,12 @@ def test_sstore_state_gas_at_transition( ] post = { - contract_before: Account(storage={0: 1}), - contract_after: Account(storage={0: 1}), + caller_before: Account(storage=storage_before), + target_before: Account(storage={0: 1}), + caller_funded: Account(storage=storage_funded), + target_funded: Account(storage={0: 1}), + caller_starved: Account(storage=storage_starved), + target_starved: Account(storage={0: 0}), } blockchain_test(pre=pre, blocks=blocks, post=post) @@ -192,20 +238,18 @@ def test_reservoir_available_after_transition( which child calls can draw from for state operations. """ after_fork = fork.fork_at(timestamp=15_000) - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(after_fork) + child_code = Op.SSTORE(0, 1, original_value=0, new_value=1) + sstore_state_gas = child_code.state_cost(after_fork) child_storage = Storage() - child = pre.deploy_contract( - code=Op.SSTORE(child_storage.store_next(1), 1), - ) + child_storage.store_next(1, "child_slot_set") + child = pre.deploy_contract(code=child_code) parent_storage = Storage() parent = pre.deploy_contract( - code=( - Op.SSTORE( - parent_storage.store_next(1), - Op.CALL(gas=100_000, address=child), - ) + code=Op.SSTORE( + parent_storage.store_next(1, "subcall_succeeds"), + Op.CALL(gas=child_code.execution_cost(after_fork), address=child), ), ) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_selfdestruct.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_selfdestruct.py index 7ff6505041e..abda3adfddd 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_selfdestruct.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_selfdestruct.py @@ -50,18 +50,15 @@ def test_selfdestruct_new_beneficiary_state_gas( spilled into `gas_left` (in-cap tx): the block bills NEW_ACCOUNT in the state dimension and the beneficiary is created. """ - new_account_state_gas = Op.SELFDESTRUCT(account_new=True).state_cost(fork) - beneficiary = 0xDEAD + beneficiary = pre.nonexistent_account() + code = Op.SELFDESTRUCT(beneficiary, account_new=True) + state_cost = code.state_cost(fork) - contract = pre.deploy_contract( - code=Op.SELFDESTRUCT(beneficiary), balance=1 - ) + contract = pre.deploy_contract(code=code, balance=1) tx = Transaction( to=contract, sender=pre.fund_eoa(), - state_gas_reservoir=( - new_account_state_gas if funding == "reservoir" else 0 - ), + state_gas_reservoir=(state_cost if funding == "reservoir" else 0), ) state_test( @@ -71,13 +68,14 @@ def test_selfdestruct_new_beneficiary_state_gas( contract: Account(balance=0), }, tx=tx, - blockchain_test_header_verify=Header(gas_used=new_account_state_gas), + blockchain_test_header_verify=Header(gas_used=state_cost), ) @pytest.mark.valid_from("EIP8037") def test_selfdestruct_existing_beneficiary_no_state_gas( state_test: StateTestFiller, + fork: Fork, pre: Alloc, ) -> None: """ @@ -86,25 +84,37 @@ def test_selfdestruct_existing_beneficiary_no_state_gas( When the beneficiary already exists, no new account is created and no state gas is charged. """ - beneficiary = pre.fund_eoa(amount=0) + beneficiary = pre.fund_eoa(amount=1) + code = Op.SELFDESTRUCT(beneficiary, account_new=False) contract = pre.deploy_contract( - code=Op.SELFDESTRUCT(beneficiary), + code=code, balance=1, ) + gas_limit = ( + fork.transaction_intrinsic_cost_calculator()() + + fork.transaction_top_frame_gas_calculator()(contract_creation=False) + + code.execution_cost(fork) + ) + tx = Transaction( to=contract, - state_gas_reservoir=0, + gas_limit=gas_limit, sender=pre.fund_eoa(), ) - state_test(pre=pre, post={}, tx=tx) + state_test( + pre=pre, + tx=tx, + post={beneficiary: Account(balance=2), contract: Account(balance=0)}, + ) @pytest.mark.valid_from("EIP8037") def test_selfdestruct_zero_balance_no_state_gas( state_test: StateTestFiller, + fork: Fork, pre: Alloc, ) -> None: """ @@ -115,25 +125,36 @@ def test_selfdestruct_zero_balance_no_state_gas( does not exist. """ # Non-existent beneficiary but contract has zero balance - beneficiary = 0xDEAD + beneficiary = pre.nonexistent_account() + code = Op.SELFDESTRUCT(beneficiary, account_new=False) contract = pre.deploy_contract( - code=Op.SELFDESTRUCT(beneficiary), + code=code, balance=0, ) + gas_limit = ( + fork.transaction_intrinsic_cost_calculator()() + + fork.transaction_top_frame_gas_calculator()(contract_creation=False) + + code.execution_cost(fork) + ) + tx = Transaction( to=contract, - state_gas_reservoir=0, + gas_limit=gas_limit, sender=pre.fund_eoa(), ) - state_test(pre=pre, post={}, tx=tx) + state_test( + pre=pre, + post={beneficiary: Account.NONEXISTENT, contract: Account(balance=0)}, + tx=tx, + ) @pytest.mark.valid_from("EIP8037") def test_selfdestruct_to_self_in_create_tx( - state_test: StateTestFiller, + blockchain_test: BlockchainTestFiller, pre: Alloc, fork: Fork, ) -> None: @@ -141,34 +162,45 @@ def test_selfdestruct_to_self_in_create_tx( Test SELFDESTRUCT to self in the transaction the contract was created. When a contract created in the current transaction SELFDESTRUCTs - to itself, the balance is burned and the account is deleted. No - new account state gas is charged since the beneficiary already - exists. + to itself, the balance stays at the cleared account. No new account + state gas is charged for the sweep since the beneficiary already + exists: the CREATE paid it, and the clearing does not refill it. """ - gas_limit_cap = fork.transaction_gas_limit_cap() - assert gas_limit_cap is not None - - inner_code = Op.SELFDESTRUCT(Op.ADDRESS) + inner_code = Op.SELFDESTRUCT( + Op.ADDRESS, + # gas accounting + address_warm=True, + account_new=False, + ) + mstore_value, size = init_code_at_high_bytes(inner_code) - contract = pre.deploy_contract( - code=( - Op.MSTORE( - 0, - int.from_bytes(bytes(inner_code), "big") - << (256 - 8 * len(inner_code)), - ) - + Op.POP(Op.CREATE(1, 0, len(inner_code))) - ), - balance=1, + code = Op.MSTORE(0, mstore_value) + Op.POP( + Op.CREATE(1, 0, size, init_code_size=size, new_memory_size=32) ) + contract = pre.deploy_contract(code=code, balance=1) + created = compute_create_address(address=contract, nonce=1) + + expected_state = code.state_cost(fork) tx = Transaction( to=contract, - gas_limit=gas_limit_cap * 2, + state_gas_reservoir=expected_state, sender=pre.fund_eoa(), ) - state_test(pre=pre, post={}, tx=tx) + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + header_verify=Header(gas_used=expected_state), + ), + ], + post={ + contract: Account(balance=0, nonce=2), + created: Account(balance=1, nonce=0, code=b""), + }, + ) @pytest.mark.valid_from("EIP8037") @@ -184,32 +216,33 @@ def test_selfdestruct_new_beneficiary_header_gas_used( beneficiary, charging GAS_NEW_ACCOUNT state gas. The block must be accepted with correct 2D gas accounting in the header. """ - new_account_state_gas = Op.SELFDESTRUCT(account_new=True).state_cost(fork) + beneficiary = pre.nonexistent_account() - beneficiary = pre.fund_eoa(amount=0) - - storage = Storage() + inner_code = Op.SELFDESTRUCT(beneficiary, account_new=True) inner = pre.deploy_contract( - code=Op.SELFDESTRUCT(beneficiary), + code=inner_code, balance=1, ) + + storage = Storage() + call_code = Op.CALL(gas=100_000, address=inner) + Op.SSTORE( + storage.store_next(1, "completed"), 1 + ) caller = pre.deploy_contract( - code=( - Op.CALL(gas=100_000, address=inner) - + Op.SSTORE(storage.store_next(1, "completed"), 1) - ), + code=call_code, ) + state_cost = inner_code.state_cost(fork) + call_code.state_cost(fork) tx = Transaction( to=caller, - state_gas_reservoir=new_account_state_gas, + state_gas_reservoir=state_cost, sender=pre.fund_eoa(), ) blockchain_test( pre=pre, blocks=[ - Block(txs=[tx]), + Block(txs=[tx], header_verify=Header(gas_used=state_cost)), ], post={caller: Account(storage=storage)}, ) @@ -238,7 +271,7 @@ def test_selfdestruct_state_gas_refilled_on_ancestor_revert( expected_execution = ( fork.transaction_intrinsic_cost_calculator()() - + caller_code.gas_cost(fork) + + caller_code.execution_cost(fork) + inner_code.execution_cost(fork) ) tx = Transaction(to=caller, sender=pre.fund_eoa()) @@ -279,7 +312,9 @@ def test_create_selfdestruct_no_refund_account_and_storage( current_value=0, new_value=1, )(i, 1) - init_code += Op.SELFDESTRUCT.with_metadata(address_warm=True)(Op.ADDRESS) + init_code += Op.SELFDESTRUCT( + Op.ADDRESS, account_new=False, address_warm=True + ) mstore_value, size = init_code_at_high_bytes(init_code) # Metadata so `.gas_cost(fork)` matches runtime charges. @@ -300,11 +335,15 @@ def test_create_selfdestruct_no_refund_account_and_storage( ) execution_used = ( intrinsic_gas - + factory_code.gas_cost(fork) - + init_code.gas_cost(fork) - - total_state_gas + + factory_code.execution_cost(fork) + + init_code.execution_cost(fork) ) - expected_gas_used = max(execution_used, total_state_gas) + + assert total_state_gas > execution_used, ( + f"test requires state gas > execution gas, got " + f"state={total_state_gas} execution={execution_used}" + ) + expected_gas_used = total_state_gas tx = Transaction( to=factory, @@ -312,12 +351,16 @@ def test_create_selfdestruct_no_refund_account_and_storage( sender=pre.fund_eoa(), ) + created = compute_create_address( + address=factory, nonce=1, opcode=create_opcode + ) + blockchain_test( pre=pre, blocks=[ Block(txs=[tx], header_verify=Header(gas_used=expected_gas_used)), ], - post={}, + post={created: Account.NONEXISTENT}, ) @@ -379,6 +422,17 @@ def test_create_selfdestruct_no_refund_code_deposit_state_gas( created_address = compute_create_address(address=factory, nonce=1) total_state_gas = factory_code.state_cost(fork) + initcode.state_cost(fork) + total_execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + fork.transaction_top_frame_gas_calculator()(contract_creation=False) + + factory_code.execution_cost(fork) + + initcode.execution_cost(fork) + ) + + assert total_state_gas > total_execution_gas, ( + "requires state gas > execution gas" + ) + tx = Transaction( to=factory, data=bytes(initcode), @@ -388,7 +442,9 @@ def test_create_selfdestruct_no_refund_code_deposit_state_gas( blockchain_test( pre=pre, - blocks=[Block(txs=[tx])], + blocks=[ + Block(txs=[tx], header_verify=Header(gas_used=total_state_gas)) + ], post={created_address: Account.NONEXISTENT}, ) @@ -439,7 +495,15 @@ def test_create_selfdestruct_code_deposit_no_refund_header_check( sender=pre.fund_eoa(), ) - baseline_block_execution = 0x94C8 + baseline_block_execution = ( + fork.transaction_intrinsic_cost_calculator()() + + fork.transaction_top_frame_gas_calculator()(contract_creation=False) + + factory_code.execution_cost(fork) + + initcode.execution_cost(fork) + ) + assert total_state_gas > baseline_block_execution, ( + "requires state gas > execution gas" + ) expected_gas_used = max(baseline_block_execution, total_state_gas) blockchain_test( @@ -495,12 +559,13 @@ def test_create_selfdestruct_sstore_restoration_refund( state_used = new_account_state_gas execution_used = ( intrinsic_gas - + factory_code.gas_cost(fork) - + init_code.gas_cost(fork) - - new_account_state_gas - - sstore_state_gas + + factory_code.execution_cost(fork) + + init_code.execution_cost(fork) ) expected_gas_used = max(execution_used, state_used) + assert expected_gas_used == state_used, ( + "expected state gas to dominate execution gas" + ) tx = Transaction( to=factory, @@ -547,7 +612,9 @@ def test_selfdestruct_pre_existing_account_no_refund( # No refund offset: both caller_code and victim_code are pure # execution gas (SELFDESTRUCT to self, no value-to-new-account). tx_execution = ( - intrinsic_gas + caller_code.gas_cost(fork) + victim_code.gas_cost(fork) + intrinsic_gas + + caller_code.execution_cost(fork) + + victim_code.execution_cost(fork) ) tx = Transaction( @@ -572,9 +639,7 @@ def test_selfdestruct_pre_existing_account_no_refund( pytest.param(2, id="two_hops"), ], ) -@pytest.mark.with_all_call_opcodes( - selector=lambda call_opcode: call_opcode in (Op.DELEGATECALL, Op.CALLCODE) -) +@pytest.mark.parametrize("call_opcode", [Op.DELEGATECALL, Op.CALLCODE]) @pytest.mark.valid_from("EIP8037") def test_selfdestruct_via_delegatecall_chain_no_refund( blockchain_test: BlockchainTestFiller, @@ -593,7 +658,7 @@ def test_selfdestruct_via_delegatecall_chain_no_refund( # just delegate further down. Track each frame's bytecode so we # can sum its execution gas into `expected_gas_used` below. sd_code = Op.SELFDESTRUCT.with_metadata(address_warm=True)(Op.ADDRESS) - chain_execution_gas = sd_code.gas_cost(fork) + chain_execution_gas = sd_code.execution_cost(fork) delegate_target = pre.deploy_contract(code=sd_code) for _ in range(num_hops - 1): hop_code = ( @@ -604,7 +669,7 @@ def test_selfdestruct_via_delegatecall_chain_no_refund( ) + Op.STOP ) - chain_execution_gas += hop_code.gas_cost(fork) + chain_execution_gas += hop_code.execution_cost(fork) delegate_target = pre.deploy_contract(code=hop_code) # A's deployed runtime: one delegation into the top of the chain. @@ -668,11 +733,10 @@ def test_selfdestruct_via_delegatecall_chain_no_refund( total_state_gas = factory_code.state_cost(fork) + initcode.state_cost(fork) execution_used = ( intrinsic_gas - + factory_code.gas_cost(fork) - + initcode.gas_cost(fork) - + deployed_code.gas_cost(fork) + + factory_code.execution_cost(fork) + + initcode.execution_cost(fork) + + deployed_code.execution_cost(fork) + chain_execution_gas - - total_state_gas ) expected_gas_used = max(execution_used, total_state_gas) @@ -714,19 +778,38 @@ def test_selfdestruct_new_beneficiary_account_write_cost( victim_code = Op.SELFDESTRUCT(beneficiary, account_new=True) victim = pre.deploy_contract(code=victim_code, balance=1) - # Tight budget: slack is less than the legacy 25,000 execution - # account-creation cost minus `ACCOUNT_WRITE`, so any execution draw - # beyond `ACCOUNT_WRITE` would OOG. The opcode metadata folds the - # `ACCOUNT_WRITE` execution cost and the account-creation state gas - # into `gas_cost`. - intrinsic = fork.transaction_intrinsic_cost_calculator()() + storage = Storage() + execution_cost = victim_code.execution_cost(fork) + state_cost = victim_code.state_cost(fork) + + slot = storage.store_next(1, "subcall_succeeds") + + caller_code = Op.SSTORE( + slot, + Op.CALL(gas=execution_cost, address=victim), + # gas accounting + key_warm=False, + original_value=2, + current_value=2, + new_value=1, + ) + caller = pre.deploy_contract(code=caller_code, storage={slot: 2}) + tx = Transaction( - to=victim, - gas_limit=(intrinsic + victim_code.gas_cost(fork) + 4_000), + to=caller, + state_gas_reservoir=state_cost, sender=pre.fund_eoa(), ) - state_test(pre=pre, post={beneficiary: Account(balance=1)}, tx=tx) + state_test( + pre=pre, + post={ + beneficiary: Account(balance=1), + victim: Account(balance=0), + caller: Account(storage=storage), + }, + tx=tx, + ) @pytest.mark.parametrize( @@ -786,6 +869,9 @@ def test_create_tx_selfdestruct_initcode_state_gas( ) + init_code.state_cost(fork) expected_execution = intrinsic_execution + init_code.execution_cost(fork) expected_gas_used = max(expected_execution, expected_state) + assert expected_gas_used == expected_state, ( + "expected state gas to dominate execution gas" + ) tx = Transaction( to=None, From 5a7cd6b00d3b78565a6aa4164505fa543fb39e85 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Wed, 26 Aug 2026 12:07:33 +0200 Subject: [PATCH 18/59] fix(evm-tools): accept empty hex transaction values (#3424) --- .../execution_testing/evm_tools/t8n/cli.py | 16 +++++++---- .../evm_tools/tests/test_statetest.py | 28 +++++++++++++++++-- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/testing/src/execution_testing/evm_tools/t8n/cli.py b/packages/testing/src/execution_testing/evm_tools/t8n/cli.py index 7b438d83f65..60bcaeee82c 100644 --- a/packages/testing/src/execution_testing/evm_tools/t8n/cli.py +++ b/packages/testing/src/execution_testing/evm_tools/t8n/cli.py @@ -116,23 +116,27 @@ def _parse_ommers_from_env_json(env_json: Any, fork: Any) -> List[Ommer]: def _normalize_tx_json(tx: Dict[str, Any]) -> Dict[str, Any]: """ - Drop fields that the testing ``Transaction`` model rejects. + Normalize JSON inputs that the testing ``Transaction`` model rejects. - Three boundary mismatches to smooth over: + Four boundary mismatches to smooth over: - 1. ``yParity`` on authorization tuples. The testing + 1. An empty hexadecimal transaction value. Some legacy state tests + encode zero as ``0x``, while the testing ``HexNumber`` requires at + least one digit. Normalize it to the accepted numeric ``0x0`` + representation. + 2. ``yParity`` on authorization tuples. The testing ``AuthorizationTuple`` serializer emits both ``v`` and ``yParity`` (they are guaranteed equal — see the model's ``duplicate_v_as_y_parity``), but its validator binds only ``v`` and treats ``yParity`` as an extra-forbidden field. - 2. ``secretKey`` on an already-signed tx. The testing + 3. ``secretKey`` on an already-signed tx. The testing ``Transaction`` retains the private key after auto-signing in ``model_post_init``, so the dump still carries ``secretKey`` alongside the populated ``v``/``r``/``s``. On re-validation the model rejects the pair with ``InvalidSignaturePrivateKeyError``. Strip ``secretKey`` whenever ``v`` is set (i.e. the tx is already signed). - 3. A tx with no signature material at all. Filled state tests + 4. A tx with no signature material at all. Filled state tests store a tx whose signature is deliberately invalid without ``v``/``r``/``s`` or ``secretKey`` (the fixture format cannot express explicit signature values), expecting the fork to @@ -140,6 +144,8 @@ def _normalize_tx_json(tx: Dict[str, Any]) -> Dict[str, Any]: would make ``Transaction.rlp`` try to auto-sign a key-less tx and die on an assertion. """ + if tx.get("value") == "0x": + tx["value"] = "0x0" auth_list = tx.get("authorizationList") if isinstance(auth_list, list): tx["authorizationList"] = [ diff --git a/packages/testing/src/execution_testing/evm_tools/tests/test_statetest.py b/packages/testing/src/execution_testing/evm_tools/tests/test_statetest.py index 5a33eae9dae..8127d9cbf1c 100644 --- a/packages/testing/src/execution_testing/evm_tools/tests/test_statetest.py +++ b/packages/testing/src/execution_testing/evm_tools/tests/test_statetest.py @@ -8,7 +8,7 @@ import pytest -from execution_testing.base_types import Hash +from execution_testing.base_types import EmptyTrieRoot, Hash from execution_testing.evm_tools import statetest from execution_testing.evm_tools.statetest import ( StateTest, @@ -23,7 +23,12 @@ pytestmark = pytest.mark.evm_tools -def _test_case(*, env: dict[str, Any], post_hash: str) -> StateTestCase: +def _test_case( + *, + env: dict[str, Any], + post_hash: str, + transaction_value: str = "0x0", +) -> StateTestCase: """Create a minimal state test case.""" return StateTestCase( path="test.json", @@ -39,7 +44,7 @@ def _test_case(*, env: dict[str, Any], post_hash: str) -> StateTestCase: transaction={ "data": ["0x"], "gasLimit": ["0x5208"], - "value": ["0x0"], + "value": [transaction_value], }, ) @@ -81,6 +86,23 @@ def fake_build_t8n( } +def test_run_test_case_accepts_empty_hex_value() -> None: + """Treat the legacy empty hexadecimal transaction value as zero.""" + test_case = _test_case( + env={ + "currentBaseFee": "0x7", + "currentRandom": "0x" + "00" * 32, + }, + post_hash="0x", + transaction_value="0x", + ) + + with ForkCache() as fork_cache: + result = run_test_case(test_case, fork_cache) + + assert result.state_root == Hash(EmptyTrieRoot) + + def test_run_one_formats_state_root_once( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], From e221d0c8f80b1829620fa663e6091ac85d456e29 Mon Sep 17 00:00:00 2001 From: shubham shinde Date: Wed, 26 Aug 2026 16:13:37 +0530 Subject: [PATCH 19/59] chore(tests): add genesis block header field BAL checklist (#3416) --- .../eip7928_block_level_access_lists/test_block_access_lists.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index 565869afdd0..7867f817faa 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -48,6 +48,7 @@ @EIPChecklist.BlockHeaderField.Test.ValueBehavior.Accept() +@EIPChecklist.BlockHeaderField.Test.Genesis() def test_bal_nonce_changes( pre: Alloc, blockchain_test: BlockchainTestFiller, From 20f7f6271a720091e5fea0a82e7bc802866ae36a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 26 Aug 2026 14:30:20 +0200 Subject: [PATCH 20/59] refactor(test-benchmark): tidy SSTORE storage benchmark (#3442) Make StorageAction a proper Enum instead of a plain class holding bare auto() sentinels (which only worked by object identity), and fix the stale "Returns: (bytecode, loop_cost, overhead)" docstrings in create_storage_initializer / create_benchmark_executor, which return a single IteratingBytecode. --- tests/benchmark/compute/instruction/test_storage.py | 4 ++-- tests/benchmark/helper/enums.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/benchmark/compute/instruction/test_storage.py b/tests/benchmark/compute/instruction/test_storage.py index c44f3c76b56..671b9a6ae79 100644 --- a/tests/benchmark/compute/instruction/test_storage.py +++ b/tests/benchmark/compute/instruction/test_storage.py @@ -98,7 +98,7 @@ def create_storage_initializer() -> IteratingBytecode: storage[i] = i for i in [index, index + num). - Returns: (bytecode, loop_cost, overhead) + Return an IteratingBytecode with the initialization loop. """ prefix = ( Op.CALLDATALOAD(0) # [index] @@ -137,7 +137,7 @@ def create_benchmark_executor( - CALLDATA[0..32] start slot (index) - CALLDATA[32..64] slot count (num) - Returns: (bytecode, loop_cost, overhead) + Return an IteratingBytecode with the benchmark execution loop. """ prefix = ( Op.CALLDATALOAD(0) # [index] diff --git a/tests/benchmark/helper/enums.py b/tests/benchmark/helper/enums.py index 2ff638712c5..8c19d0eab30 100644 --- a/tests/benchmark/helper/enums.py +++ b/tests/benchmark/helper/enums.py @@ -5,7 +5,7 @@ from execution_testing import TxOutcome -class StorageAction: +class StorageAction(Enum): """Enum for storage actions.""" READ = auto() From abbe05777ab83fb94ce18c425daaa7ab79e779c1 Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Wed, 26 Aug 2026 21:04:29 -0600 Subject: [PATCH 21/59] feat(test-fill): Optimistic grouping flag (#3390) * feat(fill): Make pre-alloc groups packing optional * fix(bug): Bug due to untyped structs * nit * claude findings * claude updated docs * fix: backwards compatible index reading * fix: properly skip amsterdam skip list for engine x * fix: bug saving pre-alloc group builder as final product * fix: hasher * fix: typo * fix: chain ID typing * fix: unit test * fix: remove `env` from the final pre-alloc group * fix: Review comments Co-authored-by: danceratopz --------- Co-authored-by: danceratopz --- .../test_formats/blockchain_test_engine_x.md | 36 ++- .../execution_testing/cli/compare_fixtures.py | 10 +- .../execution_testing/cli/extract_config.py | 5 +- .../src/execution_testing/cli/gen_index.py | 9 +- .../src/execution_testing/cli/hasher.py | 57 ++-- .../simulators/helpers/test_tracker.py | 4 +- .../consume/simulators/multi_test_client.py | 19 +- .../pytest_commands/plugins/filler/filler.py | 252 +++++++--------- .../plugins/filler/pre_alloc.py | 28 +- .../filler/tests/test_filling_session.py | 33 +- .../filler/tests/test_prealloc_group.py | 67 +---- .../cli/show_pre_alloc_group_stats.py | 2 +- .../cli/tests/test_extract_config.py | 58 +++- .../cli/tests/test_hasher.py | 175 +++++++---- .../cli/tests/test_pytest_fill_command.py | 11 +- .../execution_testing/fixtures/blockchain.py | 5 +- .../execution_testing/fixtures/collector.py | 23 +- .../src/execution_testing/fixtures/consume.py | 32 +- .../fixtures/pre_alloc_groups.py | 281 +++++++++++------- .../fixtures/tests/test_consume.py | 66 ++++ .../fixtures/tests/test_pre_alloc_groups.py | 168 +++++++---- .../src/execution_testing/specs/blockchain.py | 20 +- .../execution_testing/test_types/__init__.py | 3 +- .../test_types/account_types.py | 78 +++++ .../test_types/block_types.py | 21 +- tests/ported_static/conftest.py | 27 +- 26 files changed, 885 insertions(+), 605 deletions(-) create mode 100644 packages/testing/src/execution_testing/fixtures/tests/test_consume.py diff --git a/docs/running_tests/test_formats/blockchain_test_engine_x.md b/docs/running_tests/test_formats/blockchain_test_engine_x.md index 5087655e3b2..4302b903789 100644 --- a/docs/running_tests/test_formats/blockchain_test_engine_x.md +++ b/docs/running_tests/test_formats/blockchain_test_engine_x.md @@ -28,22 +28,27 @@ Each file in the `pre_alloc` folder corresponds to a pre-allocation group identi ```json { - "test_count": 88, - "pre_account_count": 174, + "testCount": 88, + "preAccountCount": 174, "testIds": ["test1", "test2", ...], "network": "Prague", - "environment": { ... }, + "chainId": "0x01", + "groupHash": "0xb664b0d847df2cf7", + "genesis": { ... }, "pre": { ... } } ``` #### Pre-Allocation Group Fields -- **`test_count`**: Number of tests in this pre-allocation group -- **`pre_account_count`**: Number of accounts in the pre-allocation group +- **`testCount`**: Number of tests in this pre-allocation group +- **`preAccountCount`**: Number of accounts in the pre-allocation group - **`testIds`**: Array of test identifiers that belong to this group - **`network`**: Fork name (e.g., "Prague", "Cancun") -- **`environment`**: Complete [`Environment`](./common_types.md#environment) object with execution context +- **`chainId`**: Chain id the group's genesis is configured for +- **`groupHash`**: The group's own hash; matches the file name and the [`preHash`](#-prehash-string) of every test in the group +- **`groupSalt`**: Optional isolation salt; only present for groups that were explicitly isolated +- **`genesis`**: Genesis block header ([`FixtureHeader`](./blockchain_test.md#fixtureheader)) shared by every test in the group, derived from the environment the group was keyed on; its state root matches the state root of `pre` - **`pre`**: Pre-allocation group [`Alloc`](./common_types.md#alloc-mappingaddressaccount) object containing initial account states ## Consumption @@ -53,13 +58,12 @@ For each [`BlockchainTestEngineXFixture`](#blockchaintestenginexfixture) test ob 1. **Load Pre-Allocation Group**: - Read the appropriate file from the `pre_alloc` folder in the same directory - Locate the pre-allocation group using [`preHash`](#-prehash-string) - - Extract the `pre` allocation and `environment` from the group + - Extract the `pre` allocation and `genesis` header from the group 2. **Initialize Client**: - Use [`network`](#-network-fork) to configure the execution fork schedule - Use the pre-allocation group's `pre` allocation as the starting state - - Use the pre-allocation group's `environment` as the execution context - - Use [`genesisBlockHeader`](#-genesisblockheader-fixtureheader) as the genesis block header + - Use the pre-allocation group's `genesis` as the genesis block header 3. **Execute Engine API Sequence**: - For each [`FixtureEngineNewPayload`](#fixtureenginenewpayload) in [`engineNewPayloads`](#-enginenewpayloads-listfixtureenginenewpayload): @@ -69,10 +73,8 @@ For each [`BlockchainTestEngineXFixture`](#blockchaintestenginexfixture) test ob 4. **Verify Final State**: - Compare the final chain head against [`lastblockhash`](#-lastblockhash-hash) - - If [`postStateDiff`](#-poststatediff-optionalalloc) is present: - - Apply the state differences to the pre-allocation group - - Verify the resulting state matches the client's final state - - If `post` field were present (not typical), verify it directly + - Apply [`postStateDiff`](#-poststatediff-alloc) to the pre-allocation group + - Verify the resulting state matches the client's final state ## Structures @@ -88,11 +90,7 @@ This field is going to be replaced by the value contained in `config.network`. #### - `preHash`: `string` -Hash identifier referencing a pre-allocation group in the `pre_alloc` folder. This hash uniquely identifies the combination of fork, environment, and pre-allocation state that defines the group. - -#### - `genesisBlockHeader`: [`FixtureHeader`](./blockchain_test.md#fixtureheader) - -Genesis block header. The state root in this header must match the state root calculated from the pre-allocation group referenced by [`preHash`](#-prehash-string). +Hash identifier referencing a pre-allocation group in the `pre_alloc` folder. This hash uniquely identifies the combination of fork, environment, and pre-allocation state that defines the group. It is `0x`-prefixed, 8 bytes wide, and matches both the group file's name and its `groupHash` field. #### - `engineNewPayloads`: [`List`](./common_types.md#list)`[`[`FixtureEngineNewPayload`](#fixtureenginenewpayload)`]` @@ -106,7 +104,7 @@ Optional synchronization payload. When present, this payload is typically used t Hash of the last valid block after all payloads have been processed, or the genesis block hash if all payloads are invalid. -#### - `postStateDiff`: [`Optional`](./common_types.md#optional)`[`[`Alloc`](./common_types.md#alloc-mappingaddressaccount)`]` +#### - `postStateDiff`: [`Alloc`](./common_types.md#alloc-mappingaddressaccount) State differences from the pre-allocation group after test execution. This optimization stores only the accounts that changed, were created, or were deleted during test execution, rather than the complete final state. diff --git a/packages/testing/src/execution_testing/cli/compare_fixtures.py b/packages/testing/src/execution_testing/cli/compare_fixtures.py index af288bb7062..02aa1b9e0ec 100644 --- a/packages/testing/src/execution_testing/cli/compare_fixtures.py +++ b/packages/testing/src/execution_testing/cli/compare_fixtures.py @@ -15,7 +15,7 @@ import click -from execution_testing.base_types import HexNumber +from execution_testing.base_types import Hash from execution_testing.fixtures.consume import ( IndexFile, TestCaseIndexFile, @@ -36,7 +36,7 @@ def load_index(folder: Path) -> IndexFile: return IndexFile.model_validate_json(index_path.read_text()) -def get_fixture_hashes(index: IndexFile) -> Set[HexNumber]: +def get_fixture_hashes(index: IndexFile) -> Set[Hash]: """Extract fixture hashes and their corresponding file paths from index.""" hash_set = set() @@ -49,14 +49,14 @@ def get_fixture_hashes(index: IndexFile) -> Set[HexNumber]: def find_duplicates( - base_hashes: Set[HexNumber], patch_hashes: Set[HexNumber] -) -> Set[HexNumber]: + base_hashes: Set[Hash], patch_hashes: Set[Hash] +) -> Set[Hash]: """Find fixture hashes that exist in both base and patch.""" return base_hashes & patch_hashes def pop_all_by_hash( - index: IndexFile, fixture_hash: HexNumber + index: IndexFile, fixture_hash: Hash ) -> List[TestCaseIndexFile]: """Pops all test cases from an index file by their hash.""" test_cases = [] diff --git a/packages/testing/src/execution_testing/cli/extract_config.py b/packages/testing/src/execution_testing/cli/extract_config.py index 4f7d31d538f..27d856ad5e2 100755 --- a/packages/testing/src/execution_testing/cli/extract_config.py +++ b/packages/testing/src/execution_testing/cli/extract_config.py @@ -36,7 +36,7 @@ ) from execution_testing.fixtures.blockchain import FixtureHeader from execution_testing.fixtures.file import Fixtures -from execution_testing.fixtures.pre_alloc_groups import PreAllocGroupBuilder +from execution_testing.fixtures.pre_alloc_groups import PreAllocGroup from execution_testing.forks import Fork @@ -177,8 +177,7 @@ def from_fixture(cls, fixture_path: Path) -> Self: try: # Load as builder format and compute genesis on-demand - builder = PreAllocGroupBuilder.model_validate_json(fixture_bytes) - pre_alloc_group = builder.build() + pre_alloc_group = PreAllocGroup.model_validate_json(fixture_bytes) return cls( header=pre_alloc_group.genesis, alloc=pre_alloc_group.pre, diff --git a/packages/testing/src/execution_testing/cli/gen_index.py b/packages/testing/src/execution_testing/cli/gen_index.py index c8551fc0674..9dcfcd4561e 100644 --- a/packages/testing/src/execution_testing/cli/gen_index.py +++ b/packages/testing/src/execution_testing/cli/gen_index.py @@ -19,7 +19,6 @@ TimeElapsedColumn, ) -from execution_testing.base_types import HexNumber from execution_testing.fixtures.consume import ( IndexFile, TestCaseIndexFile, @@ -113,16 +112,14 @@ def generate_fixtures_index( try: root_hash = HashableItem.from_folder(folder_path=input_path).hash() except (KeyError, TypeError): - root_hash = b"" # just regenerate a new index file + root_hash = None if not force_flag and output_file.exists(): index_data: IndexFile try: with open(output_file, "r") as f: index_data = IndexFile(**json.load(f)) - if index_data.root_hash and index_data.root_hash == HexNumber( - root_hash - ): + if index_data.root_hash and index_data.root_hash == root_hash: if not quiet_mode: rich.print( f"Index file [bold cyan]{output_file}[/] " @@ -275,8 +272,6 @@ def merge_partial_indexes(output_dir: Path, quiet_mode: bool = False) -> None: # Insert directly into trie for hash computation fixture_hash = entry.get("fixture_hash") - if not fixture_hash: - continue path_parts = Path(entry["json_path"]).parts current = root_trie diff --git a/packages/testing/src/execution_testing/cli/hasher.py b/packages/testing/src/execution_testing/cli/hasher.py index 80e37eb8fad..38db24339f0 100644 --- a/packages/testing/src/execution_testing/cli/hasher.py +++ b/packages/testing/src/execution_testing/cli/hasher.py @@ -14,6 +14,9 @@ from rich.console import Console from rich.markup import escape as rich_escape +from execution_testing.base_types import Hash +from execution_testing.fixtures.pre_alloc_groups import PreAllocGroup + if TYPE_CHECKING: from execution_testing.fixtures.consume import TestCaseIndexFile @@ -35,10 +38,10 @@ class HashableItem: type: HashableItemType parents: List[str] = field(default_factory=list) - root: Optional[bytes] = None + root: Optional[Hash] = None items: Optional[Dict[str, "HashableItem"]] = None - def hash(self) -> bytes: + def hash(self) -> Hash: """Return the hash of the item.""" if self.root is not None: return self.root @@ -46,7 +49,7 @@ def hash(self) -> bytes: raise ValueError("No items to hash") # Use list + join instead of += to avoid O(n²) byte concatenation hash_parts = [item.hash() for _, item in sorted(self.items.items())] - return hashlib.sha256(b"".join(hash_parts)).digest() + return Hash(hashlib.sha256(b"".join(hash_parts)).digest()) def format_lines( self, @@ -67,7 +70,7 @@ def format_lines( if print_type is None or self.type >= print_type: next_level += 1 - lines.append(f"{' ' * level}{print_name}: 0x{self.hash().hex()}") + lines.append(f"{' ' * level}{print_name}: {self.hash().hex()}") # Stop recursion if we've reached max_depth if max_depth is not None and next_level > max_depth: @@ -92,35 +95,39 @@ def from_json_file( ) -> "HashableItem": """Create a hashable item from a JSON file.""" items = {} - with file_path.open("r") as f: - data = json.load(f) + # Pre-alloc group files live under a "pre_alloc" folder + if file_path.parent.name == "pre_alloc": + return cls( + type=HashableItemType.FILE, + root=PreAllocGroup.from_file(file_path).hash(), + parents=parents + [file_path.name], + ) + file_text = file_path.read_text() + data = json.loads(file_text) for key, item in sorted(data.items()): if not isinstance(item, dict): - raise TypeError(f"Expected dict, got {type(item)} for {key}") + raise TypeError( + f"Expected dict, got {type(item)} for {key}, " + f"json file: {file_path.name}" + ) if "_info" not in item: raise KeyError( f"Expected '_info' in {key}, json file: {file_path.name}" ) # EEST uses 'hash'; ethereum/tests use 'generatedTestHash' - hash_value = item["_info"].get("hash") or item["_info"].get( + hash_str = item["_info"].get("hash") or item["_info"].get( "generatedTestHash" ) - if hash_value is None: + if hash_str is None: raise KeyError( f"Expected 'hash' or 'generatedTestHash' in {key}" ) + hash_value = Hash(hash_str) - if not isinstance(hash_value, str): - raise TypeError( - f"Expected hash to be a string in {key}, " - f"got {type(hash_value)}" - ) - - item_hash_bytes = bytes.fromhex(hash_value[2:]) items[key] = cls( type=HashableItemType.TEST, - root=item_hash_bytes, + root=hash_value, parents=parents + [file_path.name], ) return cls(type=HashableItemType.FILE, items=items, parents=parents) @@ -165,9 +172,7 @@ def from_index_entries( { "id": e.id, "json_path": str(e.json_path), - "fixture_hash": str(e.fixture_hash) - if e.fixture_hash - else None, + "fixture_hash": str(e.fixture_hash), } for e in entries ] @@ -193,9 +198,7 @@ def from_raw_entries(cls, entries: List[Dict]) -> "HashableItem": # Single pass: insert all entries into trie for entry in entries: - fixture_hash = entry.get("fixture_hash") - if not fixture_hash: - continue + fixture_hash = entry["fixture_hash"] # Navigate/create path to file node path_parts = Path(entry["json_path"]).parts @@ -259,7 +262,7 @@ def render_hash_report( """Return canonical output lines for a folder.""" item = HashableItem.from_folder(folder_path=folder) if root: - return [f"0x{item.hash().hex()}"] + return [item.hash().hex()] print_type: Optional[HashableItemType] = None if files: print_type = HashableItemType.FILE @@ -284,7 +287,7 @@ def collect_hashes( if print_type is None or item.type >= print_type: if path: - result[path] = f"0x{item.hash().hex()}" + result[path] = item.hash().hex() depth += 1 if max_depth is not None and depth > max_depth: return result @@ -462,8 +465,8 @@ def compare_cmd( if root: if left_item.hash() == right_item.hash(): sys.exit(0) - left_hashes = {"root": f"0x{left_item.hash().hex()}"} - right_hashes = {"root": f"0x{right_item.hash().hex()}"} + left_hashes = {"root": left_item.hash().hex()} + right_hashes = {"root": right_item.hash().hex()} else: print_type: Optional[HashableItemType] = None if files: diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/test_tracker.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/test_tracker.py index 21c4a35d22e..0afb898d946 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/test_tracker.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/test_tracker.py @@ -5,13 +5,15 @@ import pytest from pytest import StashKey +from execution_testing.test_types import AllocGroupHash + logger = logging.getLogger(__name__) # Typed stash keys for session-scoped data (replaces dynamic attributes) enginex_group_counts_key: StashKey[dict[str, int]] = StashKey() -def make_group_identifier(pre_hash: str, client_name: str) -> str: +def make_group_identifier(pre_hash: AllocGroupHash, client_name: str) -> str: """Build xdist group key from pre-alloc hash and client name.""" return f"{pre_hash}-{client_name}" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/multi_test_client.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/multi_test_client.py index c28bb60e085..f21336f1ee7 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/multi_test_client.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/multi_test_client.py @@ -8,8 +8,11 @@ from hive.client import Client from execution_testing.base_types import to_json -from execution_testing.fixtures import BlockchainEngineXFixture -from execution_testing.fixtures.pre_alloc_groups import PreAllocGroup +from execution_testing.fixtures import ( + BlockchainEngineXFixture, + PreAllocGroup, +) +from execution_testing.test_types import AllocGroupHash from ..consume import FixturesSource from .helpers.ruleset import ruleset @@ -142,19 +145,19 @@ def multi_test_client_manager() -> Generator[ @pytest.fixture(scope="session") -def pre_alloc_group_cache() -> dict[str, PreAllocGroup]: +def pre_alloc_group_cache() -> dict[AllocGroupHash, PreAllocGroup]: """Cache for pre-allocation groups to avoid reloading from disk.""" return {} @pytest.fixture(scope="session") -def client_genesis_cache() -> dict[str, dict]: +def client_genesis_cache() -> dict[AllocGroupHash, dict]: """Cache for client genesis configs to avoid redundant to_json calls.""" return {} @pytest.fixture(scope="session") -def environment_cache() -> dict[str, dict]: +def environment_cache() -> dict[AllocGroupHash, dict]: """Cache for environment configs to avoid redundant computation.""" return {} @@ -163,7 +166,7 @@ def environment_cache() -> dict[str, dict]: def pre_alloc_group( fixture: BlockchainEngineXFixture, fixtures_source: FixturesSource, - pre_alloc_group_cache: dict[str, PreAllocGroup], + pre_alloc_group_cache: dict[AllocGroupHash, PreAllocGroup], ) -> PreAllocGroup: """Load the pre-allocation group for the current test case.""" pre_hash = fixture.pre_hash @@ -210,7 +213,7 @@ def pre_alloc_group( def client_genesis( pre_alloc_group: PreAllocGroup, fixture: BlockchainEngineXFixture, - client_genesis_cache: dict[str, dict], + client_genesis_cache: dict[AllocGroupHash, dict], ) -> dict: """ Convert pre-alloc group genesis header and pre-state to client genesis. @@ -243,7 +246,7 @@ def environment( pre_alloc_group: PreAllocGroup, fixture: BlockchainEngineXFixture, check_live_port: int, - environment_cache: dict[str, dict], + environment_cache: dict[AllocGroupHash, dict], ) -> dict: """ Define environment variables for multi-test client startup. diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py index 9fa7ab22324..6d2fe4fae86 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py @@ -30,12 +30,7 @@ from filelock import FileLock from pytest_metadata.plugin import metadata_key -from execution_testing.base_types import ( - Account, - Address, - ReferenceSpec, -) -from execution_testing.base_types import Alloc as BaseAlloc +from execution_testing.base_types import ReferenceSpec from execution_testing.cli.gen_index import ( merge_partial_indexes, ) @@ -44,14 +39,12 @@ from execution_testing.fixtures import ( BaseFixture, BlockchainEngineFixture, - BlockchainEngineXFixture, BlockchainFixture, FixtureCollector, FixtureConsumer, FixtureFillingPhase, LabeledFixtureFormat, PreAllocGroup, - PreAllocGroupBuilder, PreAllocGroupBuilders, PreAllocGroups, StateFixture, @@ -64,7 +57,7 @@ verify_engine_x_execution, ) from execution_testing.fixtures.pre_alloc_groups import ( - GroupIndexEntry, + GroupIndexEntries, _get_worker_id, merge_partial_group_files, pack_pre_alloc_groups, @@ -78,7 +71,7 @@ ) from execution_testing.specs import BaseTest from execution_testing.specs.base import FillResult, OpMode -from execution_testing.test_types import EnvironmentDefaults +from execution_testing.test_types import AllocGroupHash, EnvironmentDefaults from execution_testing.test_types.chain_config_types import ( DEFAULT_CHAIN_ID, ChainConfigDefaults, @@ -170,7 +163,7 @@ class FillingSession: # of tests it holds, so it can no longer be recomputed per-test; a test # finds its group through the packed index file instead (see # read_test_group_index). - _test_group_index: Dict[str, GroupIndexEntry] | None = field( + _test_group_index: GroupIndexEntries | None = field( default=None, repr=False ) @@ -237,7 +230,7 @@ def __post_init__(self) -> None: match self.filling_phase: case FixtureFillingPhase.PRE_ALLOC_GENERATION: # Phase 1: Create empty container for collecting groups - self.pre_alloc_group_builders = PreAllocGroupBuilders(root={}) + self.pre_alloc_group_builders = PreAllocGroupBuilders() case FixtureFillingPhase.FILL_AFTER_PRE_ALLOC_GENERATION: # Phase 2: Load pre-alloc groups from disk pre_alloc_folder = ( @@ -272,12 +265,14 @@ def should_generate_format( """ return self.filling_phase in fixture_format.format_phases - def get_pre_alloc_group(self, hash_key: str) -> PreAllocGroup: + def get_pre_alloc_group( + self, pre_alloc_hash: AllocGroupHash + ) -> PreAllocGroup: """ Get a pre-allocation group by hash. Args: - hash_key: The hash of the pre-alloc group. + pre_alloc_hash: The hash of the pre-alloc group. Returns: The pre-allocation group. @@ -289,20 +284,23 @@ def get_pre_alloc_group(self, hash_key: str) -> PreAllocGroup: if self.pre_alloc_groups is None: raise ValueError("Pre-allocation groups not initialized") - if hash_key not in self.pre_alloc_groups: + if pre_alloc_hash not in self.pre_alloc_groups: pre_alloc_path = ( - self.fixture_output.pre_alloc_groups_folder_path / hash_key + self.fixture_output.pre_alloc_groups_folder_path + / f"{pre_alloc_hash}.json" ) raise ValueError( - f"Pre-allocation hash {hash_key} not found in " + f"Pre-allocation hash {pre_alloc_hash} not found in " f"pre-allocation groups. Please check the file at: " f"{pre_alloc_path}. Make sure phase 1 " "(--generate-pre-alloc-groups) was run before phase 2." ) - return self.pre_alloc_groups[hash_key] + return self.pre_alloc_groups[pre_alloc_hash] - def group_hash_for_test(self, test_id: str, phase1_hash: str) -> str: + def group_hash_for_test( + self, test_id: str, phase1_hash: AllocGroupHash + ) -> AllocGroupHash: """ Return the packed pre-alloc group hash that owns ``test_id``. @@ -386,57 +384,6 @@ def from_dict(cls, data: Dict[str, int]) -> "TransitionToolCacheStats": ) -def calculate_post_state_diff( - post_state: BaseAlloc, genesis_state: BaseAlloc -) -> BaseAlloc: - """ - Calculate the state difference between post_state and genesis_state. - - This function enables significant space savings in Engine X fixtures by - storing only the accounts that changed during test execution, rather than - the full post-state which may contain thousands of unchanged accounts. - - Returns an Alloc containing only the accounts that: - - Changed between genesis and post state (balance, nonce, storage, code) - - Were created during test execution (new accounts) - - Were deleted during test execution (represented as None) - - Args: - post_state: Final state after test execution - genesis_state: Genesis pre-allocation state - - Returns: - Alloc containing only the state differences for efficient storage - - """ - diff: Dict[Address, Account | None] = {} - - # Find all addresses that exist in either state - all_addresses = set(post_state.root.keys()) | set( - genesis_state.root.keys() - ) - - for address in all_addresses: - genesis_account = genesis_state.root.get(address) - post_account = post_state.root.get(address) - - # Account was deleted (exists in genesis but not in post) - if genesis_account is not None and post_account is None: - diff[address] = None - - # Account was created (doesn't exist in genesis but exists in post) - elif genesis_account is None and post_account is not None: - diff[address] = post_account - - # Account was modified (exists in both but different) - elif genesis_account != post_account: - diff[address] = post_account - - # Account unchanged - don't include in diff - - return BaseAlloc(diff) - - def default_output_directory() -> str: """ Directory (default) to store the generated test fixtures. Defined as a @@ -632,6 +579,17 @@ def pytest_addoption(parser: pytest.Parser) -> None: "groups, phase 2 generates all supported fixture formats." ), ) + test_group.addoption( + "--disable-optimistic-pre-alloc-grouping", + action="store_true", + dest="optimistic_pre_alloc_grouping_disabled", + default=False, + help=( + "Disable optimistic grouping that uses heuristics to attempt to " + "predict tests that reuse the same addresses, but still try to " + "group them in order to reduce the group count." + ), + ) optimize_gas_group = parser.getgroup( "optimize gas", @@ -959,13 +917,11 @@ def pytest_terminal_summary( ) group_files = list(pre_alloc_folder.glob("*.json")) total_groups = len(group_files) - # Count accounts by loading as builder (no genesis computation) + # Count accounts from the final pre-alloc group files total_accounts = 0 for group_file in group_files: - builder = PreAllocGroupBuilder.model_validate_json( - group_file.read_text() - ) - total_accounts += builder.get_pre_account_count() + pre_alloc_group = PreAllocGroup.from_file(group_file) + total_accounts += pre_alloc_group.pre_account_count else: assert session_instance.pre_alloc_group_builders is not None total_groups = len( @@ -1519,6 +1475,14 @@ def commit_hash_or_tag() -> str: return get_current_commit_hash_or_tag() +@pytest.fixture(scope="session") +def optimistic_pre_alloc_grouping_disabled( + request: pytest.FixtureRequest, +) -> bool: + """Whether optimistic pre-allocation grouping is disabled or not.""" + return request.config.getoption("optimistic_pre_alloc_grouping_disabled") + + @pytest.fixture(scope="function") def fixture_source_url( request: pytest.FixtureRequest, @@ -1580,6 +1544,7 @@ def base_test_parametrizer_func( fixture_source_url: str, gas_benchmark_value: int, fixed_opcode_count: int | None, + optimistic_pre_alloc_grouping_disabled: bool, is_tx_gas_heavy_test: bool, is_exception_test: bool, is_inclusion_test: bool, @@ -1657,8 +1622,9 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: request.node.nodeid ) - pre_alloc_hash: str | None = None + pre_alloc_hash: AllocGroupHash | None = None # Phase 1: Generate pre-allocation groups + test_id = _strip_xdist_group_suffix(request.node.nodeid) if ( session.filling_phase == FixtureFillingPhase.PRE_ALLOC_GENERATION @@ -1666,7 +1632,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: # Use the original update_pre_alloc_groups method which # returns the groups assert session.pre_alloc_group_builders is not None - test_id = _strip_xdist_group_suffix(request.node.nodeid) genesis_environment = self.get_genesis_environment() pre_alloc_hash = pre.compute_pre_alloc_group_hash( fork=fork, @@ -1690,21 +1655,21 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: FixtureFillingPhase.PRE_ALLOC_GENERATION in fixture_format.format_phases ): - # Groups are packed after phase 1, so a test's group hash - # can no longer be recomputed from its own pre; look it up - # by test id instead, fingerprinted by the recomputed - # phase 1 hash so a stale group folder fails loudly. - test_id = _strip_xdist_group_suffix(request.node.nodeid) - pre_alloc_hash = session.group_hash_for_test( - test_id, - phase1_hash=pre.compute_pre_alloc_group_hash( - fork=fork, - genesis_environment=( - self.get_genesis_environment() - ), - group_salt=group_salt, - ), + pre_alloc_hash = pre.compute_pre_alloc_group_hash( + fork=fork, + genesis_environment=self.get_genesis_environment(), + group_salt=group_salt, ) + if not optimistic_pre_alloc_grouping_disabled: + # Groups are packed after phase 1, so a test's group + # hash can no longer be recomputed from its own pre; + # look it up by test id instead, fingerprinted by the + # recomputed phase 1 hash so a stale group folder fails + # loudly. + pre_alloc_hash = session.group_hash_for_test( + test_id, + phase1_hash=pre_alloc_hash, + ) group = session.get_pre_alloc_group(pre_alloc_hash) self.pre = group.pre fill_result: FillResult | None = None @@ -1747,28 +1712,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: gas_benchmark_value=gas_benchmark_value, ) - # Post-process for Engine X format (add pre_hash and state - # diff) - if ( - FixtureFillingPhase.PRE_ALLOC_GENERATION - in fixture_format.format_phases - and pre_alloc_hash is not None - ): - # TODO: This should be handled by the `generate` method - # of the spec. - assert isinstance(fixture, BlockchainEngineXFixture) - fixture.pre_hash = pre_alloc_hash - - # Calculate state diff for efficiency - if ( - hasattr(fixture, "post_state") - and fixture.post_state is not None - ): - group = session.get_pre_alloc_group(pre_alloc_hash) - fixture.post_state_diff = calculate_post_state_diff( - fixture.post_state, group.pre - ) - fill_metadata: Dict[str, Any] = {} if t8n.opcode_count is not None: fill_metadata["opcode_count"] = ( @@ -2127,18 +2070,26 @@ def _log_timing(msg: str) -> None: if not is_worker: _log_timing("Phase 1 (master): merging partial group files...") t0 = time.time() + optimistic_pre_alloc_grouping_disabled = session.config.getoption( + "optimistic_pre_alloc_grouping_disabled" + ) + assert isinstance(optimistic_pre_alloc_grouping_disabled, bool) pre_alloc_folder = fixture_output.pre_alloc_groups_folder_path - merge_partial_group_files(pre_alloc_folder) - _log_timing( - f"Phase 1 (master): merge done in {time.time() - t0:.1f}s" + merge_partial_group_files( + pre_alloc_folder, final=optimistic_pre_alloc_grouping_disabled ) - # Pack the fine-grained groups into fewer, larger ones so Engine X - # boots one client for many tests instead of one per test. - t0 = time.time() - pack_pre_alloc_groups(pre_alloc_folder) _log_timing( - f"Phase 1 (master): pack done in {time.time() - t0:.1f}s" + f"Phase 1 (master): merge done in {time.time() - t0:.1f}s" ) + if not optimistic_pre_alloc_grouping_disabled: + # Pack the fine-grained groups into fewer, larger ones so + # Engine X boots one client for many tests instead of one per + # test. + t0 = time.time() + pack_pre_alloc_groups(pre_alloc_folder) + _log_timing( + f"Phase 1 (master): pack done in {time.time() - t0:.1f}s" + ) else: # Workers: clear in-memory state to reduce memory pressure while # waiting for other workers to finish @@ -2200,37 +2151,40 @@ def _log_timing(msg: str) -> None: file.unlink() _log_timing(f"Lock files removed in {time.time() - t0:.1f}s") - # Loudly fail the fill if pre-alloc group packing changed any Engine X - # test's execution (raises on drift, like a pre-alloc collision). - _log_timing("verify_engine_x_execution: starting...") - t0 = time.time() - engine_x_check = verify_engine_x_execution(fixture_output.directory) - engine_x_warning: str | None = None - if engine_x_check is not None: - if engine_x_check.compared > 0: - logger.info(engine_x_check.summary) - elif engine_x_check.skipped > 0: + if not session.config.getoption("optimistic_pre_alloc_grouping_disabled"): + # Loudly fail the fill if pre-alloc group packing changed any Engine X + # test's execution (raises on drift, like a pre-alloc collision). + _log_timing("verify_engine_x_execution: starting...") + t0 = time.time() + engine_x_check = verify_engine_x_execution(fixture_output.directory) + engine_x_warning: str | None = None + if engine_x_check is not None: + if engine_x_check.compared > 0: + logger.info(engine_x_check.summary) + elif engine_x_check.skipped > 0: + engine_x_warning = ( + "Engine X execution consistency check skipped: none of " + f"the {engine_x_check.skipped} Engine X fixtures have a " + "blockchain_tests_engine sibling fixture to compare " + "against. Leaks from pre-alloc group packing are not " + "verified for this output." + ) + elif (fixture_output.directory / ENGINE_X_FIXTURES_DIR).is_dir(): engine_x_warning = ( - "Engine X execution consistency check skipped: none of " - f"the {engine_x_check.skipped} Engine X fixtures have a " - "blockchain_tests_engine sibling fixture to compare " - "against. Leaks from pre-alloc group packing are not " - "verified for this output." + "Engine X execution consistency check skipped: this fill " + "generated no blockchain_tests_engine fixtures to compare " + "against (e.g. filling with `-m blockchain_test_engine_x`). " + "Leaks from pre-alloc group packing are not verified for this " + "output." ) - elif (fixture_output.directory / ENGINE_X_FIXTURES_DIR).is_dir(): - engine_x_warning = ( - "Engine X execution consistency check skipped: this fill " - "generated no blockchain_tests_engine fixtures to compare " - "against (e.g. filling with `-m blockchain_test_engine_x`). " - "Leaks from pre-alloc group packing are not verified for this " - "output." + if engine_x_warning is not None: + logger.warning(engine_x_warning) + # Repeated in the terminal summary; a log line alone is easy to + # miss. + session.config.engine_x_check_warning = engine_x_warning # type: ignore[attr-defined] # noqa: E501 + _log_timing( + f"verify_engine_x_execution: done in {time.time() - t0:.1f}s" ) - if engine_x_warning is not None: - logger.warning(engine_x_warning) - # Repeated in the terminal summary; a log line alone is easy to - # miss. - session.config.engine_x_check_warning = engine_x_warning # type: ignore[attr-defined] # noqa: E501 - _log_timing(f"verify_engine_x_execution: done in {time.time() - t0:.1f}s") # Verify fixtures after merge if verification is enabled if session.config.getoption("verify_fixtures"): diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/pre_alloc.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/pre_alloc.py index 9b7fc22f338..f6408ed130a 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/pre_alloc.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/pre_alloc.py @@ -1,6 +1,5 @@ """Pre-alloc specifically conditioned for test filling.""" -import hashlib import inspect from functools import cache from hashlib import sha256 @@ -30,6 +29,7 @@ DETERMINISTIC_FACTORY_ADDRESS, DETERMINISTIC_FACTORY_BYTECODE, EOA, + AllocGroupHash, Environment, compute_deterministic_create2_address, contract_address_from_hash, @@ -99,7 +99,7 @@ def code_pre_processor(self, code: BytesConvertible) -> BytesConvertible: """Pre-processes the code before setting it.""" return code - def modified_accounts_salt(self) -> int: + def modified_accounts_salt(self) -> AllocGroupHash: """ Return a salt if this pre-allocation was affected by setting addresses to hard-coded accounts or has pre-funded addresses. @@ -113,7 +113,7 @@ def modified_accounts_salt(self) -> int: and not self._hardcoded_addresses_deployed_to and not self._deleted_addresses ): - return 0 + return AllocGroupHash(0) # Build a hashable buffer from the modified accounts. buffer = b"" @@ -134,9 +134,7 @@ def modified_accounts_salt(self) -> int: for deleted_address in sorted(self._deleted_addresses): buffer += deleted_address - return int.from_bytes( - hashlib.sha256(buffer).digest()[:8], byteorder="big" - ) + return AllocGroupHash.from_preimage(buffer) def compute_pre_alloc_group_hash( self, @@ -144,24 +142,24 @@ def compute_pre_alloc_group_hash( fork: Fork | TransitionFork, genesis_environment: Environment, group_salt: str | None, - ) -> str: + ) -> AllocGroupHash: """Hash (fork, env) in order to group tests by genesis config.""" - fork_digest = hashlib.sha256(fork.name().encode("utf-8")).digest() - fork_hash = int.from_bytes(fork_digest[:8], byteorder="big") combined_hash = ( - fork_hash - ^ hash(genesis_environment) + AllocGroupHash.from_preimage(fork.name()) + ^ AllocGroupHash.from_preimage( + genesis_environment.canonical_json() + ) ^ self.modified_accounts_salt() ) # Check if this pre-allocation has a group salt if group_salt: # Add custom salt to hash - salt_hash = hashlib.sha256(group_salt.encode("utf-8")).digest() - salt_int = int.from_bytes(salt_hash[:8], byteorder="big") - combined_hash = combined_hash ^ salt_int + combined_hash = combined_hash ^ AllocGroupHash.from_preimage( + group_salt + ) - return f"0x{combined_hash:016x}" + return AllocGroupHash(combined_hash) def _deterministic_deploy_contract( self, diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filling_session.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filling_session.py index beb9777ac5a..04975cd5a7e 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filling_session.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filling_session.py @@ -14,7 +14,7 @@ PreAllocGroups, ) from execution_testing.forks import Prague -from execution_testing.test_types import Environment +from execution_testing.test_types import AllocGroupHash, Environment from ..filler import FillingSession @@ -84,15 +84,17 @@ def test_init_use_pre_alloc(self) -> None: config = MockConfig(use_pre_alloc_groups=True) # Mock the file system operations + group_hash = AllocGroupHash.from_preimage("test_hash") test_group_builder = PreAllocGroupBuilder( pre=Alloc().model_dump(mode="json"), environment=Environment() .set_fork_requirements(Prague) .model_dump(mode="json", exclude={"parent_hash"}), fork=Prague.name(), + group_hash=group_hash, ) test_group = test_group_builder.build() - mock_groups = PreAllocGroups(root={"test_hash": test_group}) + mock_groups = PreAllocGroups(root={group_hash: test_group}) with patch( "execution_testing.cli.pytest_commands.plugins.filler.filler.FixtureOutput", @@ -152,7 +154,7 @@ def test_should_generate_format_with_generate_all(self) -> None: generate_all_formats=True, use_pre_alloc_groups=True ) - mock_groups = PreAllocGroups(root={}) + mock_groups = PreAllocGroups() with patch( "execution_testing.cli.pytest_commands.plugins.filler.filler.FixtureOutput", @@ -178,15 +180,17 @@ def test_get_pre_alloc_group(self) -> None: """Test getting a pre-alloc group by hash.""" config = MockConfig(use_pre_alloc_groups=True) + group_hash = AllocGroupHash.from_preimage("test_hash") test_group_builder = PreAllocGroupBuilder( pre=Alloc().model_dump(mode="json"), environment=Environment() .set_fork_requirements(Prague) .model_dump(mode="json", exclude={"parent_hash"}), fork=Prague.name(), + group_hash=group_hash, ) test_group = test_group_builder.build() - mock_groups = PreAllocGroups(root={"test_hash": test_group}) + mock_groups = PreAllocGroups(root={group_hash: test_group}) with patch( "execution_testing.cli.pytest_commands.plugins.filler.filler.FixtureOutput", @@ -198,13 +202,18 @@ def test_get_pre_alloc_group(self) -> None: ): session = FillingSession.from_config(config) # type: ignore[arg-type] - assert session.get_pre_alloc_group("test_hash") is test_group + assert ( + session.get_pre_alloc_group( + AllocGroupHash.from_preimage("test_hash") + ) + is test_group + ) def test_get_pre_alloc_group_not_found(self) -> None: """Test getting a non-existent pre-alloc group.""" config = MockConfig(use_pre_alloc_groups=True) - mock_groups = PreAllocGroups(root={}) + mock_groups = PreAllocGroups() with patch( "execution_testing.cli.pytest_commands.plugins.filler.filler.FixtureOutput", @@ -219,7 +228,9 @@ def test_get_pre_alloc_group_not_found(self) -> None: with pytest.raises( ValueError, match="Pre-allocation hash .* not found" ): - session.get_pre_alloc_group("missing_hash") + session.get_pre_alloc_group( + AllocGroupHash.from_preimage("missing_hash") + ) def test_get_pre_alloc_group_not_initialized(self) -> None: """Test getting pre-alloc group when not initialized.""" @@ -234,7 +245,9 @@ def test_get_pre_alloc_group_not_initialized(self) -> None: with pytest.raises( ValueError, match="Pre-allocation groups not initialized" ): - session.get_pre_alloc_group("any_hash") + session.get_pre_alloc_group( + AllocGroupHash.from_preimage("any_hash") + ) def test_save_pre_alloc_groups(self) -> None: """Test saving pre-alloc groups to disk.""" @@ -251,13 +264,15 @@ def test_save_pre_alloc_groups(self) -> None: # here we only need a non-empty mapping for save_pre_alloc_groups # to exercise its mkdir/to_folder path. assert session.pre_alloc_group_builders is not None - session.pre_alloc_group_builders.root["test_hash"] = ( + group_hash = AllocGroupHash.from_preimage("test_hash") + session.pre_alloc_group_builders.root[group_hash] = ( PreAllocGroupBuilder( pre=Alloc().model_dump(mode="json"), environment=Environment() .set_fork_requirements(Prague) .model_dump(mode="json", exclude={"parent_hash"}), fork=Prague.name(), + group_hash=group_hash, ) ) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_prealloc_group.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_prealloc_group.py index cf4026269ed..cbd26ff955c 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_prealloc_group.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_prealloc_group.py @@ -687,71 +687,8 @@ def test_pre_alloc_grouping_by_test_type( for group_hash, group in groups.items(): error_message += f"\n{group_hash}: \n" error_message += f"tests: {group.test_ids}\n" - env_json = group.environment.model_dump_json( + genesis_json = group.genesis.model_dump_json( indent=2, exclude_none=True ) - error_message += f"env: {env_json}\n" + error_message += f"genesis: {genesis_json}\n" raise AssertionError(error_message) - - for group_hash, group in groups.items(): - assert ( - group.environment.fee_recipient == group.genesis.fee_recipient - ), ( - f"Fee recipient mismatch for group {group_hash}: " - f"{group.environment.fee_recipient} != " - f"{group.genesis.fee_recipient}" - ) - assert group.environment.prev_randao == group.genesis.prev_randao, ( - f"Prev randao mismatch for group {group_hash}: " - f"{group.environment.prev_randao} != {group.genesis.prev_randao}" - ) - assert group.environment.extra_data == group.genesis.extra_data, ( - f"Extra data mismatch for group {group_hash}: " - f"{group.environment.extra_data} != {group.genesis.extra_data}" - ) - assert group.environment.number == group.genesis.number, ( - f"Number mismatch for group {group_hash}: " - f"{group.environment.number} != {group.genesis.number}" - ) - assert group.environment.timestamp == group.genesis.timestamp, ( - f"Timestamp mismatch for group {group_hash}: " - f"{group.environment.timestamp} != {group.genesis.timestamp}" - ) - assert group.environment.difficulty == group.genesis.difficulty, ( - f"Difficulty mismatch for group {group_hash}: " - f"{group.environment.difficulty} != {group.genesis.difficulty}" - ) - assert group.environment.gas_limit == group.genesis.gas_limit, ( - f"Gas limit mismatch for group {group_hash}: " - f"{group.environment.gas_limit} != {group.genesis.gas_limit}" - ) - assert ( - group.environment.base_fee_per_gas - == group.genesis.base_fee_per_gas - ), ( - f"Base fee per gas mismatch for group {group_hash}: " - f"{group.environment.base_fee_per_gas} != " - f"{group.genesis.base_fee_per_gas}" - ) - assert ( - group.environment.excess_blob_gas == group.genesis.excess_blob_gas - ), ( - f"Excess blob gas mismatch for group {group_hash}: " - f"{group.environment.excess_blob_gas} != " - f"{group.genesis.excess_blob_gas}" - ) - assert ( - group.environment.blob_gas_used == group.genesis.blob_gas_used - ), ( - f"Blob gas used mismatch for group {group_hash}: " - f"{group.environment.blob_gas_used} != " - f"{group.genesis.blob_gas_used}" - ) - assert ( - group.environment.parent_beacon_block_root - == group.genesis.parent_beacon_block_root - ), ( - f"Parent beacon block root mismatch for group {group_hash}: " - f"{group.environment.parent_beacon_block_root} != " - f"{group.genesis.parent_beacon_block_root}" - ) diff --git a/packages/testing/src/execution_testing/cli/show_pre_alloc_group_stats.py b/packages/testing/src/execution_testing/cli/show_pre_alloc_group_stats.py index 2ec85527c1f..c801d61f313 100644 --- a/packages/testing/src/execution_testing/cli/show_pre_alloc_group_stats.py +++ b/packages/testing/src/execution_testing/cli/show_pre_alloc_group_stats.py @@ -159,7 +159,7 @@ def analyze_pre_alloc_folder(folder: Path) -> Dict: for hash_key, group in pre_alloc_groups.items(): group_details.append( { - "hash": hash_key[:8] + "...", # Shortened hash for display + "hash": str(hash_key), "tests": group.test_count, "accounts": group.pre_account_count, "fork": group.fork.name(), diff --git a/packages/testing/src/execution_testing/cli/tests/test_extract_config.py b/packages/testing/src/execution_testing/cli/tests/test_extract_config.py index c150f43688c..5deda5dcfc5 100644 --- a/packages/testing/src/execution_testing/cli/tests/test_extract_config.py +++ b/packages/testing/src/execution_testing/cli/tests/test_extract_config.py @@ -6,14 +6,14 @@ from execution_testing.base_types import Alloc from execution_testing.cli.extract_config import GenesisState -from execution_testing.fixtures.pre_alloc_groups import PreAllocGroupBuilder +from execution_testing.fixtures.blockchain import FixtureHeader +from execution_testing.fixtures.pre_alloc_groups import PreAllocGroup from execution_testing.forks import ( Fork, Prague, forks_from_until, get_deployed_forks, ) -from execution_testing.test_types import Environment def forks_from_prague_onward() -> list[Fork]: @@ -28,18 +28,35 @@ def test_genesis_state_from_pre_alloc_group_uses_stored_chain_id( fork: Fork, ) -> None: """Pre-alloc group files should preserve the configured chain ID.""" - builder = PreAllocGroupBuilder( + group = PreAllocGroup( test_ids=["test_id"], - environment=Environment() - .set_fork_requirements(fork) - .model_dump(mode="json", exclude={"parent_hash"}), fork=fork.name(), chain_id=12345, + genesis=FixtureHeader( + parent_hash=0, + ommers_hash=1, + fee_recipient=2, + state_root=3, + transactions_trie=4, + receipts_root=5, + logs_bloom=6, + difficulty=7, + number=8, + gas_limit=9, + gas_used=10, + timestamp=11, + extra_data=b"", + prev_randao=13, + nonce=14, + ), pre=Alloc().model_dump(mode="json"), + group_hash=1, + pre_account_count=1, + test_count=1, ) fixture_path = tmp_path / "pre_alloc.json" fixture_path.write_text( - builder.model_dump_json(by_alias=True, exclude_none=True, indent=2) + group.model_dump_json(by_alias=True, exclude_none=True, indent=2) ) genesis_state = GenesisState.from_fixture(fixture_path) @@ -54,16 +71,33 @@ def test_genesis_state_from_legacy_pre_alloc_group_defaults_chain_id( fork: Fork, ) -> None: """Legacy pre-alloc groups without chain ID should still default to 1.""" - builder = PreAllocGroupBuilder( + group = PreAllocGroup( test_ids=["test_id"], - environment=Environment() - .set_fork_requirements(fork) - .model_dump(mode="json", exclude={"parent_hash"}), fork=fork.name(), + genesis=FixtureHeader( + parent_hash=0, + ommers_hash=1, + fee_recipient=2, + state_root=3, + transactions_trie=4, + receipts_root=5, + logs_bloom=6, + difficulty=7, + number=8, + gas_limit=9, + gas_used=10, + timestamp=11, + extra_data=b"", + prev_randao=13, + nonce=14, + ), pre=Alloc().model_dump(mode="json"), + group_hash=1, + pre_account_count=1, + test_count=1, ) fixture_path = tmp_path / "legacy_pre_alloc.json" - fixture_path.write_text(builder.model_dump_json(exclude={"chain_id"})) + fixture_path.write_text(group.model_dump_json(exclude={"chain_id"})) genesis_state = GenesisState.from_fixture(fixture_path) diff --git a/packages/testing/src/execution_testing/cli/tests/test_hasher.py b/packages/testing/src/execution_testing/cli/tests/test_hasher.py index 1c2c2fb3319..88976351287 100644 --- a/packages/testing/src/execution_testing/cli/tests/test_hasher.py +++ b/packages/testing/src/execution_testing/cli/tests/test_hasher.py @@ -8,10 +8,13 @@ import pytest from click.testing import CliRunner -from execution_testing.base_types import HexNumber +from execution_testing.base_types import Account, Address, Hash from execution_testing.cli.gen_index import merge_partial_indexes from execution_testing.cli.hasher import HashableItem, hasher from execution_testing.fixtures.consume import IndexFile, TestCaseIndexFile +from execution_testing.fixtures.pre_alloc_groups import PreAllocGroupBuilder +from execution_testing.forks import Fork, Prague +from execution_testing.test_types import Alloc, Environment HASH_1 = 0x1111111111111111111111111111111111111111111111111111111111111111 HASH_2 = 0x2222222222222222222222222222222222222222222222222222222222222222 @@ -20,11 +23,6 @@ HASH_9 = 0x9999999999999999999999999999999999999999999999999999999999999999 -def _hex_str(h: int) -> str: - """Convert an integer hash to its 0x-prefixed hex string.""" - return f"0x{h:064x}" - - def _make_entry( test_id: str, json_path: str, @@ -36,7 +34,7 @@ def _make_entry( return TestCaseIndexFile( id=test_id, json_path=Path(json_path), - fixture_hash=HexNumber(fixture_hash), + fixture_hash=fixture_hash, fork=fork, format=fmt, ) @@ -47,17 +45,40 @@ def _make_json_fixture(test_names_and_hashes: dict[str, int]) -> str: data = {} for name, h in test_names_and_hashes.items(): data[name] = { - "_info": {"hash": _hex_str(h)}, + "_info": {"hash": str(Hash(h))}, "pre": {}, "post": {}, } return json.dumps(data) -def create_fixture(path: Path, test_name: str, hash_value: str) -> None: +def create_fixture(path: Path, test_name: str, hash_value: int) -> None: """Create a test fixture JSON file.""" path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({test_name: {"_info": {"hash": hash_value}}})) + path.write_text( + json.dumps({test_name: {"_info": {"hash": str(Hash(hash_value))}}}) + ) + + +def create_pre_alloc_group( + folder: Path, balance: int, fork: Fork = Prague +) -> Path: + """Write a pre-allocation group file, as filling phase 1 produces it.""" + folder.mkdir(parents=True, exist_ok=True) + builder = PreAllocGroupBuilder( + test_ids=["tests/test_group.py::test_group"], + environment=Environment().set_fork_requirements(fork), + fork=fork, + group_hash=0x0011223344556677, + pre=Alloc({Address(0x1000): Account(balance=balance)}), + ) + group_file = folder / f"{builder.group_hash}.json" + group_file.write_text( + builder.build().model_dump_json( + by_alias=True, exclude_none=True, indent=2 + ) + ) + return group_file class TestCompareIdenticalDirectories: @@ -67,8 +88,8 @@ def test_compare_identical_directories(self, tmp_path: Path) -> None: """Same content in both dirs should exit 0 with no output.""" dir_a = tmp_path / "dir_a" / "state_tests" dir_b = tmp_path / "dir_b" / "state_tests" - create_fixture(dir_a / "test.json", "test1", "0xabc123") - create_fixture(dir_b / "test.json", "test1", "0xabc123") + create_fixture(dir_a / "test.json", "test1", 0xABC123) + create_fixture(dir_b / "test.json", "test1", 0xABC123) runner = CliRunner() result = runner.invoke( @@ -85,8 +106,8 @@ def test_compare_different_directories(self, tmp_path: Path) -> None: """Different hashes should exit 1 with diff in stdout.""" dir_a = tmp_path / "dir_a" / "state_tests" dir_b = tmp_path / "dir_b" / "state_tests" - create_fixture(dir_a / "test.json", "test1", "0xabc123") - create_fixture(dir_b / "test.json", "test1", "0xdef456") + create_fixture(dir_a / "test.json", "test1", 0xABC123) + create_fixture(dir_b / "test.json", "test1", 0xDEF456) runner = CliRunner() result = runner.invoke( @@ -96,8 +117,68 @@ def test_compare_different_directories(self, tmp_path: Path) -> None: assert "Fixture Hash Differences" in result.output # Verify the new format shows the path and both hashes assert "test1" in result.output - assert "0xabc123" in result.output - assert "0xdef456" in result.output + assert "abc123" in result.output + assert "def456" in result.output + + +class TestComparePreAllocGroups: + """ + Compare directories holding a `pre_alloc` group folder. + + Group files are hashed by their `PreAllocGroup` content, which the hasher + keys off the folder name rather than off a trial parse, so a broken group + file fails as a group instead of falling through to the fixture path. + """ + + def test_compare_identical_groups(self, tmp_path: Path) -> None: + """Identical group files hash equal.""" + for name in ("dir_a", "dir_b"): + root = tmp_path / name / "blockchain_tests_engine_x" + create_fixture(root / "test.json", "test1", 0xABC123) + create_pre_alloc_group(root / "pre_alloc", balance=1) + + runner = CliRunner() + result = runner.invoke( + hasher, + ["compare", str(tmp_path / "dir_a"), str(tmp_path / "dir_b")], + ) + assert result.exit_code == 0 + assert result.output == "" + + def test_compare_different_groups(self, tmp_path: Path) -> None: + """A changed pre-allocation shows as a group file difference.""" + for name, balance in (("dir_a", 1), ("dir_b", 2)): + root = tmp_path / name / "blockchain_tests_engine_x" + create_fixture(root / "test.json", "test1", 0xABC123) + create_pre_alloc_group(root / "pre_alloc", balance=balance) + + runner = CliRunner() + result = runner.invoke( + hasher, + ["compare", str(tmp_path / "dir_a"), str(tmp_path / "dir_b")], + ) + assert result.exit_code == 1 + assert "pre_alloc" in result.output + # The regular fixture sitting next to the group folder is untouched. + assert "test.json" not in result.output + + def test_broken_group_file_reports_a_group_error( + self, tmp_path: Path + ) -> None: + """A malformed group file fails as a group, not as a fixture.""" + root = tmp_path / "dir_a" / "blockchain_tests_engine_x" + pre_alloc = root / "pre_alloc" + pre_alloc.mkdir(parents=True) + (pre_alloc / "broken.json").write_text(json.dumps({"test1": {}})) + + runner = CliRunner() + result = runner.invoke( + hasher, + ["compare", str(tmp_path / "dir_a"), str(tmp_path / "dir_a")], + ) + assert result.exit_code == 2 + assert "PreAllocGroup" in result.output + assert "_info" not in result.output class TestCompareMissingDirectory: @@ -106,7 +187,7 @@ class TestCompareMissingDirectory: def test_compare_missing_directory(self, tmp_path: Path) -> None: """One path doesn't exist should exit 2 with error in stderr.""" dir_a = tmp_path / "dir_a" / "state_tests" - create_fixture(dir_a / "test.json", "test1", "0xabc123") + create_fixture(dir_a / "test.json", "test1", 0xABC123) runner = CliRunner() result = runner.invoke( @@ -122,7 +203,7 @@ class TestCompareFlagParity: def test_compare_flag_parity_files(self, tmp_path: Path) -> None: """Hasher -f X vs hasher compare -f X X should exit 0.""" dir_a = tmp_path / "dir_a" / "state_tests" - create_fixture(dir_a / "test.json", "test1", "0xabc123") + create_fixture(dir_a / "test.json", "test1", 0xABC123) runner = CliRunner() # Compare same directory with -f flag @@ -134,7 +215,7 @@ def test_compare_flag_parity_files(self, tmp_path: Path) -> None: def test_compare_flag_parity_tests(self, tmp_path: Path) -> None: """Hasher -t X vs hasher compare -t X X should exit 0.""" dir_a = tmp_path / "dir_a" / "state_tests" - create_fixture(dir_a / "test.json", "test1", "0xabc123") + create_fixture(dir_a / "test.json", "test1", 0xABC123) runner = CliRunner() # Compare same directory with -t flag @@ -146,7 +227,7 @@ def test_compare_flag_parity_tests(self, tmp_path: Path) -> None: def test_compare_flag_parity_root(self, tmp_path: Path) -> None: """Hasher -r X vs hasher compare -r X X should exit 0.""" dir_a = tmp_path / "dir_a" / "state_tests" - create_fixture(dir_a / "test.json", "test1", "0xabc123") + create_fixture(dir_a / "test.json", "test1", 0xABC123) runner = CliRunner() # Compare same directory with -r flag @@ -162,7 +243,7 @@ class TestBackwardsCompatibility: def test_backwards_compat(self, tmp_path: Path) -> None: """Hasher FOLDER without subcommand should work as before.""" dir_a = tmp_path / "dir_a" / "state_tests" - create_fixture(dir_a / "test.json", "test1", "0xabc123") + create_fixture(dir_a / "test.json", "test1", 0xABC123) runner = CliRunner() # Old syntax without subcommand @@ -173,7 +254,7 @@ def test_backwards_compat(self, tmp_path: Path) -> None: def test_explicit_hash_subcommand(self, tmp_path: Path) -> None: """Hasher hash FOLDER should work.""" dir_a = tmp_path / "dir_a" / "state_tests" - create_fixture(dir_a / "test.json", "test1", "0xabc123") + create_fixture(dir_a / "test.json", "test1", 0xABC123) runner = CliRunner() # Explicit hash subcommand @@ -186,7 +267,7 @@ def test_hash_output_matches_between_syntaxes( ) -> None: """Both syntaxes should produce identical output.""" dir_a = tmp_path / "dir_a" / "state_tests" - create_fixture(dir_a / "test.json", "test1", "0xabc123") + create_fixture(dir_a / "test.json", "test1", 0xABC123) runner = CliRunner() # Old syntax @@ -234,7 +315,7 @@ class TestHashCommandFlags: def test_hash_with_files_flag(self, tmp_path: Path) -> None: """Hasher hash -f FOLDER should work.""" dir_a = tmp_path / "dir_a" / "state_tests" - create_fixture(dir_a / "test.json", "test1", "0xabc123") + create_fixture(dir_a / "test.json", "test1", 0xABC123) runner = CliRunner() result = runner.invoke(hasher, ["hash", "-f", str(dir_a.parent)]) @@ -244,7 +325,7 @@ def test_hash_with_files_flag(self, tmp_path: Path) -> None: def test_hash_with_tests_flag(self, tmp_path: Path) -> None: """Hasher hash -t FOLDER should work.""" dir_a = tmp_path / "dir_a" / "state_tests" - create_fixture(dir_a / "test.json", "test1", "0xabc123") + create_fixture(dir_a / "test.json", "test1", 0xABC123) runner = CliRunner() result = runner.invoke(hasher, ["hash", "-t", str(dir_a.parent)]) @@ -254,7 +335,7 @@ def test_hash_with_tests_flag(self, tmp_path: Path) -> None: def test_hash_with_root_flag(self, tmp_path: Path) -> None: """Hasher hash -r FOLDER should only print root hash.""" dir_a = tmp_path / "dir_a" / "state_tests" - create_fixture(dir_a / "test.json", "test1", "0xabc123") + create_fixture(dir_a / "test.json", "test1", 0xABC123) runner = CliRunner() result = runner.invoke(hasher, ["hash", "-r", str(dir_a.parent)]) @@ -272,8 +353,8 @@ def test_depth_limits_output(self, tmp_path: Path) -> None: """--depth should limit how deep the comparison goes.""" dir_a = tmp_path / "dir_a" / "folder" / "subfolder" dir_b = tmp_path / "dir_b" / "folder" / "subfolder" - create_fixture(dir_a / "test.json", "test1", "0xabc123") - create_fixture(dir_b / "test.json", "test1", "0xdef456") + create_fixture(dir_a / "test.json", "test1", 0xABC123) + create_fixture(dir_b / "test.json", "test1", 0xDEF456) runner = CliRunner() @@ -296,8 +377,8 @@ def test_depth_2_shows_subfolders(self, tmp_path: Path) -> None: """--depth 2 should show subfolders.""" dir_a = tmp_path / "dir_a" / "folder" / "subfolder" dir_b = tmp_path / "dir_b" / "folder" / "subfolder" - create_fixture(dir_a / "test.json", "test1", "0xabc123") - create_fixture(dir_b / "test.json", "test1", "0xdef456") + create_fixture(dir_a / "test.json", "test1", 0xABC123) + create_fixture(dir_b / "test.json", "test1", 0xDEF456) runner = CliRunner() @@ -327,22 +408,22 @@ def test_full_paths_in_output(self, tmp_path: Path) -> None: create_fixture( dir_a / "blockchain_tests" / "shanghai" / "test.json", "test1", - "0xaaa111", + 0xAAA111, ) create_fixture( dir_a / "state_tests" / "shanghai" / "test.json", "test1", - "0xbbb222", + 0xBBB222, ) create_fixture( dir_b / "blockchain_tests" / "shanghai" / "test.json", "test1", - "0xccc333", + 0xCCC333, ) create_fixture( dir_b / "state_tests" / "shanghai" / "test.json", "test1", - "0xddd444", + 0xDDD444, ) runner = CliRunner() @@ -524,28 +605,6 @@ def test_single_file_single_test(self) -> None: hash_from_entries = HashableItem.from_index_entries(entries).hash() assert hash_from_folder == hash_from_entries - def test_entries_with_none_fixture_hash_skipped(self) -> None: - """Verify entries with fixture_hash=None are skipped.""" - entries_with_none = [ - _make_entry("t1", "tests/a.json", HASH_1), - TestCaseIndexFile( - id="t_null", - json_path=Path("tests/a.json"), - fixture_hash=None, - fork=None, - format=None, - ), - ] - entries_without_none = [ - _make_entry("t1", "tests/a.json", HASH_1), - ] - - hash_with = HashableItem.from_index_entries(entries_with_none).hash() - hash_without = HashableItem.from_index_entries( - entries_without_none - ).hash() - assert hash_with == hash_without - class TestMergePartialIndexes: """Test the JSONL partial index merge pipeline end-to-end.""" @@ -569,7 +628,7 @@ def _make_entry_dict( return { "id": test_id, "json_path": json_path, - "fixture_hash": _hex_str(fixture_hash), + "fixture_hash": str(Hash(fixture_hash)), "fork": fork, "format": fmt, "pre_hash": None, @@ -862,4 +921,4 @@ def test_merge_root_hash_matches_from_index_entries(self) -> None: merged = IndexFile.merge([idx_a, idx_b]) expected_hash = HashableItem.from_index_entries(cases).hash() - assert merged.root_hash == HexNumber(expected_hash) + assert merged.root_hash == expected_hash diff --git a/packages/testing/src/execution_testing/cli/tests/test_pytest_fill_command.py b/packages/testing/src/execution_testing/cli/tests/test_pytest_fill_command.py index 8a94b7e18e5..8c283d079a3 100644 --- a/packages/testing/src/execution_testing/cli/tests/test_pytest_fill_command.py +++ b/packages/testing/src/execution_testing/cli/tests/test_pytest_fill_command.py @@ -1,6 +1,5 @@ """Tests for pytest commands (e.g., fill) click CLI.""" -import json import shutil from pathlib import Path from typing import Callable @@ -10,6 +9,7 @@ from pytest import MonkeyPatch, Pytester, RunResult, TempPathFactory import execution_testing.cli.pytest_commands.plugins.filler.filler +from execution_testing.fixtures import PreAllocGroup from ..pytest_commands.fill import fill @@ -246,12 +246,13 @@ def test_generate_pre_alloc_groups_preserves_chain_id_for_valid_from( pytester.copy_example( name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" ) + chain_id = 12345 result = pytester.runpytest( "-c", "pytest-fill.ini", "--generate-pre-alloc-groups", "--chain-id", - "12345", + str(chain_id), f"--output={default_fixtures_output}", str(test_file), "-q", @@ -265,8 +266,10 @@ def test_generate_pre_alloc_groups_preserves_chain_id_for_valid_from( assert pre_alloc_files, f"No pre-alloc files found in {pre_alloc_dir}" for pre_alloc_file in pre_alloc_files: - payload = json.loads(pre_alloc_file.read_text()) - assert payload["chainId"] == 12345, pre_alloc_file + payload = PreAllocGroup.model_validate_json( + pre_alloc_file.read_text() + ) + assert payload.chain_id == 12345, pre_alloc_file def test_fill_html_option( self, diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index f50856597ec..7e2b9b953a9 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -72,6 +72,7 @@ ssz_schema_fork_key, ) from execution_testing.test_types import ( + AllocGroupHash, BlockAccessList, Environment, Removable, @@ -1016,10 +1017,10 @@ class BlockchainEngineXFixture(BlockchainEngineFixtureCommon): } transition_tool_cache_key: ClassVar[str] = "" - pre_hash: str + pre_hash: AllocGroupHash """Hash of the pre-allocation group this test belongs to.""" - post_state_diff: Alloc | None = None + post_state_diff: Alloc """ State difference from genesis after test execution (efficiency optimization). diff --git a/packages/testing/src/execution_testing/fixtures/collector.py b/packages/testing/src/execution_testing/fixtures/collector.py index 414f92e0f64..7761180eda8 100644 --- a/packages/testing/src/execution_testing/fixtures/collector.py +++ b/packages/testing/src/execution_testing/fixtures/collector.py @@ -25,7 +25,7 @@ ) from .base import BaseFixture -from .consume import FixtureConsumer +from .consume import FixtureConsumer, TestCaseIndexFile from .file import Fixtures @@ -303,15 +303,14 @@ def add_fixture( if self.generate_index and self.output_dir.name != "stdout": relative_path = fixture_path.relative_to(self.output_dir) fixture_fork = fixture.get_fork() - index_entry = { - "id": info.get_id(), - "json_path": str(relative_path), - "fixture_hash": str(fixture.hash) if fixture.hash else None, - "fork": fixture_fork.name() if fixture_fork else None, - "format": fixture.format_name, - } - if (pre_hash := getattr(fixture, "pre_hash", None)) is not None: - index_entry["pre_hash"] = pre_hash + index_entry = TestCaseIndexFile( + id=info.get_id(), + json_path=relative_path, + fixture_hash=fixture.hash, + fork=fixture_fork, + format=fixture.format_class(), + pre_hash=getattr(fixture, "pre_hash", None), + ) self._stream_index_entry_to_partial(index_entry) return fixture_path @@ -355,10 +354,10 @@ def _get_partial_index_file(self) -> "IO[str]": return self._partial_index_file - def _stream_index_entry_to_partial(self, entry: Dict) -> None: + def _stream_index_entry_to_partial(self, entry: TestCaseIndexFile) -> None: """Stream a single index entry to partial JSONL file.""" f = self._get_partial_index_file() - f.write(json.dumps(entry) + "\n") + f.write(entry.model_dump_json(exclude_none=True) + "\n") f.flush() # Ensure data is written immediately def close_streaming_files(self) -> None: diff --git a/packages/testing/src/execution_testing/fixtures/consume.py b/packages/testing/src/execution_testing/fixtures/consume.py index 499be630dd7..b7c78821eaa 100644 --- a/packages/testing/src/execution_testing/fixtures/consume.py +++ b/packages/testing/src/execution_testing/fixtures/consume.py @@ -3,17 +3,35 @@ import datetime from abc import ABC, abstractmethod from pathlib import Path -from typing import Iterator, List, Optional, TextIO +from typing import Annotated, Any, Iterator, List, Optional, TextIO -from pydantic import BaseModel, RootModel +from pydantic import BaseModel, BeforeValidator, RootModel -from execution_testing.base_types import HexNumber +from execution_testing.base_types import Hash from execution_testing.forks import Fork, TransitionFork +from execution_testing.test_types import AllocGroupHash from .base import BaseFixture, FixtureFormat from .file import Fixtures +def _left_pad_hash(value: Any) -> Any: + """Left-pad a hash that was serialized without its leading zeros.""" + if isinstance(value, str): + return Hash(value, left_padding=True) + return value + + +IndexHash = Annotated[Hash, BeforeValidator(_left_pad_hash)] +""" +A hash read from an index file. + +Index files written before hashes were typed stored them as numbers, which +drops any leading zero bytes (and writes a missing root hash as ``0x0``), so +a hash read back from one is padded to its full width instead of rejected. +""" + + class FixtureConsumer(ABC): """Abstract class for verifying Ethereum test fixtures.""" @@ -47,10 +65,10 @@ class TestCaseBase(BaseModel): """Base model for a test case used in EEST consume commands.""" id: str - fixture_hash: HexNumber | None = None + fixture_hash: IndexHash fork: Fork | TransitionFork | None = None format: FixtureFormat - pre_hash: str | None = None + pre_hash: AllocGroupHash | None = None __test__ = False # stop pytest from collecting this class as a test @@ -84,7 +102,7 @@ def _marks_default(cls): class IndexFile(BaseModel): """The model definition used for fixture index files.""" - root_hash: HexNumber | None + root_hash: IndexHash | None created_at: datetime.datetime test_count: int forks: Optional[List[Fork]] = [] @@ -113,7 +131,7 @@ def merge(cls, indexes: List["IndexFile"]) -> "IndexFile": root_hash = HashableItem.from_index_entries(all_cases).hash() return cls( - root_hash=HexNumber(root_hash), + root_hash=root_hash, created_at=datetime.datetime.now(datetime.timezone.utc), test_count=len(all_cases), forks=sorted(all_forks, key=lambda f: f.name()), diff --git a/packages/testing/src/execution_testing/fixtures/pre_alloc_groups.py b/packages/testing/src/execution_testing/fixtures/pre_alloc_groups.py index 44a2f5f8999..e134aa27732 100644 --- a/packages/testing/src/execution_testing/fixtures/pre_alloc_groups.py +++ b/packages/testing/src/execution_testing/fixtures/pre_alloc_groups.py @@ -1,10 +1,10 @@ """Pre-allocation group models for test fixture generation.""" -import hashlib import json import os from collections import defaultdict from dataclasses import dataclass +from hashlib import sha256 from pathlib import Path from typing import ( Any, @@ -14,7 +14,6 @@ KeysView, List, Literal, - NamedTuple, Optional, Self, Set, @@ -27,23 +26,27 @@ CamelModel, EthereumTestRootModel, Hash, + ZeroPaddedHexNumber, ) from execution_testing.forks import Fork, TransitionFork -from execution_testing.test_types import Alloc, Environment +from execution_testing.test_types import Alloc, AllocGroupHash, Environment from execution_testing.test_types.chain_config_types import DEFAULT_CHAIN_ID from .blockchain import FixtureHeader -class PreAllocGroupBuilder(CamelModel): - """Pre-allocation group builder.""" +class PreAllocGroupCommon(CamelModel): + """ + Fields that identify a pre-allocation group, in either of its two forms. + + `PreAllocGroupBuilder` is the scratch accumulator phase 1 fills in and + must never publish; `PreAllocGroup` is the artifact consumers read. The + two are not interchangeable and share only the fields below. + """ test_ids: List[str] = Field(default_factory=list) - environment: Environment = Field( - ..., description="Grouping environment for this test group" - ) fork: Fork | TransitionFork = Field(..., alias="network") - chain_id: int = DEFAULT_CHAIN_ID + chain_id: ZeroPaddedHexNumber = ZeroPaddedHexNumber(DEFAULT_CHAIN_ID) group_salt: str | None = Field( None, description=( @@ -51,6 +54,35 @@ class PreAllocGroupBuilder(CamelModel): "groups only pack with groups carrying the same salt." ), ) + group_hash: AllocGroupHash | None = None + + @classmethod + def from_file(cls, file: Path) -> Self: + """ + Load a pre-allocation group or builder from a JSON file. + + Additionally, verify that the file name contains the group hash. + """ + instance = cls.model_validate_json(file.read_bytes()) + if str(instance.group_hash).lower() not in file.stem.lower(): + raise Exception( + f"Pre-alloc group file name `{file}` does not contain the " + "group hash contained in the file " + f"`{str(instance.group_hash)}`" + ) + return instance + + +class PreAllocGroupBuilder(PreAllocGroupCommon): + """ + Pre-allocation group temporary builder. + + This file must _NOT_ be saved as the final output of the filling process. + """ + + environment: Environment = Field( + ..., description="Grouping environment for this test group" + ) pre: Alloc def model_post_init(self, __context: Any) -> None: @@ -92,10 +124,10 @@ def build(self) -> "PreAllocGroup": """Build the pre-alloc group.""" return PreAllocGroup( test_ids=self.test_ids, - environment=self.environment, fork=self.fork, chain_id=self.chain_id, group_salt=self.group_salt, + group_hash=self.group_hash, pre=self.pre.model_dump(), pre_account_count=self.get_pre_account_count(), test_count=self.get_test_count(), @@ -114,7 +146,8 @@ def to_partial_file( Saves the builder format (without genesis/state_root) to avoid expensive state root computation during Phase 1. State root is - computed once when loading in Phase 2 via PreAllocGroup.from_file(). + computed once when the `build` method is used to construct the final + `PreAllocGroup`. """ suffix = f".{worker_id}" if worker_id else ".main" partial_path = file.with_suffix(f".partial{suffix}.json") @@ -128,13 +161,17 @@ def _get_worker_id() -> Optional[str]: return os.environ.get("PYTEST_XDIST_WORKER") -def merge_partial_group_files(folder: Path) -> None: +def merge_partial_group_files(folder: Path, final: bool) -> None: """ Merge all partial group files into final group files. Called by master process after all workers have finished Phase 1. Each worker writes {group_hash}.partial.{worker_id}.json files, which are merged here into {group_hash}.json files. + + The `final` parameter establishes whether to save the files in the final + pre-alloc format to be included in the output, or in the builder format, + in order for them to be able to be re-processed later. """ partial_files = list(folder.glob("*.partial.*.json")) if not partial_files: @@ -195,32 +232,20 @@ def merge_partial_group_files(folder: Path) -> None: # Write final merged file if merged_builder is not None: - target_path.write_text( - merged_builder.model_dump_json( + if final: + output = merged_builder.build().model_dump_json( by_alias=True, exclude_none=True, indent=2 ) - ) - - -def _environment_group_key(environment: Environment) -> str: - """ - Return a stable string identifying a genesis environment. - - Two groups can only share a client if they share a genesis block, so the - environment is part of every packing bucket. The canonical JSON dump - matches the equality semantics of `Environment` (which compares the - alias-keyed, none-excluded dump). - """ - return json.dumps( - environment.model_dump(mode="json", by_alias=True, exclude_none=True), - sort_keys=True, - ) + else: + output = merged_builder.model_dump_json( + by_alias=True, exclude_none=True, indent=2 + ) + target_path.write_text(output) -def _packed_group_hash(test_ids: List[str]) -> str: +def _packed_group_hash(test_ids: List[str]) -> AllocGroupHash: """Return a deterministic ``0x``-prefixed id for a packed group.""" - digest = hashlib.sha256("\n".join(test_ids).encode("utf-8")).digest() - return f"0x{int.from_bytes(digest[:8], byteorder='big'):016x}" + return AllocGroupHash.from_preimage("\n".join(test_ids)) # The test id -> group hash index written next to the group files by @@ -229,7 +254,7 @@ def _packed_group_hash(test_ids: List[str]) -> str: TEST_GROUP_INDEX_FILE = "test_group_index" -class GroupIndexEntry(NamedTuple): +class GroupIndexEntry(CamelModel): """ A test's entry in the test id -> pre-alloc group index. @@ -241,11 +266,49 @@ class GroupIndexEntry(NamedTuple): reconstructed by scanning group files. """ - group_hash: str - phase1_hash: str | None + group_hash: AllocGroupHash + phase1_hash: AllocGroupHash | None + + +class GroupIndexEntries(EthereumTestRootModel): + """File containing a test-id to GroupIndexEntry mapping.""" + + root: Dict[str, GroupIndexEntry] = Field(default_factory=dict) + @classmethod + def from_file(cls, file_path: Path) -> Self: + """Read an index from file.""" + return cls.model_validate_json(file_path.read_text()) + + def to_file(self, file_path: Path) -> None: + """Write the index entries to a file.""" + file_path.write_text(self.model_dump_json(by_alias=True, indent=2)) + + def __getitem__(self, item: str) -> GroupIndexEntry: + """Get an index entry.""" + return self.root[item] -def read_test_group_index(folder: Path) -> Dict[str, GroupIndexEntry]: + def __setitem__(self, item: str, value: GroupIndexEntry) -> None: + """Set an index entry.""" + self.root[item] = value + + def __iter__(self) -> Iterator[str]: # type: ignore [override] + """Iterate over root dict.""" + return iter(self.root) + + def items( + self, + ) -> Generator[Tuple[str, GroupIndexEntry], None, None]: + """Get items from root dict.""" + for key, value in self.root.items(): + yield key, value + + def get(self, key: str) -> GroupIndexEntry | None: + """Get item from root dict.""" + return self.root.get(key) + + +def read_test_group_index(folder: Path) -> GroupIndexEntries: """ Map every test id to the pre-alloc group that contains it. @@ -256,23 +319,23 @@ def read_test_group_index(folder: Path) -> Dict[str, GroupIndexEntry]: """ index_file = folder / TEST_GROUP_INDEX_FILE if index_file.exists(): - return { - test_id: GroupIndexEntry(entry["group"], entry["phase1"]) - for test_id, entry in json.loads(index_file.read_text()).items() - } - index: Dict[str, GroupIndexEntry] = {} + return GroupIndexEntries.from_file(index_file) + index = GroupIndexEntries() for file in folder.glob("*.json"): data = json.loads(file.read_text()) for test_id in data.get("testIds", []): - index[test_id] = GroupIndexEntry(file.stem, None) + assert isinstance(test_id, str) + index[test_id] = GroupIndexEntry( + group_hash=AllocGroupHash(file.stem), phase1_hash=None + ) return index def packed_group_hash_for_test( - index: Dict[str, GroupIndexEntry], + index: GroupIndexEntries, test_id: str, - phase1_hash: str, -) -> str: + phase1_hash: AllocGroupHash, +) -> AllocGroupHash: """ Return the packed group hash owning ``test_id``, verifying freshness. @@ -400,32 +463,31 @@ def pack_pre_alloc_groups(folder: Path) -> None: return builders = [] - phase1_hash_by_test: Dict[str, str] = {} + phase1_hash_by_test: Dict[str, AllocGroupHash] = {} for file in files: - builder = PreAllocGroupBuilder.model_validate_json(file.read_text()) + builder = PreAllocGroupBuilder.from_file(file) for test_id in builder.test_ids: - phase1_hash_by_test[test_id] = file.stem + phase1_hash_by_test[test_id] = AllocGroupHash(file.stem) builders.append(builder) genesis_buckets: Dict[ - Tuple[str, int, str, str], List[PreAllocGroupBuilder] + Tuple[Fork | TransitionFork, int, str, str], List[PreAllocGroupBuilder] ] = defaultdict(list) for builder in builders: - genesis_buckets[ - ( - builder.fork.name(), - builder.chain_id, - builder.group_salt or "", - _environment_group_key(builder.environment), - ) - ].append(builder) + key = ( + builder.fork, + builder.chain_id, + builder.group_salt or "", + builder.environment.canonical_json(), + ) + genesis_buckets[key].append(builder) # Drop the fine-grained files up front; the packed files written below are # named by content hash and never clash with the (now stale) originals. for file in files: file.unlink() - test_group_index: Dict[str, GroupIndexEntry] = {} + test_group_index = GroupIndexEntries() for genesis_key in sorted(genesis_buckets): bucket = genesis_buckets[genesis_key] reserved = _reserved_addresses(bucket) @@ -443,42 +505,26 @@ def pack_pre_alloc_groups(folder: Path) -> None: for merged in packed.values(): merged.test_ids.sort() packed_hash = _packed_group_hash(merged.test_ids) + merged.group_hash = packed_hash (folder / f"{packed_hash}.json").write_text( - merged.model_dump_json( + merged.build().model_dump_json( by_alias=True, exclude_none=True, indent=2 ) ) for test_id in merged.test_ids: test_group_index[test_id] = GroupIndexEntry( - packed_hash, phase1_hash_by_test[test_id] + group_hash=packed_hash, + phase1_hash=phase1_hash_by_test[test_id], ) - - (folder / TEST_GROUP_INDEX_FILE).write_text( - json.dumps( - { - test_id: { - "group": entry.group_hash, - "phase1": entry.phase1_hash, - } - for test_id, entry in test_group_index.items() - }, - sort_keys=True, - indent=2, - ) - ) + test_group_index.to_file(folder / TEST_GROUP_INDEX_FILE) class PreAllocGroupBuilders(EthereumTestRootModel): - """ - Root model mapping pre-allocation group hashes to test groups. + """Root model mapping pre-allocation group builders to group hashes.""" - If lazy_load is True, the groups are not loaded from the folder until they - are accessed. - - Iterating will fail if lazy_load is True. - """ - - root: Dict[str, PreAllocGroupBuilder] + root: Dict[AllocGroupHash, PreAllocGroupBuilder] = Field( + default_factory=dict + ) def to_folder(self, folder: Path, worker_id: Optional[str] = None) -> None: """ @@ -494,7 +540,7 @@ def to_folder(self, folder: Path, worker_id: Optional[str] = None) -> None: def add_test_pre( self, *, - pre_alloc_hash: str, + pre_alloc_hash: AllocGroupHash, test_id: str, fork: Fork | TransitionFork, chain_id: int, @@ -525,6 +571,7 @@ def add_test_pre( chain_id=chain_id, environment=environment, group_salt=group_salt, + group_hash=pre_alloc_hash, pre=Alloc.merge( Alloc.model_validate( fork.transitions_to().pre_allocation_blockchain() @@ -564,6 +611,7 @@ class GroupPreAlloc(Alloc): _cached_state_root: Hash | None = PrivateAttr(None) _model_dump_cache: ModelDumpCache | None = PrivateAttr(None) + _pre_alloc_group_hash: AllocGroupHash | None = PrivateAttr(None) def state_root(self) -> Hash: """On pre-alloc groups, which are normally very big, always cache.""" @@ -617,13 +665,24 @@ def model_dump_json(self, **kwargs: Any) -> str: ) return data + def get_alloc_grouping_hash(self) -> AllocGroupHash | None: + """ + Return the grouping hash if the allocation belongs to a particular + group, otherwise `None`. + + Method can be overloaded by other implementations of the Alloc to + return the appropriate group. + """ + return self._pre_alloc_group_hash + -class PreAllocGroup(PreAllocGroupBuilder): +class PreAllocGroup(PreAllocGroupCommon): """ Pre-allocation group for tests with identical Environment and fork values. - Groups tests by a hash of their fixture Environment and fork to enable - pre-allocation group optimization. + Grouping is still keyed on the tests' fixture Environment and fork, but + that Environment lives in phase 1 (`PreAllocGroupBuilder`) only: what + reaches the final group is the `genesis` header derived from it. """ pre: GroupPreAlloc @@ -637,23 +696,17 @@ def model_post_init(self, __context: Any) -> None: """ super().model_post_init(__context) self.pre._cached_state_root = self.genesis.state_root + self.pre._pre_alloc_group_hash = self.group_hash - @classmethod - def from_file(cls, file: Path) -> Self: + def hash(self) -> Hash: """ - Load a pre-allocation group from a JSON file. - - Files are stored in builder format (without genesis). Genesis is - computed on-demand when loading, ensuring state root computation - happens exactly once in Phase 2, not during Phase 1 merging. + Return a Hash based on the canonical JSON of the model. """ - with open(file) as f: - data = f.read() - - builder = PreAllocGroupBuilder.model_validate_json(data) - built = builder.build() - # Use cls.model_validate to ensure proper Self return type - return cls.model_validate(built.model_dump()) + canonical_json = json.dumps( + self.model_dump(mode="json", by_alias=True, exclude_none=True), + sort_keys=True, + ) + return Hash(sha256(canonical_json.encode("utf-8")).digest()) class PreAllocGroups(EthereumTestRootModel): @@ -666,11 +719,13 @@ class PreAllocGroups(EthereumTestRootModel): Iterating will fail if lazy_load is True. """ - root: Dict[str, PreAllocGroup | None] + root: Dict[AllocGroupHash, PreAllocGroup | None] = Field( + default_factory=dict + ) _folder_source: Path | None = PrivateAttr(None) - def __setitem__(self, key: str, value: Any) -> None: + def __setitem__(self, key: AllocGroupHash, value: Any) -> None: """Set item in root dict.""" assert self._folder_source is None, ( "Cannot set item in root dict after folder source is set" @@ -685,18 +740,18 @@ def from_folder(cls, folder: Path, *, lazy_load: bool = False) -> Self: with open(fail_file) as f: raise Alloc.CollisionError.from_json(json.loads(f.read())) - data: Dict[str, PreAllocGroup | None] = {} + data: Dict[AllocGroupHash, PreAllocGroup | None] = {} for file in folder.glob("*.json"): if lazy_load: - data[file.stem] = None + data[AllocGroupHash(file.stem)] = None else: - data[file.stem] = PreAllocGroup.from_file(file) + data[AllocGroupHash(file.stem)] = PreAllocGroup.from_file(file) instance = cls(root=data) if lazy_load: instance._folder_source = folder return instance - def __getitem__(self, item: str) -> PreAllocGroup: + def __getitem__(self, item: AllocGroupHash) -> PreAllocGroup: """Get item from root dict.""" if self._folder_source is None: value = self.root[item] @@ -711,11 +766,11 @@ def __getitem__(self, item: str) -> PreAllocGroup: assert result is not None return result - def __iter__(self) -> Iterator[str]: # type: ignore [override] + def __iter__(self) -> Iterator[AllocGroupHash]: # type: ignore [override] """Iterate over root dict.""" return iter(self.root) - def __contains__(self, item: str) -> bool: + def __contains__(self, item: AllocGroupHash) -> bool: """Check if item in root dict.""" return item in self.root @@ -723,7 +778,7 @@ def __len__(self) -> int: """Get length of root dict.""" return len(self.root) - def keys(self) -> KeysView[str]: + def keys(self) -> KeysView[AllocGroupHash]: """Get keys from root dict.""" return self.root.keys() @@ -733,7 +788,9 @@ def values(self) -> Generator[PreAllocGroup, None, None]: assert value is not None, "Value is None" yield value - def items(self) -> Generator[Tuple[str, PreAllocGroup], None, None]: + def items( + self, + ) -> Generator[Tuple[AllocGroupHash, PreAllocGroup], None, None]: """Get items from root dict.""" for key, value in self.root.items(): assert value is not None, f"Value for key {key} is None" diff --git a/packages/testing/src/execution_testing/fixtures/tests/test_consume.py b/packages/testing/src/execution_testing/fixtures/tests/test_consume.py new file mode 100644 index 00000000000..e9c27804709 --- /dev/null +++ b/packages/testing/src/execution_testing/fixtures/tests/test_consume.py @@ -0,0 +1,66 @@ +"""Tests for the consume index file models.""" + +import json + +from execution_testing.base_types import Hash +from execution_testing.fixtures.consume import IndexFile + +# A fixture hash as an older framework version wrote it: serialized from a +# number, so its two leading zero bytes are missing. +LEGACY_FIXTURE_HASH = ( + "0x511ecc977c8f0ea5e940b47f4faac9ff6f7b77b2bb82f4d94a369f4475d1463" +) + + +def _index_json(root_hash: str, fixture_hash: str) -> str: + """Return an index file holding a single test case.""" + return json.dumps( + { + "root_hash": root_hash, + "created_at": "2025-10-09T22:01:49.594302", + "test_count": 1, + "forks": ["Prague"], + "fixture_formats": ["state_test"], + "test_cases": [ + { + "id": "tests/a.py::test_a", + "json_path": "state_tests/a.json", + "fixture_hash": fixture_hash, + "fork": "Prague", + "format": "state_test", + "pre_hash": None, + } + ], + } + ) + + +def test_index_file_reads_legacy_hashes() -> None: + """ + Load an index file written before its hashes were typed. + + Those hashes were serialized from numbers, which drops leading zero + bytes and writes an unavailable root hash as ``0x0``, so reading one + back must pad rather than reject it. + """ + index = IndexFile.model_validate_json( + _index_json(root_hash="0x0", fixture_hash=LEGACY_FIXTURE_HASH) + ) + + assert index.root_hash == Hash(0) + + fixture_hash = index.test_cases[0].fixture_hash + assert len(fixture_hash) == 32 + assert int(str(fixture_hash), 16) == int(LEGACY_FIXTURE_HASH, 16) + + +def test_index_file_reads_full_width_hashes() -> None: + """Load an index file whose hashes already carry their zero bytes.""" + full_width = str(Hash(int(LEGACY_FIXTURE_HASH, 16))) + + index = IndexFile.model_validate_json( + _index_json(root_hash=full_width, fixture_hash=full_width) + ) + + assert index.root_hash == Hash(full_width) + assert index.test_cases[0].fixture_hash == Hash(full_width) diff --git a/packages/testing/src/execution_testing/fixtures/tests/test_pre_alloc_groups.py b/packages/testing/src/execution_testing/fixtures/tests/test_pre_alloc_groups.py index f2bb4a4c797..a84cc631d17 100644 --- a/packages/testing/src/execution_testing/fixtures/tests/test_pre_alloc_groups.py +++ b/packages/testing/src/execution_testing/fixtures/tests/test_pre_alloc_groups.py @@ -1,4 +1,4 @@ -"""Tests for conflict-aware packing of pre-allocation groups.""" +"""Tests for pre-allocation group building and conflict-aware packing.""" import json from pathlib import Path @@ -7,6 +7,7 @@ import pytest from execution_testing.base_types import Account, Address +from execution_testing.fixtures.blockchain import FixtureHeader from execution_testing.fixtures.pre_alloc_groups import ( TEST_GROUP_INDEX_FILE, GroupIndexEntry, @@ -15,13 +16,13 @@ packed_group_hash_for_test, read_test_group_index, ) -from execution_testing.forks import Fork, Osaka, Prague -from execution_testing.test_types import Alloc, Environment +from execution_testing.forks import Fork, Osaka, Prague, get_forks +from execution_testing.test_types import Alloc, AllocGroupHash, Environment def _write_group( folder: Path, - stem: str, + stem: AllocGroupHash | int, test_id: str, pre: Dict[int, Account], *, @@ -32,22 +33,23 @@ def _write_group( """Write a single fine-grained group file, as Phase 1 would.""" builder = PreAllocGroupBuilder( test_ids=[test_id], - environment=environment, + environment=environment.set_fork_requirements(fork), fork=fork, group_salt=group_salt, + group_hash=stem, pre=Alloc( {Address(address): account for address, account in pre.items()} ), ) - (folder / f"{stem}.json").write_text( + (folder / f"{builder.group_hash}.json").write_text( builder.model_dump_json(by_alias=True, exclude_none=True, indent=2) ) -def _packed(folder: Path) -> Dict[str, dict]: +def _packed(folder: Path) -> Dict[AllocGroupHash, dict]: """Load the packed group files by stem.""" return { - file.stem: json.loads(file.read_text()) + AllocGroupHash(file.stem): json.loads(file.read_text()) for file in folder.glob("*.json") } @@ -57,14 +59,14 @@ def test_pack_merges_non_conflicting_groups(tmp_path: Path) -> None: env = Environment() _write_group( tmp_path, - "0x01", + 1, "tests/a.py::test_a", {0x1000: Account(balance=1)}, environment=env, ) _write_group( tmp_path, - "0x02", + 2, "tests/b.py::test_b", {0x2000: Account(balance=2)}, environment=env, @@ -90,14 +92,14 @@ def test_pack_keeps_conflicting_groups_apart(tmp_path: Path) -> None: env = Environment() _write_group( tmp_path, - "0x01", + 1, "tests/a.py::test_a", {0x1000: Account(balance=1)}, environment=env, ) _write_group( tmp_path, - "0x02", + 2, "tests/b.py::test_b", {0x1000: Account(balance=2)}, environment=env, @@ -125,14 +127,14 @@ def test_pack_merges_identical_account_at_shared_address( shared = Account(balance=1, nonce=1) _write_group( tmp_path, - "0x01", + 1, "tests/a.py::test_a", {0x1000: shared, 0x2000: Account(balance=5)}, environment=env, ) _write_group( tmp_path, - "0x02", + 2, "tests/b.py::test_b", {0x1000: shared, 0x3000: Account(balance=6)}, environment=env, @@ -147,14 +149,14 @@ def test_pack_separates_distinct_environments(tmp_path: Path) -> None: """Groups with different genesis environments never merge.""" _write_group( tmp_path, - "0x01", + 1, "tests/a.py::test_a", {0x1000: Account(balance=1)}, environment=Environment(), ) _write_group( tmp_path, - "0x02", + 2, "tests/b.py::test_b", {0x2000: Account(balance=2)}, environment=Environment(gas_limit=0x1000000), @@ -173,14 +175,14 @@ def test_pack_respects_group_salt(tmp_path: Path) -> None: env = Environment() _write_group( tmp_path, - "0x01", + 1, "tests/a.py::test_a", {0x1000: Account(balance=1)}, environment=env, ) _write_group( tmp_path, - "0x02", + 2, "tests/b.py::test_b", {0x2000: Account(balance=2)}, environment=env, @@ -188,7 +190,7 @@ def test_pack_respects_group_salt(tmp_path: Path) -> None: ) _write_group( tmp_path, - "0x03", + 3, "tests/c.py::test_c", {0x3000: Account(balance=3)}, environment=env, @@ -214,7 +216,7 @@ def test_pack_merges_groups_with_matching_salt(tmp_path: Path) -> None: env = Environment() _write_group( tmp_path, - "0x01", + 1, "tests/a.py::test_a", {0x1000: Account(balance=1)}, environment=env, @@ -222,7 +224,7 @@ def test_pack_merges_groups_with_matching_salt(tmp_path: Path) -> None: ) _write_group( tmp_path, - "0x02", + 2, "tests/b.py::test_b", {0x2000: Account(balance=2)}, environment=env, @@ -245,21 +247,21 @@ def test_pack_writes_test_group_index(tmp_path: Path) -> None: env = Environment() _write_group( tmp_path, - "0x01", + 1, "tests/a.py::test_a", {0x1000: Account(balance=1)}, environment=env, ) _write_group( tmp_path, - "0x02", + 2, "tests/b.py::test_b", {0x2000: Account(balance=2)}, environment=env, ) _write_group( tmp_path, - "0x03", + 3, "tests/c.py::test_c", {0x1000: Account(balance=3)}, environment=env, @@ -271,7 +273,7 @@ def test_pack_writes_test_group_index(tmp_path: Path) -> None: packed = _packed(tmp_path) assert TEST_GROUP_INDEX_FILE not in packed index = read_test_group_index(tmp_path) - assert sorted(index) == [ + assert sorted(index.root) == [ "tests/a.py::test_a", "tests/b.py::test_b", "tests/c.py::test_c", @@ -279,9 +281,9 @@ def test_pack_writes_test_group_index(tmp_path: Path) -> None: for test_id, entry in index.items(): assert test_id in packed[entry.group_hash]["testIds"] # Every entry records the test's fine-grained phase 1 hash. - assert index["tests/a.py::test_a"].phase1_hash == "0x01" - assert index["tests/b.py::test_b"].phase1_hash == "0x02" - assert index["tests/c.py::test_c"].phase1_hash == "0x03" + assert index["tests/a.py::test_a"].phase1_hash == AllocGroupHash(1) + assert index["tests/b.py::test_b"].phase1_hash == AllocGroupHash(2) + assert index["tests/c.py::test_c"].phase1_hash == AllocGroupHash(3) def test_read_test_group_index_falls_back_to_scanning( @@ -291,23 +293,27 @@ def test_read_test_group_index_falls_back_to_scanning( env = Environment() _write_group( tmp_path, - "0x01", + 1, "tests/a.py::test_a", {0x1000: Account(balance=1)}, environment=env, ) _write_group( tmp_path, - "0x02", + 2, "tests/b.py::test_b", {0x2000: Account(balance=2)}, environment=env, ) assert not (tmp_path / TEST_GROUP_INDEX_FILE).exists() - assert read_test_group_index(tmp_path) == { - "tests/a.py::test_a": GroupIndexEntry("0x01", None), - "tests/b.py::test_b": GroupIndexEntry("0x02", None), + assert read_test_group_index(tmp_path).root == { + "tests/a.py::test_a": GroupIndexEntry( + group_hash=AllocGroupHash(1), phase1_hash=None + ), + "tests/b.py::test_b": GroupIndexEntry( + group_hash=AllocGroupHash(2), phase1_hash=None + ), } @@ -322,7 +328,7 @@ def test_packed_group_hash_lookup_validates_phase1_hash( env = Environment() _write_group( tmp_path, - "0x01", + 1, "tests/a.py::test_a", {0x1000: Account(balance=1)}, environment=env, @@ -331,17 +337,17 @@ def test_packed_group_hash_lookup_validates_phase1_hash( index = read_test_group_index(tmp_path) packed_hash = packed_group_hash_for_test( - index, "tests/a.py::test_a", phase1_hash="0x01" + index, "tests/a.py::test_a", phase1_hash=AllocGroupHash(1) ) assert packed_hash == index["tests/a.py::test_a"].group_hash with pytest.raises(ValueError, match="stale"): packed_group_hash_for_test( - index, "tests/a.py::test_a", phase1_hash="0xff" + index, "tests/a.py::test_a", phase1_hash=AllocGroupHash(0xFF) ) with pytest.raises(ValueError, match="not assigned"): packed_group_hash_for_test( - index, "tests/b.py::test_b", phase1_hash="0x02" + index, "tests/b.py::test_b", phase1_hash=AllocGroupHash(2) ) @@ -352,19 +358,16 @@ def test_packed_group_hash_lookup_without_fingerprint( env = Environment() _write_group( tmp_path, - "0x01", + 1, "tests/a.py::test_a", {0x1000: Account(balance=1)}, environment=env, ) index = read_test_group_index(tmp_path) - assert ( - packed_group_hash_for_test( - index, "tests/a.py::test_a", phase1_hash="0xff" - ) - == "0x01" - ) + assert packed_group_hash_for_test( + index, "tests/a.py::test_a", phase1_hash=AllocGroupHash(0xFF) + ) == AllocGroupHash(1) def test_pack_is_deterministic(tmp_path: Path) -> None: @@ -376,7 +379,7 @@ def build(folder: Path) -> None: for i in range(6): _write_group( folder, - f"0x0{i}", + i, f"tests/t{i}.py::test_{i}", {0x1000 + i: Account(balance=i)}, environment=env, @@ -399,14 +402,14 @@ def test_pack_isolates_funded_precompile(tmp_path: Path) -> None: env = Environment() _write_group( tmp_path, - "0x01", + 1, "tests/a.py::test_a", {0x02: Account(balance=1), 0x9000: Account(balance=1)}, environment=env, ) _write_group( tmp_path, - "0x02", + 2, "tests/b.py::test_b", {0x9001: Account(balance=2)}, environment=env, @@ -440,7 +443,7 @@ def test_pack_isolates_fork_precompile_above_blanket_range( folder.mkdir() _write_group( folder, - "0x01", + 1, "tests/a.py::test_a", {0x100: Account(balance=1)}, environment=env, @@ -448,7 +451,7 @@ def test_pack_isolates_fork_precompile_above_blanket_range( ) _write_group( folder, - "0x02", + 2, "tests/b.py::test_b", {0x2000: Account(balance=2)}, environment=env, @@ -468,9 +471,9 @@ def test_pack_merges_when_shared_address_agrees(tmp_path: Path) -> None: env = Environment() shared = Account(balance=1, nonce=1) for stem, test_id, private in [ - ("0x01", "tests/a.py::test_a", 0xA000), - ("0x02", "tests/b.py::test_b", 0xB000), - ("0x03", "tests/c.py::test_c", 0xC000), + (1, "tests/a.py::test_a", 0xA000), + (2, "tests/b.py::test_b", 0xB000), + (3, "tests/c.py::test_c", 0xC000), ]: _write_group( tmp_path, @@ -494,21 +497,21 @@ def test_pack_isolates_disagreeing_shared_address(tmp_path: Path) -> None: shared = Account(balance=1, nonce=1) _write_group( tmp_path, - "0x01", + 1, "tests/a.py::test_a", {0x9000: shared, 0xA000: Account(balance=2)}, environment=env, ) _write_group( tmp_path, - "0x02", + 2, "tests/b.py::test_b", {0x9000: shared, 0xB000: Account(balance=2)}, environment=env, ) _write_group( tmp_path, - "0x03", + 3, "tests/c.py::test_c", {0xC000: Account(balance=2)}, environment=env, @@ -526,3 +529,58 @@ def test_pack_isolates_disagreeing_shared_address(tmp_path: Path) -> None: "tests/b.py::test_b", "tests/c.py::test_c", ] + + +def test_builder_genesis_carries_the_environment() -> None: + """ + Every `Environment` field the genesis header shares reaches that header. + + A final `PreAllocGroup` does not carry the `Environment` it was grouped + on, only the `genesis` header the builder derives from it, so this + mapping is the whole of what a consumer ends up seeing. + + The fields to compare come from the two models, so one added to both is + covered without touching this test. Fields left `None` are skipped, and + the newest fork is used: `FixtureHeader.genesis` dumps the environment + with `exclude_none=True` and derives the rest itself, gated on the fork + (the empty block access list hash, for one). + """ + fork = get_forks()[-1] + environment = Environment( + fee_recipient=0x1234, + prev_randao=0x5678, + extra_data=b"\x01\x02", + number=0, + timestamp=7, + difficulty=0, + gas_limit=0x123456, + base_fee_per_gas=99, + excess_blob_gas=0x20000, + blob_gas_used=0x10000, + parent_beacon_block_root=0xABC, + slot_number=42, + ).set_fork_requirements(fork) + + genesis = ( + PreAllocGroupBuilder( + test_ids=["tests/a.py::test_a"], + environment=environment, + fork=fork, + pre=Alloc(), + ) + .build() + .genesis + ) + + carried = sorted( + field + for field in set(Environment.model_fields) + & set(FixtureHeader.model_fields) + if getattr(environment, field) is not None + ) + assert carried, "no environment field reaches the genesis header" + for field in carried: + assert getattr(genesis, field) == getattr(environment, field), ( + f"{field} did not reach the genesis header: " + f"{getattr(environment, field)} != {getattr(genesis, field)}" + ) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index b04ad806940..0163cebfad7 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -1348,15 +1348,14 @@ def make_hive_fixture( if fixture_format == BlockchainEngineXFixture: # For Engine X format, exclude pre (will be provided via shared # state) and prepare for state diff optimization - fixture_data.update( - { - "post_state": alloc - if self.include_full_post_state_in_output - else None, - "pre_hash": "", # Will be set by BaseTestWrapper - } - ) - fixture = BlockchainEngineXFixture(**fixture_data) + pre_alloc_group_hash = self.pre.get_alloc_grouping_hash() + if pre_alloc_group_hash is None: + raise ValueError( + "Engine X fixtures require a pre-alloc group; was phase 1 " + "run?" + ) + fixture_data["pre_hash"] = pre_alloc_group_hash + fixture_data["post_state_diff"] = alloc.calculate_diff(self.pre) elif fixture_format == BlockchainEngineSyncFixture: # Sync fixture format assert genesis.header.block_hash != head_hash, ( @@ -1383,7 +1382,6 @@ def make_hive_fixture( else None, } ) - fixture = BlockchainEngineSyncFixture(**fixture_data) else: # Standard engine fixture fixture_data.update( @@ -1394,7 +1392,7 @@ def make_hive_fixture( else None, } ) - fixture = BlockchainEngineFixture(**fixture_data) + fixture = fixture_format.format_class()(**fixture_data) return FillResult( fixture=fixture, diff --git a/packages/testing/src/execution_testing/test_types/__init__.py b/packages/testing/src/execution_testing/test_types/__init__.py index ffb82b59196..315f5543b10 100644 --- a/packages/testing/src/execution_testing/test_types/__init__.py +++ b/packages/testing/src/execution_testing/test_types/__init__.py @@ -1,6 +1,6 @@ """Common definitions and types.""" -from .account_types import EOA, Alloc +from .account_types import EOA, Alloc, AllocGroupHash from .blob_types import Blob from .block_access_list import ( BalAccountAbsentValues, @@ -65,6 +65,7 @@ "DETERMINISTIC_FACTORY_BYTECODE", "DETERMINISTIC_FACTORY_ADDRESS", "Alloc", + "AllocGroupHash", "AuthorizationTuple", "BalAccountAbsentValues", "BalAccountChange", diff --git a/packages/testing/src/execution_testing/test_types/account_types.py b/packages/testing/src/execution_testing/test_types/account_types.py index e69c66c9ccd..9971f3e4375 100644 --- a/packages/testing/src/execution_testing/test_types/account_types.py +++ b/packages/testing/src/execution_testing/test_types/account_types.py @@ -4,6 +4,7 @@ from collections.abc import Sequence from dataclasses import dataclass from enum import Enum, auto +from hashlib import sha256 from types import ModuleType from typing import ( Any, @@ -28,6 +29,7 @@ from execution_testing.base_types import ( Account, Address, + FixedSizeBytes, Hash, HashInt, Number, @@ -108,6 +110,31 @@ def copy(self) -> Self: return self.__class__(Address(self), key=self.key, nonce=self.nonce) +class AllocGroupHash(FixedSizeBytes[8]): # type: ignore + """Class that helps represent hashes used to group allocs.""" + + @classmethod + def from_preimage(cls, x: str | bytes) -> "AllocGroupHash": + """ + Perform a hash (sha256) then truncate the output to get the alloc + hash. + """ + if isinstance(x, str): + x = x.encode("utf-8") + return cls(sha256(x).digest()[:8]) + + def __xor__(self, other: "int | AllocGroupHash") -> "AllocGroupHash": + """ + Alloc hashes are usually combination of multiple inputs via + XOR operation. + """ + if isinstance(other, int): + other = AllocGroupHash(other) + return AllocGroupHash( + bytes(a ^ b for a, b in zip(self, other, strict=True)) + ) + + class Alloc(BaseAlloc): """ Allocation of accounts in the state, pre and post test execution. @@ -340,6 +367,57 @@ def verify_post_alloc(self, got_alloc: "Alloc") -> None: else: raise Alloc.MissingAccountError(address=address) + def get_alloc_grouping_hash(self) -> AllocGroupHash | None: + """ + Return the grouping hash if the allocation belongs to a particular + group, otherwise `None`. + + Method can be overloaded by other implementations of the Alloc to + return the appropriate group. + """ + return None + + def calculate_diff(self, base_alloc: "Alloc") -> "Alloc": + """ + Calculate the state difference between self and a base. + + Returns an Alloc containing only the accounts that: + - Changed between base and self (balance, nonce, storage, code) + - Were created during test execution (new accounts) + - Were deleted during test execution (represented as None) + + Args: + base_alloc: Genesis pre-allocation state + + Returns: + Alloc containing only the state differences for efficient storage + + """ + diff: Dict[Address, Account | None] = {} + + # Find all addresses that exist in either state + all_addresses = set(self.root.keys()) | set(base_alloc.root.keys()) + + for address in all_addresses: + genesis_account = base_alloc.root.get(address) + post_account = self.root.get(address) + + # Account was deleted (exists in genesis but not in post) + if genesis_account is not None and post_account is None: + diff[address] = None + + # Account was created (doesn't exist in genesis but exists in post) + elif genesis_account is None and post_account is not None: + diff[address] = post_account + + # Account was modified (exists in both but different) + elif genesis_account != post_account: + diff[address] = post_account + + # Account unchanged - don't include in diff + + return Alloc(diff) + # ------------------------------------------------------------------ # PreState protocol implementation # ------------------------------------------------------------------ diff --git a/packages/testing/src/execution_testing/test_types/block_types.py b/packages/testing/src/execution_testing/test_types/block_types.py index b6fb5d06206..c905196ec47 100644 --- a/packages/testing/src/execution_testing/test_types/block_types.py +++ b/packages/testing/src/execution_testing/test_types/block_types.py @@ -1,6 +1,6 @@ """Block-related types for Ethereum tests.""" -import hashlib +import json from dataclasses import dataclass from functools import cached_property from typing import Any, Dict, Generic, List, Sequence @@ -214,15 +214,18 @@ def set_fork_requirements(self, fork: Fork) -> "Environment": return self.copy(**updated_values) - def __hash__(self) -> int: - """Hashes the environment object.""" - hash_dict = self.model_dump(exclude_none=True, by_alias=True) - - sorted_items = sorted(hash_dict.items()) - hash_string = str(sorted_items) + def canonical_json(self) -> str: + """ + Return the canonical JSON encoding of this model. - digest = hashlib.sha256(hash_string.encode("utf-8")).digest() - return int.from_bytes(digest[:8], byteorder="big") + Keys are alias-cased and sorted, and unset fields are excluded, so + two equal models encode identically and the encoding is stable + across processes: usable as a grouping key or a hash pre-image. + """ + return json.dumps( + self.model_dump(mode="json", by_alias=True, exclude_none=True), + sort_keys=True, + ) def __eq__(self, other: object) -> bool: """Check if two environment objects are equal.""" diff --git a/tests/ported_static/conftest.py b/tests/ported_static/conftest.py index c5d9aff2a9d..ad1933053d7 100644 --- a/tests/ported_static/conftest.py +++ b/tests/ported_static/conftest.py @@ -13,6 +13,7 @@ from pathlib import Path import pytest +from execution_testing.fixtures import BaseFixture, LabeledFixtureFormat from execution_testing.forks import Amsterdam _SKIP_LIST_PATH = Path(__file__).parent / "amsterdam_skip_list.txt" @@ -22,21 +23,20 @@ if line.strip() and not line.lstrip().startswith("#") ) -# Fixture format suffixes pytest appends inside the parametrize id. These -# must be stripped from the nodeid before substring-matching against the -# skip list, because the skip list predates these suffixes. -_FIXTURE_FORMAT_TOKENS: tuple[str, ...] = ( - "-blockchain_test_engine_from_state_test", - "-blockchain_test_from_state_test", - "-blockchain_test_engine", - "-blockchain_test", - "-state_test", -) + +def _fixture_format_tokens() -> tuple[str, ...]: + """ + Return the fixture format suffixes pytest appends inside parametrize ids. + """ + names = set(BaseFixture.formats) | set( + LabeledFixtureFormat.registered_labels + ) + return tuple(f"-{name}" for name in sorted(names, key=len, reverse=True)) -def _normalize_nodeid(nodeid: str) -> str: +def _normalize_nodeid(nodeid: str, tokens: tuple[str, ...]) -> str: """Strip pytest fixture-format suffixes to match the skip list format.""" - for token in _FIXTURE_FORMAT_TOKENS: + for token in tokens: nodeid = nodeid.replace(token, "") return nodeid @@ -48,6 +48,7 @@ def pytest_collection_modifyitems( skip_marker = pytest.mark.skip( reason="Ported static test gas limits not yet updated for EIP-8037" ) + tokens = _fixture_format_tokens() for item in items: if "ported_static" not in item.nodeid: continue @@ -59,7 +60,7 @@ def pytest_collection_modifyitems( # EIP-8037 breakage applies equally to its descendant forks. # Rewriting the item's fork token to Amsterdam's lets one list # cover them all. - normalized = _normalize_nodeid(item.nodeid).replace( + normalized = _normalize_nodeid(item.nodeid, tokens).replace( f"fork_{fork.name()}", "fork_Amsterdam" ) for skip_case in _AMSTERDAM_SKIP_CASES: From 4add50378c9de7950f878676eada5819fbe80922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E4=BD=B3=E8=AA=A0=20Louis=20Tsai?= <72684086+LouisTsai-Csie@users.noreply.github.com> Date: Fri, 28 Aug 2026 07:08:23 +0800 Subject: [PATCH 22/59] feat(test-vm): implement placeholder for bytecode (#2103) * feat: implement placeholder * test: add more placeholder cases * refactor: fix opcode listing issue * fix: refactor * fixes: Make `substitute` in-place, guard `bytes` method * fix: Exception types in bytecode.py --------- Co-authored-by: marioevz --- docs/navigation.md | 1 + docs/writing_tests/bytecode_placeholders.md | 180 +++++++++++++++ docs/writing_tests/index.md | 1 + .../src/execution_testing/vm/bytecode.py | 103 ++++++++- .../src/execution_testing/vm/opcodes.py | 14 ++ .../src/execution_testing/vm/tests/test_vm.py | 217 ++++++++++++++++++ 6 files changed, 513 insertions(+), 3 deletions(-) create mode 100644 docs/writing_tests/bytecode_placeholders.md diff --git a/docs/navigation.md b/docs/navigation.md index aa61e2bd46e..737bb99f0e6 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -35,6 +35,7 @@ * [Adding a State Test](writing_tests/tutorials/state_transition.md) * [Adding a Blockchain Test](writing_tests/tutorials/blockchain.md) * [Opcode Metadata](writing_tests/opcode_metadata.md) + * [Bytecode Placeholders](writing_tests/bytecode_placeholders.md) * [Filling Tests](filling_tests/index.md) * [Getting Started](filling_tests/getting_started.md) * [Filling Tests at a Prompt](filling_tests/filling_tests_command_line.md) diff --git a/docs/writing_tests/bytecode_placeholders.md b/docs/writing_tests/bytecode_placeholders.md new file mode 100644 index 00000000000..41be0fc2443 --- /dev/null +++ b/docs/writing_tests/bytecode_placeholders.md @@ -0,0 +1,180 @@ +# Advanced: Bytecode Placeholders + +## Overview + +A bytecode placeholder reserves a fixed-width slot inside a data portion so that the value can be supplied *after* the bytecode has been assembled. This breaks the circular dependency that arises whenever a value embedded in the code depends on a property of the code that contains it: + +- **Gas reserves in loops** — the loop condition compares `GAS` against the cost of one more iteration, but that cost cannot be measured until the loop (including the condition itself) has been built. +- **Self-referential offsets** — `CODECOPY` and `CREATE2` initcode offsets depend on the length of the execution code that precedes the embedded data. +- **Jump targets** — a `JUMP` destination is only known once the code up to that destination exists. + +Placeholders solve this in two passes: build the code with a named slot, measure it, then substitute the measured value back in. + +## The Problem Placeholders Replace + +Without placeholders, tests reserve space with a stand-in literal chosen to have the same encoded width as the final value, then rebuild the bytecode from scratch: + +```python +# Relies on "PUSH costs 3 gas regardless of the pushed value" +placeholder = Op.GT(Op.GAS, Op.PUSH1(0)) +per_iter_gas = While(body=body, condition=placeholder).gas_cost(fork) +``` + +```python +# Relies on "0xFF happens to be the same byte size as the final value" +placeholder_offset = 0xFF +factory_execution_template = Op.CODECOPY(0, placeholder_offset, init_code_size) + ... +``` + +Both patterns work, but the width match is an unchecked invariant maintained by a comment. If the final value needs a wider `PUSH` than the stand-in, the measurement silently describes bytecode that is no longer the bytecode being deployed. Placeholders make the width explicit and enforce it. + +## Creating a Placeholder + +Pass `data_placeholder` with a name to any opcode that takes a data portion. The data portion is zeroed and the slot is registered under that name: + +```python +code = Op.POP(Op.PUSH2(data_placeholder="loop_cost")) +``` + +The opcode you choose fixes the slot width — `PUSH2` reserves two bytes. Pick an opcode wide enough for the largest value you intend to substitute; substituting a value that does not fit is an error rather than a silent truncation. + +Any opcode with a data portion is accepted: `PUSH1`–`PUSH32`, which is the common case, as well as `DUPN`, `SWAPN`, and `EXCHANGE`. Until it is substituted, the slot holds zero, which for `DUPN` and `SWAPN` is not a valid index — see [Restrictions](#restrictions). + +The result is an ordinary `Bytecode` object: it concatenates, reports its length and gas cost, and can be nested inside further opcode calls. + +!!! warning "An open slot cannot be converted to bytes" + While any placeholder is still unsubstituted, `bytes(code)`, `code.hex()`, and equality comparisons raise: + + ```text + Exception: bytecode with active placeholders cannot be converted to bytes + ``` + + This is deliberate — the reserved slot holds zero, so silently emitting it would deploy code that pushes the wrong value. Substitute every slot before handing the bytecode to `pre.deploy_contract()`, an `Account`, or a transaction. `len()` and `gas_cost()` deliberately keep working, since measuring the template is the whole point. + +## Substituting Values + +`Bytecode.substitute()` takes placeholder names as keyword arguments and fills those slots **in place**. It returns `None`: + +```python +code = Op.POP(Op.PUSH2(data_placeholder="loop_cost")) + +code.substitute(loop_cost=1000) + +# bytes: 61 03e8 50 +assert bytes(code) == bytes(Op.POP(Op.PUSH2(1000))) +``` + +Substituted names are no longer tracked afterwards, so the same slot cannot be filled twice. + +!!! warning "Substitution mutates the bytecode" + `substitute()` does not return a copy — there is no `new_code = code.substitute(...)`, and assigning its result stores `None`. Because the object itself is modified, a template cannot be reused for two different values; build a fresh bytecode for each one. + +Concatenation does copy, so a fragment used to build a larger program keeps its own open slot: + +```python +fragment = Op.POP(Op.PUSH2(data_placeholder="value")) +combined = Op.PUSH1(0xFF) + Op.POP + fragment + +combined.substitute(value=0xBEEF) # `fragment` is untouched +``` + +## Why the Measurement Is Exact + +Because the placeholder's width is fixed when the code is built, the bytecode's length and gas cost are identical before and after substitution: + +- Every `PUSH1`–`PUSH32` costs the same 3 gas (`G_VERY_LOW`) regardless of width or pushed value, and none of them incur state gas. +- The encoded length does not change, so every offset, jump target, and code-size calculation measured on the template remains valid. + +`len()`, `gas_cost()`, `state_cost()`, and `refund()` all work on a template with open slots; only conversion to bytes is withheld. + +This is what makes the two-pass approach sound: measure on the template, then substitute. + +```python +body = Op.MSTORE(0, Op.SHA3(0, 32)) +loop = ( + Op.JUMPDEST + + body + + Op.JUMPI(Op.GT(Op.GAS, Op.PUSH2(data_placeholder="reserve")), 0) +) + +# Measure the loop, including its own condition +per_iteration = loop.gas_cost(fork) + +# Feed the measurement back into the code that produced it +loop.substitute(reserve=per_iteration) + +assert loop.gas_cost(fork) == per_iteration +``` + +## Multiple Placeholders + +A single bytecode may carry any number of placeholders, as long as their names are distinct. They can be substituted together or one at a time: + +```python +code = Op.ADD( + Op.PUSH2(data_placeholder="first"), + Op.PUSH1(data_placeholder="second"), +) + +# Together +code.substitute(first=0x1234, second=0xAB) +``` + +```python +code = Op.ADD( + Op.PUSH2(data_placeholder="first"), + Op.PUSH1(data_placeholder="second"), +) + +# Or progressively, leaving the remaining slots open +code.substitute(first=0x1234) +code.substitute(second=0xAB) +``` + +Substituting a subset is useful when the values become known at different points, for example a gas reserve known after measuring the loop and a jump target known after the surrounding program is assembled. + +## Concatenation + +Placeholder offsets are tracked through concatenation, so a template can be built up from fragments and substituted at the end: + +```python +prefix = Op.PUSH1(0xFF) + Op.POP # 3 bytes +suffix = Op.POP(Op.PUSH2(data_placeholder="value")) + +combined = prefix + suffix # offset shifts from 1 to 4 +combined.substitute(value=0xBEEF) +``` + +Because names identify slots globally within a bytecode, concatenating two fragments that use the **same** name raises an exception rather than silently dropping one: + +```python +code = Op.POP(Op.PUSH2(data_placeholder="value")) +code + code # Exception: Conflicting data placeholders between bytecode objects +``` + +Give each slot a distinct name, or substitute one fragment before combining it. + +## Restrictions + +- **The opcode must have a data portion.** `Op.ADD(1, 2, data_placeholder="x")` raises `ValueError`; there is nowhere to put the slot. +- **The name must be a string.** `Op.PUSH2(data_placeholder=1)` raises `ValueError`. +- **Substitution checks the slot width, not the opcode's own constraints.** A placeholder writes raw bytes into the data portion, so encoder validation that normally runs when the data portion is given directly is skipped. `Op.DUPN(5)` raises, because a `DUPN` index must be in `[17, 235]`, but building `Op.DUPN(data_placeholder="depth")` and substituting `depth=5` produces `e605` without complaint. For `PUSH1`–`PUSH32` this is irrelevant, since every byte value is a valid operand; for `DUPN`, `SWAPN`, and `EXCHANGE` the caller is responsible for the range. +- **Bytecode containing placeholders cannot be repeated with `*`.** Duplicating the bytes would duplicate the slot, leaving one name pointing at several offsets, so `code * 3` raises `ValueError`. Multiplying by `0` or `1` is still allowed. Substitute first, then repeat. + +## Error Reference + +| Condition | Exception | Message | +|-----------|-----------|---------| +| Opcode has no data portion | `ValueError` | ``` `data_placeholder` requires an opcode with data portion ``` | +| Name is not a string | `ValueError` | ``` `data_placeholder` must be a str ``` | +| Unknown name passed to `substitute()` | `KeyError` | `Placeholder not found in bytecode` | +| Value too large for the slot | `ValueError` | `Value doesn't fit in bytes (max )` | +| Negative value | `ValueError` | `Value -1 doesn't fit in bytes (max )` | +| Same name on both sides of `+` | `Exception` | `Conflicting data placeholders between bytecode objects` | +| `*` on bytecode with placeholders | `ValueError` | `Cannot multiply bytecode containing placeholders` | +| `bytes()`, `hex()`, or `==` while a slot is open | `Exception` | `bytecode with active placeholders cannot be converted to bytes` | + +## Related + +- [Opcode Metadata and Gas Calculations](./opcode_metadata.md) — how `gas_cost(fork)`, `state_cost(fork)`, and `refund(fork)` derive the measurements that get substituted back in. +- [Gas Optimization](./gas_optimization.md) — choosing gas limits for tests. diff --git a/docs/writing_tests/index.md b/docs/writing_tests/index.md index 839903fed40..ec06527fbfc 100644 --- a/docs/writing_tests/index.md +++ b/docs/writing_tests/index.md @@ -30,5 +30,6 @@ For help deciding which test format to select, see [Types of Tests](./types_of_t ## Advanced Topics - [Opcode Metadata and Gas Calculations](./opcode_metadata.md) - Calculate gas costs and refunds using opcode metadata (advanced feature for gas-focused tests) +- [Bytecode Placeholders](./bytecode_placeholders.md) - Embed a value that depends on the bytecode containing it, by reserving a fixed-width slot and substituting the measured value Please check that your code adheres to the repo's coding standards and read the other pages in this section for more background and an explanation of how to implement state transition and blockchain tests. diff --git a/packages/testing/src/execution_testing/vm/bytecode.py b/packages/testing/src/execution_testing/vm/bytecode.py index fdb73d2fa0c..a311e49d0f8 100644 --- a/packages/testing/src/execution_testing/vm/bytecode.py +++ b/packages/testing/src/execution_testing/vm/bytecode.py @@ -1,6 +1,6 @@ """Ethereum Virtual Machine bytecode primitives and utilities.""" -from typing import Any, List, Self, SupportsBytes, Type +from typing import Any, Dict, List, Self, SupportsBytes, Type from pydantic import GetCoreSchemaHandler from pydantic_core.core_schema import ( @@ -52,6 +52,8 @@ class Bytecode: terminating: bool opcode_list: List[OpcodeBase] + _placeholder_offsets: Dict[str, int] + _placeholder_sizes: Dict[str, int] def __new__( cls, @@ -64,10 +66,25 @@ def __new__( terminating: bool = False, name: str = "", opcode_list: List[OpcodeBase] | None = None, + placeholder_offsets: Dict[str, int] | None = None, + placeholder_sizes: Dict[str, int] | None = None, ) -> Self: """Create new opcode instance.""" if opcode_list is None: opcode_list = [] + if placeholder_offsets is not None or placeholder_sizes is not None: + if placeholder_offsets is None or placeholder_sizes is None: + raise ValueError( + f"incongruent parameters: placeholder_offsets " + f"({placeholder_offsets}) placeholder_sizes " + f"({placeholder_sizes})" + ) + if len(placeholder_offsets) != len(placeholder_sizes): + raise ValueError( + f"incongruent parameters: len(placeholder_offsets) " + f"({len(placeholder_offsets)}) len(placeholder_sizes) " + f"({len(placeholder_sizes)})" + ) if bytes_or_byte_code_base is None: instance = super().__new__(cls) instance._bytes_ = b"" @@ -78,6 +95,9 @@ def __new__( instance.terminating = False instance._name_ = name instance.opcode_list = opcode_list + instance._placeholder_offsets = placeholder_offsets or {} + instance._placeholder_sizes = placeholder_sizes or {} + return instance if isinstance(bytes_or_byte_code_base, Bytecode): @@ -92,6 +112,12 @@ def __new__( obj.terminating = bytes_or_byte_code_base.terminating obj.opcode_list = bytes_or_byte_code_base.opcode_list[:] obj._name_ = bytes_or_byte_code_base._name_ + obj._placeholder_offsets = ( + bytes_or_byte_code_base._placeholder_offsets.copy() + ) + obj._placeholder_sizes = ( + bytes_or_byte_code_base._placeholder_sizes.copy() + ) return obj if isinstance(bytes_or_byte_code_base, bytes): @@ -114,6 +140,8 @@ def __new__( obj.terminating = terminating obj.opcode_list = opcode_list obj._name_ = name + obj._placeholder_offsets = placeholder_offsets or {} + obj._placeholder_sizes = placeholder_sizes or {} return obj raise TypeError( @@ -122,6 +150,11 @@ def __new__( def __bytes__(self) -> bytes: """Return the opcode byte representation.""" + if self._placeholder_offsets or self._placeholder_sizes: + raise ValueError( + "bytecode with active placeholders cannot be converted to " + "bytes" + ) return self._bytes_ def __len__(self) -> int: @@ -228,7 +261,7 @@ def __add__(self, other: "Bytecode | bytes | int | None") -> "Bytecode": c_min + a_max - a_min, c_min - a_pop + a_push + b_max - b_min ) - return Bytecode( + c = Bytecode( self._bytes_ + other._bytes_, popped_stack_items=c_pop, pushed_stack_items=c_push, @@ -237,6 +270,26 @@ def __add__(self, other: "Bytecode | bytes | int | None") -> "Bytecode": terminating=other.terminating, opcode_list=self.opcode_list + other.opcode_list, ) + # Merge placeholders, adjusting offsets for 'other' + if ( + len( + self._placeholder_offsets.keys() + & other._placeholder_offsets.keys() + ) + != 0 + ): + raise ValueError( + "Conflicting data placeholders between bytecode objects: " + f"{self._placeholder_offsets.keys()}, " + f"{other._placeholder_offsets.keys()}" + ) + c._placeholder_offsets = self._placeholder_offsets.copy() + c._placeholder_sizes = ( + self._placeholder_sizes | other._placeholder_sizes + ) + for placeholder, offset in other._placeholder_offsets.items(): + c._placeholder_offsets[placeholder] = len(self) + offset + return c def __radd__(self, other: "Bytecode | int | None") -> "Bytecode": """ @@ -261,6 +314,11 @@ def __mul__(self, other: int) -> "Bytecode": if other == 1: return Bytecode(self) + if self._placeholder_offsets or self._placeholder_sizes: + raise ValueError( + "Cannot multiply bytecode containing placeholders" + ) + result_bytes = self._bytes_ * other a_pop = self.popped_stack_items @@ -295,7 +353,7 @@ def hex(self) -> str: def keccak256(self) -> Hash: """Return the keccak256 hash of the opcode byte representation.""" if self._keccak_256_ is None: - self._keccak_256_ = Bytes(self._bytes_).keccak256() + self._keccak_256_ = Bytes(self).keccak256() return self._keccak_256_ def gas_cost(self, fork: Type[ForkOpcodeInterface]) -> int: @@ -345,6 +403,45 @@ def refund(self, fork: Type[ForkOpcodeInterface]) -> int: self._refund_ += opcode_refund_calculator(opcode) return self._refund_ + def substitute(self, **kwargs: int) -> None: + """ + Replace named placeholders with actual values. + + Args: + kwargs: The placeholders and their values to set + + Raises: + ValueError: If a value doesn't fit in the placeholder's size + KeyError: If a placeholder name is not found in this bytecode + + """ + for placeholder, value in kwargs.items(): + if placeholder not in self._placeholder_offsets: + raise KeyError( + f"Placeholder {placeholder} not found in bytecode" + ) + + max_value = (1 << (self._placeholder_sizes[placeholder] * 8)) - 1 + if value < 0 or value > max_value: + raise ValueError( + f"Value {value} doesn't fit in " + f"{self._placeholder_sizes[placeholder]} bytes " + f"(max {max_value})" + ) + + offset, size = ( + self._placeholder_offsets.pop(placeholder), + self._placeholder_sizes.pop(placeholder), + ) + + # Replace the placeholder bytes with the actual value + self._bytes_ = ( + self._bytes_[:offset] + + value.to_bytes(size, "big") + + self._bytes_[(offset + size) :] + ) + self._keccak_256_ = None + def state_refund(self, fork: Type[ForkOpcodeInterface]) -> int: """ Use a fork object to calculate the state refund from this bytecode. diff --git a/packages/testing/src/execution_testing/vm/opcodes.py b/packages/testing/src/execution_testing/vm/opcodes.py index 7402e55dcca..f468bab77a6 100644 --- a/packages/testing/src/execution_testing/vm/opcodes.py +++ b/packages/testing/src/execution_testing/vm/opcodes.py @@ -378,6 +378,20 @@ def __call__( # Nothing else to do, return return opcode + if "data_placeholder" in kwargs: + if not opcode.has_data_portion(): + raise ValueError( + "`data_placeholder` requires an opcode with data portion" + ) + data_placeholder = kwargs.pop("data_placeholder") + if not isinstance(data_placeholder, str): + raise ValueError("`data_placeholder` must be a str") + data_size = opcode.data_portion_length + opcode = opcode[b"\0" * data_size] + + opcode._placeholder_offsets = {data_placeholder: 1} + opcode._placeholder_sizes = {data_placeholder: data_size} + if opcode.has_data_portion(): if len(args) == 0: raise ValueError( diff --git a/packages/testing/src/execution_testing/vm/tests/test_vm.py b/packages/testing/src/execution_testing/vm/tests/test_vm.py index 85fe28fed58..abd3f08deb1 100644 --- a/packages/testing/src/execution_testing/vm/tests/test_vm.py +++ b/packages/testing/src/execution_testing/vm/tests/test_vm.py @@ -3,6 +3,7 @@ import pytest from execution_testing.base_types import Address +from execution_testing.forks.forks.forks import Prague from ..opcodes import Bytecode from ..opcodes import Macros as Om @@ -467,3 +468,219 @@ def test_opcode_kwargs_validation() -> None: ValueError, match=r"Invalid keyword argument\(s\).*for opcode MSTORE" ): Op.MSTORE(offest=0, valu=1, extra=2) # codespell:ignore offest,valu + + +def test_placeholder_requires_data_portion() -> None: + """Test that a placeholder requires an opcode with a data portion.""" + with pytest.raises( + ValueError, + match="`data_placeholder` requires an opcode with data portion", + ): + Op.ADD(1, 2, data_placeholder="value") + + +@pytest.mark.parametrize( + "placeholder_offsets,placeholder_sizes", + [ + pytest.param({"value": 1}, None, id="offsets_only"), + pytest.param(None, {"value": 2}, id="sizes_only"), + pytest.param( + {"value": 1, "other": 4}, {"value": 2}, id="length_mismatch" + ), + ], +) +def test_placeholder_incongruent_parameters( + placeholder_offsets: dict[str, int] | None, + placeholder_sizes: dict[str, int] | None, +) -> None: + """Test that placeholder offsets and sizes must agree with each other.""" + with pytest.raises(ValueError, match="incongruent parameters"): + Bytecode( + placeholder_offsets=placeholder_offsets, + placeholder_sizes=placeholder_sizes, + ) + + +@pytest.mark.parametrize( + "size,value", + [ + pytest.param(1, 0x42, id="PUSH1"), + pytest.param(2, 0x1234, id="PUSH2"), + pytest.param(3, 0x123456, id="PUSH3"), + pytest.param(4, 0x12345678, id="PUSH4"), + pytest.param(8, 0xFF, id="PUSH8"), + pytest.param(16, 0xABCD, id="PUSH16"), + pytest.param(32, 0xDEADBEEF, id="PUSH32"), + ], +) +def test_placeholder_substitute_basic(size: int, value: int) -> None: + """Test basic placeholder substitution functionality.""" + push_op = getattr(Op, f"PUSH{size}") + code = Op.POP(push_op(data_placeholder="value")) + + # The placeholder is sized after the opcode's data portion + assert code._placeholder_sizes == {"value": size} + + # Substitute the actual value + code.substitute(value=value) + assert bytes(code) == bytes(Op.POP(push_op(value))) + + +@pytest.mark.parametrize( + "size", + [ + pytest.param(1, id="PUSH1"), + pytest.param(2, id="PUSH2"), + pytest.param(4, id="PUSH4"), + pytest.param(8, id="PUSH8"), + pytest.param(32, id="PUSH32"), + ], +) +def test_placeholder_substitute_out_of_range(size: int) -> None: + """Test that substitute rejects values that don't fit.""" + push_op = getattr(Op, f"PUSH{size}") + code = Op.POP(push_op(data_placeholder="value")) + + for out_of_range in (-1, 256**size): + with pytest.raises(ValueError, match="doesn't fit"): + code.substitute(value=out_of_range) + + # Max value should work + max_value = 256**size - 1 + code.substitute(value=max_value) + assert bytes(code) == bytes(Op.POP(push_op(max_value))) + + +def test_placeholder_substitute_not_found() -> None: + """Test that substitute raises error for an unknown placeholder.""" + code = Op.POP(Op.PUSH2(data_placeholder="value")) + + with pytest.raises(KeyError, match="not found in bytecode"): + code.substitute(other_value=0x1234) + + +def test_placeholder_bytes_guard() -> None: + """Test that bytecode with an open placeholder cannot become bytes.""" + code = Op.POP(Op.PUSH2(data_placeholder="value")) + reference = Op.POP(Op.PUSH2(0)) + + with pytest.raises(ValueError, match="active placeholders"): + bytes(code) + + with pytest.raises(ValueError, match="active placeholders"): + code.hex() + + with pytest.raises(ValueError, match="active placeholders"): + code.keccak256() + + # Length and gas cost remain available, which is what makes the + # measure-then-substitute pattern possible + assert len(code) == len(reference) + assert code.gas_cost(Prague) == reference.gas_cost(Prague) + + # Once every slot is filled the conversion succeeds + code.substitute(value=0x1234) + filled = Op.POP(Op.PUSH2(0x1234)) + assert bytes(code) == bytes(filled) + assert code.keccak256() == filled.keccak256() + + +def test_multiple_placeholders() -> None: + """Test multiple placeholders in the same bytecode.""" + code = Op.ADD( + Op.PUSH2(data_placeholder="first"), + Op.PUSH1(data_placeholder="second"), + ) + expected = bytes(Op.ADD(Op.PUSH2(0x1234), Op.PUSH1(0xAB))) + + assert code._placeholder_sizes == {"first": 2, "second": 1} + + # Substitute first placeholder, second should remain + code.substitute(first=0x1234) + assert "first" not in code._placeholder_offsets + assert "second" in code._placeholder_offsets + + # Substitute second placeholder + code.substitute(second=0xAB) + assert not code._placeholder_offsets + assert bytes(code) == expected + + +def test_placeholder_offset_after_concatenation() -> None: + """Test that placeholder offsets are adjusted after concatenation.""" + prefix = Op.PUSH1(0xFF) + Op.POP + suffix = Op.POP(Op.PUSH2(data_placeholder="value")) + + combined = prefix + suffix + + # The placeholder offset should account for the prefix length + assert combined._placeholder_offsets["value"] == ( + len(prefix) + suffix._placeholder_offsets["value"] + ) + assert combined._placeholder_sizes == suffix._placeholder_sizes + + # Substitution should still produce correct bytecode + combined.substitute(value=0xBEEF) + expected = prefix + Op.POP(Op.PUSH2(0xBEEF)) + assert bytes(combined) == bytes(expected) + + +def test_placeholder_conflicting_names_raise() -> None: + """Test that concatenating a reused placeholder name raises.""" + code = Op.POP(Op.PUSH2(data_placeholder="value")) + + with pytest.raises(ValueError, match="Conflicting data placeholders"): + code + code + + # Distinct names concatenate without complaint + other = Op.POP(Op.PUSH2(data_placeholder="other_value")) + combined = code + other + assert set(combined._placeholder_offsets) == {"value", "other_value"} + + +def test_placeholder_mul_raises() -> None: + """Test that multiplying bytecode with placeholders raises.""" + code = Op.POP(Op.PUSH2(data_placeholder="value")) + + with pytest.raises(ValueError, match="Cannot multiply.*placeholders"): + code * 3 + + # Multiplying by 0 and 1 should still work + assert bytes(code * 0) == b"" + assert len(code * 1) == len(code) + assert "value" in (code * 1)._placeholder_offsets + + +def test_placeholder_in_opcode_list() -> None: + """Test that placeholder PUSH opcode is included in opcode_list.""" + code = Op.POP(Op.PUSH2(data_placeholder="value")) + + # The opcode_list should contain the PUSH2 and POP opcodes + assert len(code.opcode_list) == 2 + assert code.opcode_list[0] == Op.PUSH2 + assert code.opcode_list[1] == Op.POP + + +def test_placeholder_in_complex_bytecode() -> None: + """Test placeholder in more complex bytecode constructions.""" + code = ( + Op.JUMPDEST + + Op.PUSH1(1) + + Op.ADD + + Op.DUP1 + + Op.JUMPI( + Op.GT(Op.GAS, Op.PUSH2(data_placeholder="loop_cost")), + 0, + ) + + Op.STOP + ) + + # Placeholder should be tracked + assert "loop_cost" in code._placeholder_offsets + + # Substitute and verify the bytecode is valid + code.substitute(loop_cost=1000) + assert "loop_cost" not in code._placeholder_offsets + + # Verify the value 1000 (0x03E8) appears in the bytecode + assert b"\x03\xe8" in bytes(code) From d8a126ea4b67cacc592b5f1f7a896609526ce794 Mon Sep 17 00:00:00 2001 From: Max Gorbuk Date: Fri, 28 Aug 2026 10:30:52 +0300 Subject: [PATCH 23/59] fix(test-cli): ignore `tests/{json_loader,spec_tools}` with `eip_version_check` (#3461) --- .../pytest_ini_files/pytest-check-eip-versions.ini | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-check-eip-versions.ini b/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-check-eip-versions.ini index 4131774c808..4fe8e966188 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-check-eip-versions.ini +++ b/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-check-eip-versions.ini @@ -15,4 +15,6 @@ addopts = -p execution_testing.cli.pytest_commands.plugins.shared.transaction_fixtures -p execution_testing.cli.pytest_commands.plugins.help.help -m eip_version_check - --tb short \ No newline at end of file + --tb short + --ignore tests/json_loader + --ignore tests/spec_tools From 241f6a80da5458443325b5ac8fb56a0754a3ec38 Mon Sep 17 00:00:00 2001 From: spencer Date: Fri, 28 Aug 2026 22:20:38 +0200 Subject: [PATCH 24/59] fix(tests): enhance & un-skip Amsterdam ported static SSTORE, refund & misc tests (Pt. 2c) (#3321) * fix(tests): enhance & un-skip Amsterdam ported static SSTORE, refund & misc tests (Pt. 2c) * refactor(tests): Further refactor tests and expand coverage * fix(test-forks): Add EIP-160 mixin * fix(test-forks): Fix CODE_INIT_PER_WORD pre-Shanghai --------- Co-authored-by: marioevz --- .claude/commands/enhance-ported-test.md | 98 +- .../forks/forks/eips/london/eip_3529.py | 11 + .../forks/forks/eips/shanghai/eip_3860.py | 9 + .../forks/eips/spurious_dragon/eip_160.py | 24 + .../execution_testing/forks/forks/forks.py | 7 +- tests/frontier/opcodes/test_all_opcodes.py | 6 + tests/ported_static/amsterdam_skip_list.txt | 65 +- .../stAttackTest/test_crashing_transaction.py | 76 +- .../test_callcallcallcode_001_suicide_end.py | 196 +- .../test_callcallcallcode_001_suicide_end.py | 156 -- .../test_callcallcallcode_001_suicide_end.py | 126 - .../test_create_address_warm_after_fail.py | 1126 +++------ .../test_new_gas_price_for_codes.py | 305 ++- .../test_gas_cost.py | 1274 ----------- .../test_gas_cost_berlin.py | 1003 -------- .../stEIP158Specific/test_exp_empty.py | 129 +- ...nt_leave_empty_contract_via_transaction.py | 124 +- ...rice_for_codes_with_mem_expanding_calls.py | 155 -- tests/ported_static/stMemoryTest/test_oog.py | 1158 ++++------ .../stRefundTest/test_refund600.py | 116 +- .../stSStoreTest/test_sstore_gas.py | 253 +-- .../stSStoreTest/test_sstore_gas_left.py | 473 +--- .../test_recursive_create_contracts.py | 453 ++-- .../test_test_contract_interaction.py | 366 ++- .../test_test_contract_suicide.py | 404 +++- .../test_double_selfdestruct_touch_paris.py | 241 +- .../test_opcodes_transaction_init.py | 2018 +++++------------ .../test_store_gas_on_create.py | 104 +- ...ides_and_internal_call_suicides_success.py | 153 +- 29 files changed, 3085 insertions(+), 7544 deletions(-) create mode 100644 packages/testing/src/execution_testing/forks/forks/eips/spurious_dragon/eip_160.py delete mode 100644 tests/ported_static/stCallDelegateCodesCallCodeHomestead/test_callcallcallcode_001_suicide_end.py delete mode 100644 tests/ported_static/stCallDelegateCodesHomestead/test_callcallcallcode_001_suicide_end.py delete mode 100644 tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost.py delete mode 100644 tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_berlin.py delete mode 100644 tests/ported_static/stMemExpandingEIP150Calls/test_new_gas_price_for_codes_with_mem_expanding_calls.py diff --git a/.claude/commands/enhance-ported-test.md b/.claude/commands/enhance-ported-test.md index 67ec0bdfd2c..e14b22289db 100644 --- a/.claude/commands/enhance-ported-test.md +++ b/.claude/commands/enhance-ported-test.md @@ -526,69 +526,11 @@ persists with the sentinel) plus the callee-side observable already separate the outcomes. Validated on `test_contract_creation_make_call_that_ask_more_gas_then_transaction_provided`. -**Any *exact* budget off the intrinsic calculator needs the EIP-7623 -kwarg.** `transaction_intrinsic_cost_calculator()` returns `max(intrinsic, -calldata_floor)`, but the floor is only compared against *after* execution — -it is never deducted up front. So whenever the derived number stands for -"gas taken before the first opcode" — a one-gas OOG boundary, an exact -success budget, an EIP-3529 refund cap — pass -`return_cost_deducted_prior_execution=True` if the transaction carries -calldata. **The bug hides until an EIP-7981 fork:** the delta is 0 from -Berlin through Osaka and 238 on Amsterdam for a 5-byte creation payload, so -a boundary tuned without the kwarg passes on every fork that exists today -and silently goes slack on the future one — and the symptom is an *OOG arm -that stops OOG-ing*, i.e. a test that fails only once someone runs -`--fork Amsterdam`. -**Keep the default (no kwarg) when the number is a validity floor** rather -than an execution budget: a tx whose `gas_limit` falls below -`max(intrinsic, floor)` is rejected outright, which is a different outcome -from running out of gas. -Validated on `test_refund_suicide50procent_cap` and -`test_transaction_collision_to_empty2`, whose OOG arm had been loosened to -"half the store's cost" to work around this, with a comment misattributing -it to a pre-Shanghai init-code word cost. - -**Read the trace when a derived budget does not line up.** `fill --traces ---evm-dump-dir ` writes, per transaction, `input/txs.json`, -`output/result.json` and a `trace-*.jsonl`. Subtracting the first trace -entry's `gas` from the tx `gas` gives the intrinsic the EVM *actually* -charged, which is what settles a disagreement with any calculator; on -EIP-8037 forks the per-step `gasCost`/`stateGasCost` split shows where a -composite's cost really lands. Far faster than bisecting `gas_limit` by -re-filling, and it produced the 238 above in one run. - -**Gates are not costs, and `gas_cost()` only knows costs.** Several EVM -rules are *preconditions on `gas_left`* rather than charges, so a budget -derived from `gas_cost(fork)` is exactly right and still too small. The -canonical one is EIP-2200 (Istanbul+): **any** `SSTORE` halts when it runs -with `CALL_STIPEND` (2300) gas or less still available — including a -100-gas dirty-warm rewrite. No amount of fixing `_calculate_sstore*` can -express that, because it is not part of the price. - -*Tell it apart in a trace:* a gated halt shows `gasCost: 0x0` next to an -`error`, while a genuine can't-afford shows the real cost. `SSTORE -gas_left=2300 cost=0 OutOfGasError` is the signature. - -*Derive the headroom instead of padding.* The **last** gated op is the one -running closest to empty, so it sets the requirement: -``` -last_charge = Op.SSTORE(key_warm=True, original_value=…, current_value=…, - new_value=…).gas_cost(fork) # bare `55`, no PUSHes -headroom = fork.gas_costs().CALL_STIPEND - last_charge + 1 -gas_limit = overhead + code.gas_cost(fork) + headroom -``` -A bare opcode carrying only metadata prices the charge alone (same trick as -`Op.RETURN(code_deposit_size=n)`). This is what turns "add 5,000 and hope" -into a real one-gas boundary — and because it targets only the *last* gated -op, it is invariant in how many precede it. Validated on -`test_out_of_gas_contract_creation`, whose arms now straddle -`gas_left = 2301` / `2300` exactly. - -*Same family, when an "impossible" budget is really a gate:* an -`SSTORE` in a `STATICCALL` subtree, `RETURNDATACOPY` reading past the -return buffer, stack under/overflow, an EIP-684 address collision. Each -aborts the frame outright rather than charging for it, so the fix is -always to model the gate, never to inflate the budget until it passes. +**Refund-cap derivations need the EIP-7623 kwarg.** The EIP-3529 cap's +base is the gas deducted before execution, which excludes the calldata +floor: pass `return_cost_deducted_prior_execution=True` to the intrinsic +calculator whenever the tx has calldata, or the derived `executed` (and +the cap) overstate. Validated on `test_refund_suicide50procent_cap`. **A CREATE address collision burns the child's gas allowance** (the EIP-684 path): the withheld child grant is consumed, nothing is created, @@ -609,27 +551,15 @@ a derived budget that must survive pre-Istanbul forks needs an explicit headroom constant for it (named, commented). Observed on `test_revert_depth_create_address_collision`'s ConstantinopleFix sweep. -**Code-deposit cost comes from `Op.RETURN`'s metadata — never a per-byte -formula.** Annotate the init code's terminator, -`Op.RETURN(offset=o, size=n, code_deposit_size=n)`, and `gas_cost(fork)` -includes the deposit charge, correct on both sides of the EIP-8037 boundary -(10 bytes costs 2,000 before it; 15,306 after, once the per-byte price became -state gas). The annotated and unannotated forms assemble to **identical -bytes**, so building both yields an exact two-sided boundary out of the fork's -own model, with no EIP branch: -``` -child = stage + Op.RETURN(offset=o, size=n) -child_with_deposit = stage + Op.RETURN(offset=o, size=n, code_deposit_size=n) -assert child.gas_cost(fork) <= granted < child_with_deposit.gas_cost(fork) -``` -`fork.gas_costs().CODE_DEPOSIT_PER_BYTE` (200) is a **last resort**: it is the -*pre-8037* constant and understates the real charge ~7.5x on 8037 forks. Use -it only where understating is the safe direction — a *sufficiency* budget -overshoots, an "is this unaffordable?" guard stays conservative — and never in -a one-gas-short *boundary*, which it silently funds on Amsterdam. Branching on -`fork.is_eip_enabled(8037)` to patch it up is the wrong fix; the metadata -removes the branch entirely. Validated on -`test_create_oog_after_init_code_returndata_size`. +**EIP-8037 repriced the code deposit's regular part — boundaries beware.** +On 8037 forks the deposit charges only the keccak word cost +(`OPCODE_KECCAK256_PER_WORD * ceil32(len)/32`, ~6 gas) as regular gas plus +`len * 1530` state; `fork.gas_costs().CODE_DEPOSIT_PER_BYTE` (200) is the +*pre-8037* constant. Using 200/byte in a *sufficiency* budget merely +overshoots (safe); using it in a one-gas-short *boundary* silently funds +the deposit on Amsterdam. Branch on `fork.is_eip_enabled(8037)` for exact +deposit boundaries. Validated on +`test_create_oo_gafter_init_code_returndata_size`. **Match the intrinsic calculator's kwargs to the transaction's shape.** `fork.transaction_intrinsic_cost_calculator()()` defaults to diff --git a/packages/testing/src/execution_testing/forks/forks/eips/london/eip_3529.py b/packages/testing/src/execution_testing/forks/forks/eips/london/eip_3529.py index beb12140d94..8baab195a29 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/london/eip_3529.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/london/eip_3529.py @@ -6,12 +6,23 @@ https://eips.ethereum.org/EIPS/eip-3529 """ +from dataclasses import replace + from ....base_fork import BaseFork +from ....gas_costs import GasCosts class EIP3529(BaseFork): """EIP-3529 class.""" + @classmethod + def gas_costs(cls) -> GasCosts: + """Storage clearing refund is reduced from 15000 to 4800.""" + return replace( + super(EIP3529, cls).gas_costs(), + REFUND_STORAGE_CLEAR=4_800, + ) + @classmethod def max_refund_quotient(cls) -> int: """Max refund quotient is increased to 5 (reducing refunds).""" diff --git a/packages/testing/src/execution_testing/forks/forks/eips/shanghai/eip_3860.py b/packages/testing/src/execution_testing/forks/forks/eips/shanghai/eip_3860.py index 01a5c2d6eda..725f0ef42b4 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/shanghai/eip_3860.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/shanghai/eip_3860.py @@ -7,6 +7,7 @@ https://eips.ethereum.org/EIPS/eip-3860 """ +from dataclasses import replace from typing import List, Sized from execution_testing.base_types import AccessList, Bytes @@ -22,6 +23,14 @@ class EIP3860(BaseFork): """EIP-3860 class.""" + @classmethod + def gas_costs(cls) -> GasCosts: + """Introduce the per-word initcode metering cost.""" + return replace( + super(EIP3860, cls).gas_costs(), + CODE_INIT_PER_WORD=2, + ) + @classmethod def max_initcode_size(cls) -> int: """Initcode size is limited.""" diff --git a/packages/testing/src/execution_testing/forks/forks/eips/spurious_dragon/eip_160.py b/packages/testing/src/execution_testing/forks/forks/eips/spurious_dragon/eip_160.py new file mode 100644 index 00000000000..decfa4e372c --- /dev/null +++ b/packages/testing/src/execution_testing/forks/forks/eips/spurious_dragon/eip_160.py @@ -0,0 +1,24 @@ +""" +EIP-160: EXP cost increase. + +Raise the per-byte charge for EXP's exponent operand from 10 to 50. + +https://eips.ethereum.org/EIPS/eip-160 +""" + +from dataclasses import replace + +from ....base_fork import BaseFork +from ....gas_costs import GasCosts + + +class EIP160(BaseFork): + """EIP-160 class.""" + + @classmethod + def gas_costs(cls) -> GasCosts: + """Raise the EXP per-exponent-byte gas cost to 50.""" + return replace( + super(EIP160, cls).gas_costs(), + OPCODE_EXP_PER_BYTE=50, + ) diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index d30f8fd376f..37562f59a01 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -111,7 +111,6 @@ def gas_costs(cls) -> GasCosts: NEW_ACCOUNT=25_000, # Contract Creation CODE_DEPOSIT_PER_BYTE=200, - CODE_INIT_PER_WORD=2, # Authorization AUTH_PER_EMPTY_ACCOUNT=0, # Utility @@ -124,7 +123,7 @@ def gas_costs(cls) -> GasCosts: TX_DATA_PER_NON_ZERO=68, TX_CREATE=32_000, # Refunds - REFUND_STORAGE_CLEAR=4_800, + REFUND_STORAGE_CLEAR=15_000, REFUND_AUTH_PER_EXISTING_ACCOUNT=0, # Precompiles PRECOMPILE_ECRECOVER=3_000, @@ -175,7 +174,7 @@ def gas_costs(cls) -> GasCosts: OPCODE_COPY_PER_WORD=3, OPCODE_CREATE_BASE=32_000, OPCODE_EXP_BASE=10, - OPCODE_EXP_PER_BYTE=50, + OPCODE_EXP_PER_BYTE=10, OPCODE_LOG_BASE=375, OPCODE_LOG_DATA_PER_BYTE=8, OPCODE_LOG_TOPIC=375, @@ -183,6 +182,7 @@ def gas_costs(cls) -> GasCosts: OPCODE_KECCAK256_PER_WORD=6, # Zero-initialized: introduced in later forks, set via # replace() in the fork that activates them. + CODE_INIT_PER_WORD=0, TX_DATA_TOKEN_STANDARD=0, TX_DATA_TOKEN_FLOOR=0, PRECOMPILE_ECADD=0, @@ -1373,6 +1373,7 @@ class TangerineWhistle( class SpuriousDragon( eips.EIP170, eips.EIP161, + eips.EIP160, eips.EIP155, TangerineWhistle, ruleset_name="SPURIOUS", diff --git a/tests/frontier/opcodes/test_all_opcodes.py b/tests/frontier/opcodes/test_all_opcodes.py index 8429e9661d2..bda207d269d 100644 --- a/tests/frontier/opcodes/test_all_opcodes.py +++ b/tests/frontier/opcodes/test_all_opcodes.py @@ -298,6 +298,12 @@ def constant_gas_opcodes(fork: Fork) -> Generator[ParameterSet, None, None]: ) +@pytest.mark.ported_from( + [ + "state_tests/stEIP150singleCodeGasPrices/gasCostFiller.yml", + "state_tests/stEIP150singleCodeGasPrices/gasCostBerlinFiller.yml", + ], +) @pytest.mark.valid_from("Berlin") @pytest.mark.parametrize_by_fork("opcode", constant_gas_opcodes) @pytest.mark.eels_base_coverage diff --git a/tests/ported_static/amsterdam_skip_list.txt b/tests/ported_static/amsterdam_skip_list.txt index e4c6faa9429..2aa957df599 100644 --- a/tests/ported_static/amsterdam_skip_list.txt +++ b/tests/ported_static/amsterdam_skip_list.txt @@ -10,8 +10,7 @@ # # Total entries: 86 -# stAttackTest (1) -stAttackTest/test_crashing_transaction.py::test_crashing_transaction[fork_Amsterdam] +# stAttackTest (0) # stBadOpcode (0) @@ -19,8 +18,7 @@ stAttackTest/test_crashing_transaction.py::test_crashing_transaction[fork_Amster # stCallCreateCallCodeTest (0) -# stCallDelegateCodesCallCodeHomestead (1) -stCallDelegateCodesCallCodeHomestead/test_callcallcallcode_001_suicide_end.py::test_callcallcallcode_001_suicide_end[fork_Amsterdam] +# stCallDelegateCodesCallCodeHomestead (0) # stCreate2 (31) stCreate2/test_create2_oo_gafter_init_code_revert2.py::test_create2_oo_gafter_init_code_revert2[fork_Amsterdam] @@ -55,49 +53,21 @@ stCreate2/test_revert_depth_create_address_collision_berlin.py::test_revert_dept stCreate2/test_revert_depth_create_address_collision_berlin.py::test_revert_depth_create_address_collision_berlin[fork_Amsterdam-d1-g1-v0] stCreate2/test_revert_depth_create_address_collision_berlin.py::test_revert_depth_create_address_collision_berlin[fork_Amsterdam-d1-g1-v1] -# stCreateTest (13) -stCreateTest/test_create_address_warm_after_fail.py::test_create_address_warm_after_fail[fork_Amsterdam-create-0xef-v1] -stCreateTest/test_create_address_warm_after_fail.py::test_create_address_warm_after_fail[fork_Amsterdam-create-contructor-revert-v1] -stCreateTest/test_create_address_warm_after_fail.py::test_create_address_warm_after_fail[fork_Amsterdam-create-high-nonce-v1] -stCreateTest/test_create_address_warm_after_fail.py::test_create_address_warm_after_fail[fork_Amsterdam-create-invalid-opcode-v1] -stCreateTest/test_create_address_warm_after_fail.py::test_create_address_warm_after_fail[fork_Amsterdam-create-ok-v1] -stCreateTest/test_create_address_warm_after_fail.py::test_create_address_warm_after_fail[fork_Amsterdam-create-oog-constructor-v1] -stCreateTest/test_create_address_warm_after_fail.py::test_create_address_warm_after_fail[fork_Amsterdam-create-oog-post-constr-v1] -stCreateTest/test_create_address_warm_after_fail.py::test_create_address_warm_after_fail[fork_Amsterdam-create2-0xef-v1] -stCreateTest/test_create_address_warm_after_fail.py::test_create_address_warm_after_fail[fork_Amsterdam-create2-contructor-revert-v1] -stCreateTest/test_create_address_warm_after_fail.py::test_create_address_warm_after_fail[fork_Amsterdam-create2-invalid-opcode-v1] -stCreateTest/test_create_address_warm_after_fail.py::test_create_address_warm_after_fail[fork_Amsterdam-create2-ok-v1] -stCreateTest/test_create_address_warm_after_fail.py::test_create_address_warm_after_fail[fork_Amsterdam-create2-oog-constructor-v1] -stCreateTest/test_create_address_warm_after_fail.py::test_create_address_warm_after_fail[fork_Amsterdam-create2-oog-post-constr-v1] - # stDelegatecallTestHomestead (0) -# stEIP150Specific (1) -stEIP150Specific/test_new_gas_price_for_codes.py::test_new_gas_price_for_codes[fork_Amsterdam] - -# stEIP150singleCodeGasPrices (2) -stEIP150singleCodeGasPrices/test_gas_cost.py::test_gas_cost[fork_Amsterdam-d40] -stEIP150singleCodeGasPrices/test_gas_cost_berlin.py::test_gas_cost_berlin[fork_Amsterdam-d40] +# stEIP150singleCodeGasPrices (0) -# stEIP158Specific (1) -stEIP158Specific/test_exp_empty.py::test_exp_empty[fork_Amsterdam] +# stEIP158Specific (0) -# stHomesteadSpecific (1) -stHomesteadSpecific/test_contract_creation_oo_gdont_leave_empty_contract_via_transaction.py::test_contract_creation_oo_gdont_leave_empty_contract_via_transaction[fork_Amsterdam] +# stHomesteadSpecific (0) # stInitCodeTest (0) -# stMemExpandingEIP150Calls (1) -stMemExpandingEIP150Calls/test_new_gas_price_for_codes_with_mem_expanding_calls.py::test_new_gas_price_for_codes_with_mem_expanding_calls[fork_Amsterdam] +# stMemoryTest (0) -# stMemoryTest (2) -stMemoryTest/test_oog.py::test_oog[fork_Amsterdam-success14] -stMemoryTest/test_oog.py::test_oog[fork_Amsterdam-success15] - -# stRefundTest (7) +# stRefundTest (6) stRefundTest/test_refund50_2.py::test_refund50_2[fork_Amsterdam] stRefundTest/test_refund50percent_cap.py::test_refund50percent_cap[fork_Amsterdam] -stRefundTest/test_refund600.py::test_refund600[fork_Amsterdam] stRefundTest/test_refund_call_a.py::test_refund_call_a[fork_Amsterdam] stRefundTest/test_refund_suicide50procent_cap.py::test_refund_suicide50procent_cap[fork_Amsterdam-d0] stRefundTest/test_refund_suicide50procent_cap.py::test_refund_suicide50procent_cap[fork_Amsterdam-d1] @@ -117,25 +87,10 @@ stRevertTest/test_revert_opcode_in_calls_on_non_empty_return_data.py::test_rever stRevertTest/test_revert_opcode_in_calls_on_non_empty_return_data.py::test_revert_opcode_in_calls_on_non_empty_return_data[fork_Amsterdam-d2-g0] stRevertTest/test_revert_opcode_in_calls_on_non_empty_return_data.py::test_revert_opcode_in_calls_on_non_empty_return_data[fork_Amsterdam-d3-g0] -# stSStoreTest (4) -stSStoreTest/test_sstore_gas.py::test_sstore_gas[fork_Amsterdam] -stSStoreTest/test_sstore_gas_left.py::test_sstore_gas_left[fork_Amsterdam-d2] -stSStoreTest/test_sstore_gas_left.py::test_sstore_gas_left[fork_Amsterdam-d5] -stSStoreTest/test_sstore_gas_left.py::test_sstore_gas_left[fork_Amsterdam-d8] +# stSStoreTest (0) -# stSolidityTest (3) -stSolidityTest/test_recursive_create_contracts.py::test_recursive_create_contracts[fork_Amsterdam] -stSolidityTest/test_test_contract_interaction.py::test_test_contract_interaction[fork_Amsterdam] -stSolidityTest/test_test_contract_suicide.py::test_test_contract_suicide[fork_Amsterdam] +# stSolidityTest (0) # stStaticCall (0) -# stSystemOperationsTest (2) -stSystemOperationsTest/test_double_selfdestruct_touch_paris.py::test_double_selfdestruct_touch_paris[fork_Amsterdam--v1] -stSystemOperationsTest/test_double_selfdestruct_touch_paris.py::test_double_selfdestruct_touch_paris[fork_Amsterdam--v2] - -# stTransactionTest (4) -stTransactionTest/test_opcodes_transaction_init.py::test_opcodes_transaction_init[fork_Amsterdam-d120] -stTransactionTest/test_opcodes_transaction_init.py::test_opcodes_transaction_init[fork_Amsterdam-side_effects] -stTransactionTest/test_store_gas_on_create.py::test_store_gas_on_create[fork_Amsterdam] -stTransactionTest/test_suicides_and_internal_call_suicides_success.py::test_suicides_and_internal_call_suicides_success[fork_Amsterdam-d1] +# stTransactionTest (0) diff --git a/tests/ported_static/stAttackTest/test_crashing_transaction.py b/tests/ported_static/stAttackTest/test_crashing_transaction.py index 8f8320dd1b8..ac1b1191509 100644 --- a/tests/ported_static/stAttackTest/test_crashing_transaction.py +++ b/tests/ported_static/stAttackTest/test_crashing_transaction.py @@ -1,21 +1,29 @@ """ -Https://ropsten.etherscan.io/tx/0x8ec445380649f6c75a042a438ea9256c2fab2a... +Verify the Ropsten "crashing transaction" attack replay: a creation +transaction whose init code CREATEs children in a loop while more than +50000 gas remains, then deposits its runtime code. Ported from: state_tests/stAttackTest/CrashingTransactionFiller.json + +@manually-enhanced: Do not overwrite. On pre-EIP-8037 forks the loop +drains to the ported child count (created nonce 124); under EIP-8037 +with the revised EIP-8038 pricing an iteration is dearer (new-account +plus code-deposit state gas spill from the frame) but still fits the +loop's 50000-gas guard, so the loop drains earlier and deposits with +fewer children — the split post pins both child counts. """ import pytest from execution_testing import ( Account, - Address, Alloc, - Environment, + Fork, + Op, StateTestFiller, Transaction, compute_create_address, ) -from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" @@ -25,28 +33,16 @@ ["state_tests/stAttackTest/CrashingTransactionFiller.json"], ) @pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable def test_crashing_transaction( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: - """Https://ropsten.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = pre.fund_eoa(amount=0xDE0B6B3A7640000, nonce=3270) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=4712388, - ) - - tx = Transaction( - sender=sender, - to=None, - data=Op.MSTORE(offset=0x40, value=0x60) + """Replay the attack loop; EIP-8037 shrinks the child count.""" + sender = pre.fund_eoa() + tx_balance = 1 + initcode = ( + Op.MSTORE(offset=0x40, value=0x60) + Op.JUMPDEST * 2 + Op.JUMPI(pc=0x2C, condition=Op.ISZERO(Op.GT(Op.GAS, 0xC350))) + Op.MLOAD(offset=0x40) @@ -88,20 +84,34 @@ def test_crashing_transaction( + Op.MSTORE(offset=0x40, value=0x60) + Op.JUMP(pc=0x8) + Op.JUMPDEST - + Op.STOP, + + Op.STOP + ) + tx = Transaction( + sender=sender, + to=None, + data=initcode, gas_limit=4657786, - value=1, - nonce=3270, - gas_price=11, + value=tx_balance, ) + created = compute_create_address(address=sender, nonce=0) + expected_created_contracts = 124 + if fork.is_eip_enabled(8037): + # An iteration's state gas spill makes each pass dearer while + # still fitting the loop's 50000-gas guard, so the loop drains + # after far fewer children than the ported count. + expected_created_contracts = 23 + created_account = Account( + code=Op.MSTORE(offset=0x40, value=0x60) + + Op.JUMP(pc=0x8) + + Op.JUMPDEST + + Op.STOP, + balance=tx_balance, + nonce=expected_created_contracts, + ) post = { - sender: Account(nonce=3271), - compute_create_address(address=sender, nonce=3270): Account( - code=bytes.fromhex("60606040526008565b00"), - balance=1, - nonce=124, - ), + sender: Account(nonce=1), + created: created_account, } - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stCallCodes/test_callcallcallcode_001_suicide_end.py b/tests/ported_static/stCallCodes/test_callcallcallcode_001_suicide_end.py index facef0e01ca..6d316b3d81c 100644 --- a/tests/ported_static/stCallCodes/test_callcallcallcode_001_suicide_end.py +++ b/tests/ported_static/stCallCodes/test_callcallcallcode_001_suicide_end.py @@ -1,10 +1,29 @@ """ -Call -> call -> ( callcode - > code ) suicide. +Verify where a three-deep call chain leaves its storage writes, and which +account a trailing SELFDESTRUCT removes, across the call opcodes that +differ in whether they switch the executing context. Ported from: state_tests/stCallCodes/callcallcallcode_001_SuicideEndFiller.json +state_tests/stCallDelegateCodesHomestead/callcallcallcode_001_SuicideEndFiller.json +state_tests/stCallDelegateCodesCallCodeHomestead/callcallcallcode_001_SuicideEndFiller.json -@manually-enhanced: Do not overwrite. Explicit gas values removed. +@manually-enhanced: Do not overwrite. The three fillers are one chain +under a per-directory opcode substitution -- `stCallCodes` runs it +literally, `stCallDelegateCodesHomestead` swaps CALLCODE for +DELEGATECALL, and the `*CallCode*` variant additionally swaps CALL for +CALLCODE -- so they collapse to one test parametrized on the chain. + +The post state is then derived from the opcodes rather than transcribed: +CALL moves the context to the callee while CALLCODE and DELEGATECALL keep +the caller's, and that alone decides which account each slot lands in and +which account the SELFDESTRUCT empties. `stCallCodes`' own post asserted +only two balances, so its storage outcome had never actually been +checked. + +The beneficiary travels down as calldata because the fillers' hardcoded +addresses formed a reference cycle -- the third contract names the second +-- which is what forced `pre_alloc_mutable` on all three. """ import pytest @@ -12,111 +31,144 @@ Account, Address, Alloc, - Bytes, - Environment, + Fork, + Op, + Opcodes, StateTestFiller, Transaction, ) -from execution_testing.vm import Op +from execution_testing.forks import Cancun REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +# The ported argument/return window. Its first word carries the +# SELFDESTRUCT beneficiary down the chain. +WINDOW = 0x40 + +CONTRACT_BALANCE = 1 + +# The per-directory substitutions of the filler family's `001` chain. +CHAINS = { + "call_call_callcode": (Op.CALL, Op.CALL, Op.CALLCODE), + "call_call_delegatecall": (Op.CALL, Op.CALL, Op.DELEGATECALL), + "callcode_callcode_delegatecall": ( + Op.CALLCODE, + Op.CALLCODE, + Op.DELEGATECALL, + ), +} + + @pytest.mark.ported_from( - ["state_tests/stCallCodes/callcallcallcode_001_SuicideEndFiller.json"], + [ + "state_tests/stCallCodes/callcallcallcode_001_SuicideEndFiller.json", + "state_tests/stCallDelegateCodesHomestead/callcallcallcode_001_SuicideEndFiller.json", # noqa: E501 + "state_tests/stCallDelegateCodesCallCodeHomestead/callcallcallcode_001_SuicideEndFiller.json", # noqa: E501 + ], ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable +@pytest.mark.valid_from("SpuriousDragon") +@pytest.mark.parametrize("chain", CHAINS.values(), ids=CHAINS.keys()) def test_callcallcallcode_001_suicide_end( state_test: StateTestFiller, pre: Alloc, + fork: Fork, + chain: tuple[Opcodes, Opcodes, Opcodes], ) -> None: - """Call -> call -> ( callcode - > code ) suicide.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = pre.fund_eoa(amount=0xDE0B6B3A7640000) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=30000000, - ) + """Chained calls write where their context points; the last one dies.""" + sender = pre.fund_eoa() - # Source: lll - # { (SSTORE 3 1) } - addr_3 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x3, value=0x1) + Op.STOP, - balance=0x2540BE400, - nonce=0, - address=Address(0x73B954EBC05BB0FF4A0F6A13A054D50AD1584099), # noqa: E501 + # Every link leaves `value` at its default, so the value-passing forms + # assemble against the same argument list as DELEGATECALL, and leaves + # `gas` alone so each frame forwards everything it holds. + # + # Deployed leaf-first: threading the beneficiary through calldata is + # what breaks the fillers' address cycle and leaves a plain DAG. + leaf = pre.deploy_contract( + code=Op.SSTORE(key=3, value=0x1) + Op.STOP, + balance=CONTRACT_BALANCE, ) - # Source: lll - # { [[ 0 ]] (CALL 150000 0 0 64 0 64 ) } # noqa: E501 - target = pre.deploy_contract( # noqa: F841 + suicider = pre.deploy_contract( code=Op.SSTORE( - key=0x0, - value=Op.CALL( - address=0x77B749FFFF7EC61D31C79ED104F230A7959B2879, - value=0x0, + key=2, + value=chain[2]( + address=leaf, args_offset=0x0, - args_size=0x40, + args_size=WINDOW, ret_offset=0x0, - ret_size=0x40, + ret_size=WINDOW, ), ) + + Op.SELFDESTRUCT(address=Op.CALLDATALOAD(0x0)) + Op.STOP, - balance=0xDE0B6B3A7640000, - nonce=0, - address=Address(0x4353E77718BE108D4C149D88B34CACEDA42C5C66), # noqa: E501 + balance=CONTRACT_BALANCE, ) - # Source: lll - # { [[ 1 ]] (CALL 100000 0 0 64 0 64 ) } # noqa: E501 - addr = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE( - key=0x1, - value=Op.CALL( - address=0x94C8F980AEECBB6575B12AE614A249FC3E836F21, - value=0x0, + # Each frame gets fresh memory, so the beneficiary has to be restaged + # from calldata before being forwarded another level down. + middle = pre.deploy_contract( + code=Op.MSTORE(offset=0x0, value=Op.CALLDATALOAD(0x0)) + + Op.SSTORE( + key=1, + value=chain[1]( + address=suicider, args_offset=0x0, - args_size=0x40, + args_size=WINDOW, ret_offset=0x0, - ret_size=0x40, + ret_size=WINDOW, ), ) + Op.STOP, - balance=0x2540BE400, - nonce=0, - address=Address(0x77B749FFFF7EC61D31C79ED104F230A7959B2879), # noqa: E501 + balance=CONTRACT_BALANCE, ) - # Source: lll - # { [[ 2 ]] (CALLCODE 50000 0 0 64 0 64 ) (SELFDESTRUCT ) } # noqa: E501 - addr_2 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE( - key=0x2, - value=Op.CALLCODE( - address=0x73B954EBC05BB0FF4A0F6A13A054D50AD1584099, - value=0x0, + target = pre.deploy_contract( + code=Op.MSTORE(offset=0x0, value=middle) + + Op.SSTORE( + key=0, + value=chain[0]( + address=middle, args_offset=0x0, - args_size=0x40, + args_size=WINDOW, ret_offset=0x0, - ret_size=0x40, + ret_size=WINDOW, ), ) - + Op.SELFDESTRUCT(address=0x77B749FFFF7EC61D31C79ED104F230A7959B2879) + Op.STOP, - balance=0x2540BE400, - nonce=0, - address=Address(0x94C8F980AEECBB6575B12AE614A249FC3E836F21), # noqa: E501 + balance=CONTRACT_BALANCE, ) - tx = Transaction(sender=sender, to=target, data=Bytes("")) + # CALL switches the executing context to the callee; CALLCODE and + # DELEGATECALL run the callee's code as the caller. Frame `i` writes + # slot `i` into whichever account it is executing as. + context = [target] + for call_opcode, callee in zip( + chain, (middle, suicider, leaf), strict=True + ): + context.append(callee if call_opcode == Op.CALL else context[-1]) + + # The SELFDESTRUCT sits in the third frame, so it takes whichever + # account that frame is running as -- not necessarily its own code's. + destroyed = context[2] + assert destroyed is not middle, "beneficiary must outlive the transfer" + + written: dict[Address, dict[int, int]] = {} + for slot, account in zip(range(4), context, strict=True): + written.setdefault(account, {})[slot] = 0x1 - post = { - addr: Account(balance=0x4A817C800), - addr_3: Account(storage={3: 0}, balance=0x2540BE400), - } + balance = dict.fromkeys((target, middle, suicider, leaf), CONTRACT_BALANCE) + balance[middle] += balance[destroyed] + balance[destroyed] = 0 + + tx = Transaction(sender=sender, to=target) + + # Before EIP-6780 the destroyed account is gone outright; from Cancun + # on it keeps its code and storage and only surrenders its balance. + post: dict[Address, Account | None] = {} + for account in (target, middle, suicider, leaf): + post[account] = ( + Account(storage=written.get(account, {}), balance=balance[account]) + if account is not destroyed or fork >= Cancun + else Account.NONEXISTENT + ) - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stCallDelegateCodesCallCodeHomestead/test_callcallcallcode_001_suicide_end.py b/tests/ported_static/stCallDelegateCodesCallCodeHomestead/test_callcallcallcode_001_suicide_end.py deleted file mode 100644 index 9d540998144..00000000000 --- a/tests/ported_static/stCallDelegateCodesCallCodeHomestead/test_callcallcallcode_001_suicide_end.py +++ /dev/null @@ -1,156 +0,0 @@ -""" -Test_callcallcallcode_001_suicide_end. - -Ported from: -state_tests/stCallDelegateCodesCallCodeHomestead/callcallcallcode_001_SuicideEndFiller.json - -@manually-enhanced: Do not overwrite. The hardcoded inner-CALL gas -values (50k / 100k / 150k) were tuned to the pre-EIP-8037 gas budget. - -""" - -import pytest -from execution_testing import ( - Account, - Address, - Alloc, - Bytes, - Environment, - StateTestFiller, - Transaction, -) -from execution_testing.forks import Fork -from execution_testing.vm import Op - -REFERENCE_SPEC_GIT_PATH = "N/A" -REFERENCE_SPEC_VERSION = "N/A" - - -@pytest.mark.ported_from( - [ - "state_tests/stCallDelegateCodesCallCodeHomestead/callcallcallcode_001_SuicideEndFiller.json" # noqa: E501 - ], -) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable -def test_callcallcallcode_001_suicide_end( - state_test: StateTestFiller, - pre: Alloc, - fork: Fork, -) -> None: - """Test_callcallcallcode_001_suicide_end.""" - # EIP-8037 inner-CALL gas bumps: original values restored for - # pre-EIP-8037 forks; bumped values cover the per-storage state- - # gas spill into regular gas on Amsterdam. - outer_call_gas = 150000 - middle_call_gas = 100000 - inner_call_gas = 50000 - if fork.is_eip_enabled(8037): - outer_call_gas = 1000000 - middle_call_gas = 800000 - inner_call_gas = 100000 - - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = pre.fund_eoa(amount=0xDE0B6B3A7640000) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=30000000, - ) - - # Source: lll - # { (SSTORE 3 1) } - addr_3 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x3, value=0x1) + Op.STOP, - balance=0x2540BE400, - nonce=0, - address=Address(0x73B954EBC05BB0FF4A0F6A13A054D50AD1584099), # noqa: E501 - ) - # Source: lll - # { [[ 0 ]] (CALLCODE 150000 0 0 64 0 64 ) } # noqa: E501 - target = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE( - key=0x0, - value=Op.CALLCODE( - gas=outer_call_gas, - address=0xEAF8C2AE0D01A880CEA4E1AA88DEF5EDD153D57B, - value=0x0, - args_offset=0x0, - args_size=0x40, - ret_offset=0x0, - ret_size=0x40, - ), - ) - + Op.STOP, - balance=0xDE0B6B3A7640000, - nonce=0, - address=Address(0xA74CA10B765DCDA3B60687F73F2881E2A56EDA64), # noqa: E501 - ) - # Source: lll - # { [[ 1 ]] (CALLCODE 100000 0 0 64 0 64 ) } # noqa: E501 - addr = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE( - key=0x1, - value=Op.CALLCODE( - gas=middle_call_gas, - address=0xAC521409E2FA9526BFE6B827805783D2E307C4CE, - value=0x0, - args_offset=0x0, - args_size=0x40, - ret_offset=0x0, - ret_size=0x40, - ), - ) - + Op.STOP, - balance=0x2540BE400, - nonce=0, - address=Address(0xEAF8C2AE0D01A880CEA4E1AA88DEF5EDD153D57B), # noqa: E501 - ) - # Source: lll - # { [[ 2 ]] (DELEGATECALL 50000 0 64 0 64 ) (SELFDESTRUCT ) } # noqa: E501 - addr_2 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE( - key=0x2, - value=Op.DELEGATECALL( - gas=inner_call_gas, - address=0x73B954EBC05BB0FF4A0F6A13A054D50AD1584099, - args_offset=0x0, - args_size=0x40, - ret_offset=0x0, - ret_size=0x40, - ), - ) - + Op.SELFDESTRUCT(address=0xEAF8C2AE0D01A880CEA4E1AA88DEF5EDD153D57B) - + Op.STOP, - balance=0x2540BE400, - nonce=0, - address=Address(0xAC521409E2FA9526BFE6B827805783D2E307C4CE), # noqa: E501 - ) - - tx = Transaction( - sender=sender, - to=target, - data=Bytes(""), - gas_limit=3000000, - ) - - post = { - target: Account( - storage={0: 1, 1: 1, 2: 1, 3: 1}, - code=bytes.fromhex( - "6040600060406000600073eaf8c2ae0d01a880cea4e1aa88def5edd153d57b620249f0f260005500" # noqa: E501 - ), - balance=0, - nonce=0, - ), - addr: Account(storage={1: 0, 3: 0}), - addr_2: Account(storage={0: 0, 2: 0}), - addr_3: Account(storage={3: 0}), - sender: Account(storage={1: 0, 2: 0}), - } - - state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stCallDelegateCodesHomestead/test_callcallcallcode_001_suicide_end.py b/tests/ported_static/stCallDelegateCodesHomestead/test_callcallcallcode_001_suicide_end.py deleted file mode 100644 index 9f4aa38224e..00000000000 --- a/tests/ported_static/stCallDelegateCodesHomestead/test_callcallcallcode_001_suicide_end.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -Test_callcallcallcode_001_suicide_end. - -Ported from: -state_tests/stCallDelegateCodesHomestead/callcallcallcode_001_SuicideEndFiller.json - -@manually-enhanced: Do not overwrite. Explicit gas values removed. -""" - -import pytest -from execution_testing import ( - Account, - Address, - Alloc, - Bytes, - Environment, - StateTestFiller, - Transaction, -) -from execution_testing.vm import Op - -REFERENCE_SPEC_GIT_PATH = "N/A" -REFERENCE_SPEC_VERSION = "N/A" - - -@pytest.mark.ported_from( - [ - "state_tests/stCallDelegateCodesHomestead/callcallcallcode_001_SuicideEndFiller.json" # noqa: E501 - ], -) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable -def test_callcallcallcode_001_suicide_end( - state_test: StateTestFiller, - pre: Alloc, -) -> None: - """Test_callcallcallcode_001_suicide_end.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = pre.fund_eoa(amount=0xDE0B6B3A7640000) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=30000000, - ) - - # Source: lll - # { (SSTORE 3 1) } - addr_3 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x3, value=0x1) + Op.STOP, - balance=0x2540BE400, - nonce=0, - address=Address(0x73B954EBC05BB0FF4A0F6A13A054D50AD1584099), # noqa: E501 - ) - # Source: lll - # { [[ 0 ]] (CALL 150000 0 0 64 0 64 ) } # noqa: E501 - target = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE( - key=0x0, - value=Op.CALL( - address=0x77B749FFFF7EC61D31C79ED104F230A7959B2879, - value=0x0, - args_offset=0x0, - args_size=0x40, - ret_offset=0x0, - ret_size=0x40, - ), - ) - + Op.STOP, - balance=0xDE0B6B3A7640000, - nonce=0, - address=Address(0x4353E77718BE108D4C149D88B34CACEDA42C5C66), # noqa: E501 - ) - # Source: lll - # { [[ 1 ]] (CALL 100000 0 0 64 0 64 ) } # noqa: E501 - addr = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE( - key=0x1, - value=Op.CALL( - address=0xAC521409E2FA9526BFE6B827805783D2E307C4CE, - value=0x0, - args_offset=0x0, - args_size=0x40, - ret_offset=0x0, - ret_size=0x40, - ), - ) - + Op.STOP, - balance=0x2540BE400, - nonce=0, - address=Address(0x77B749FFFF7EC61D31C79ED104F230A7959B2879), # noqa: E501 - ) - # Source: lll - # { [[ 2 ]] (DELEGATECALL 50000 0 64 0 64 ) (SELFDESTRUCT ) } # noqa: E501 - addr_2 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE( - key=0x2, - value=Op.DELEGATECALL( - address=0x73B954EBC05BB0FF4A0F6A13A054D50AD1584099, - args_offset=0x0, - args_size=0x40, - ret_offset=0x0, - ret_size=0x40, - ), - ) - + Op.SELFDESTRUCT(address=0x77B749FFFF7EC61D31C79ED104F230A7959B2879) - + Op.STOP, - balance=0x2540BE400, - nonce=0, - address=Address(0xAC521409E2FA9526BFE6B827805783D2E307C4CE), # noqa: E501 - ) - - tx = Transaction(sender=sender, to=target, data=Bytes("")) - - post = { - target: Account(storage={0: 1, 2: 0}), - addr: Account(storage={1: 1, 3: 0}), - addr_2: Account(storage={2: 1, 3: 1}, balance=0), - addr_3: Account(storage={3: 0}), - sender: Account(storage={1: 0, 2: 0}), - } - - state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stCreateTest/test_create_address_warm_after_fail.py b/tests/ported_static/stCreateTest/test_create_address_warm_after_fail.py index dd8381ad14c..3741ecdef2c 100644 --- a/tests/ported_static/stCreateTest/test_create_address_warm_after_fail.py +++ b/tests/ported_static/stCreateTest/test_create_address_warm_after_fail.py @@ -19,878 +19,346 @@ the Amsterdam value. """ +from typing import NamedTuple + import pytest from execution_testing import ( - EOA, Account, Address, Alloc, - Bytes, - Environment, + CodeGasMeasure, Hash, StateTestFiller, Transaction, compute_create_address, ) from execution_testing.forks import Fork -from execution_testing.vm import Op - -from tests.ported_static.post_state_resolution import ( - resolve_expect_post, -) +from execution_testing.vm import Bytecode, Op REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +CREATE_RESULT_SLOT = 0 +CALL_RESULT_SLOT = 1 +FIRST_CREATED_CALL_COST_SLOT = 2 + + +def create_from(*, create_opcode: Op, initcode: Bytecode) -> Bytecode: + """Place `initcode` at memory 0 and CREATE, or CREATE2, from it.""" + return Op.MSTORE( + offset=0, + value=Op.PUSH32[Hash(initcode, right_padding=True)], + ) + Op.SSTORE( + CREATE_RESULT_SLOT, + create_opcode(value=0, offset=0, size=len(initcode)), + ) + + +class CaseOutcome(NamedTuple): + """ + The facts a case's post-state follows from. + + `create_result` is what slot 0 records, where `None` stands for the + probed address itself. + """ + + create_result_stored: bool + call_result: int + probed_deployed_code: Bytecode | None + probed_warm: bool + entry_nonce_bump: bool + + +# The entry contract's own create fails: EIP-2929 keeps the create +# address warm, and the nonce bump outlives the child frame's failure. +CREATE_FAILED = CaseOutcome( + create_result_stored=False, + call_result=0, + probed_deployed_code=None, + probed_warm=True, + entry_nonce_bump=True, +) +# The create succeeds, so slot 0 records the address it deployed to. +CREATE_SUCCEEDED = CaseOutcome( + create_result_stored=True, + call_result=0, + probed_deployed_code=Op.STOP, + probed_warm=True, + entry_nonce_bump=True, +) +# A callee ran the create and died out of gas, so its rollback took the +# warmed create address with it and its CALL reports failure. +CALLEE_OUT_OF_GAS = CaseOutcome( + create_result_stored=False, + call_result=0, + probed_deployed_code=None, + probed_warm=False, + entry_nonce_bump=False, +) +# A callee ran the create and died out of gas, so its rollback took the +# warmed create address with it and its CALL reports failure. +CONSTRUCTOR_OUT_OF_GAS = CaseOutcome( + create_result_stored=False, + call_result=1, + probed_deployed_code=None, + probed_warm=True, + entry_nonce_bump=False, +) +# A callee could not create at all, so it returns normally and its CALL +# reports success, but nothing ever warmed the create address. +CALLEE_COULD_NOT_CREATE = CaseOutcome( + create_result_stored=False, + call_result=1, + probed_deployed_code=None, + probed_warm=False, + entry_nonce_bump=False, +) + @pytest.mark.ported_from( ["state_tests/stCreateTest/CreateAddressWarmAfterFailFiller.yml"], ) @pytest.mark.valid_from("Cancun") +@pytest.mark.parametrize("value", [0, 1], ids=["v0", "v1"]) +@pytest.mark.with_all_create_opcodes @pytest.mark.parametrize( - "d, g, v", + "initcode_outcome", [ - pytest.param( - 0, - 0, - 0, - id="create-contructor-revert-v0", - ), - pytest.param( - 0, - 0, - 1, - id="create-contructor-revert-v1", - ), - pytest.param( - 1, - 0, - 0, - id="create2-contructor-revert-v0", - ), - pytest.param( - 1, - 0, - 1, - id="create2-contructor-revert-v1", - ), - pytest.param( - 2, - 0, - 0, - id="create-code-too-big-v0", - marks=pytest.mark.valid_before("EIP7954"), - ), - pytest.param( - 2, - 0, - 1, - id="create-code-too-big-v1", - marks=pytest.mark.valid_before("EIP7954"), - ), - pytest.param( - 3, - 0, - 0, - id="create2-code-too-big-v0", - marks=pytest.mark.valid_before("EIP7954"), - ), - pytest.param( - 3, - 0, - 1, - id="create2-code-too-big-v1", - marks=pytest.mark.valid_before("EIP7954"), - ), - pytest.param( - 4, - 0, - 0, - id="create-invalid-opcode-v0", - ), - pytest.param( - 4, - 0, - 1, - id="create-invalid-opcode-v1", - ), - pytest.param( - 5, - 0, - 0, - id="create2-invalid-opcode-v0", - ), - pytest.param( - 5, - 0, - 1, - id="create2-invalid-opcode-v1", - ), - pytest.param( - 6, - 0, - 0, - id="create-oog-constructor-v0", - ), - pytest.param( - 6, - 0, - 1, - id="create-oog-constructor-v1", - ), - pytest.param( - 7, - 0, - 0, - id="create-oog-post-constr-v0", - ), - pytest.param( - 7, - 0, - 1, - id="create-oog-post-constr-v1", - ), - pytest.param( - 8, - 0, - 0, - id="create2-oog-constructor-v0", - ), - pytest.param( - 8, - 0, - 1, - id="create2-oog-constructor-v1", - ), - pytest.param( - 9, - 0, - 0, - id="create2-oog-post-constr-v0", - ), - pytest.param( - 9, - 0, - 1, - id="create2-oog-post-constr-v1", - ), - pytest.param( - 10, - 0, - 0, - id="create-high-nonce-v0", - ), - pytest.param( - 10, - 0, - 1, - id="create-high-nonce-v1", - ), - pytest.param( - 11, - 0, - 0, - id="create-0xef-v0", - ), - pytest.param( - 11, - 0, - 1, - id="create-0xef-v1", - ), - pytest.param( - 12, - 0, - 0, - id="create2-0xef-v0", - ), - pytest.param( - 12, - 0, - 1, - id="create2-0xef-v1", - ), - pytest.param( - 13, - 0, - 0, - id="create-ok-v0", - ), - pytest.param( - 13, - 0, - 1, - id="create-ok-v1", - ), - pytest.param( - 14, - 0, - 0, - id="create2-ok-v0", - ), - pytest.param( - 14, - 0, - 1, - id="create2-ok-v1", - ), + "contructor-revert", + "code-too-big", + "invalid-opcode", + "oog-constructor", + "oog-post-constr", + "high-nonce", + "0xef", + "success", ], ) -@pytest.mark.pre_alloc_mutable def test_create_address_warm_after_fail( state_test: StateTestFiller, pre: Alloc, fork: Fork, - d: int, - g: int, - v: int, + value: int, + initcode_outcome: str, + create_opcode: Op, ) -> None: """ Invokes failing CREATE (because initcode fails) and checks - if the... + if the contract address that was supposed to be created is warm after the + attempt. """ - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0x00000000000000000000000000000000000C0DEC) - contract_1 = Address(0x00000000000000000000000000000000C0DE1006) - contract_2 = Address(0x00000000000000000000000000000020C0DE1006) - contract_3 = Address(0x00000000000000000000000000000000C0DEFFFF) - sender = EOA( - key=0x45A915E4D060149EB4365960E6A7A45F334393093061116B197E3240065FF2D8 - ) + sender = pre.fund_eoa() - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=999, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=3000000000, - ) + create_attempt: Bytecode + creator_address: Address | None = None + creator_nonce = 1 + initcode: Bytecode - pre[sender] = Account(balance=0xE8D4A51001) - # Source: yul - # berlin - # object "C" { - # code { - # datacopy(0, dataoffset("dummy"), datasize("dummy")) - # sstore(0, create(0, 0, datasize("dummy"))) - # stop() - # } - # object "dummy" { - # code { - # return(0,0x6000) - # } - # } - # } - contract_1 = pre.deploy_contract( # noqa: F841 - code=Op.CODECOPY(dest_offset=0x0, offset=0x12, size=0x6) - + Op.SSTORE( - key=0x0, value=Op.CREATE(value=Op.DUP1, offset=0x0, size=0x6) + if initcode_outcome == "contructor-revert": + initcode = Op.REVERT(0, 0) + create_attempt = create_from( + create_opcode=create_opcode, initcode=initcode ) - + Op.STOP - + Op.INVALID - + Op.RETURN(offset=0x0, size=0x6000), - balance=4096, - nonce=1, - address=Address(0x00000000000000000000000000000000C0DE1006), # noqa: E501 - ) - # Source: yul - # berlin - # object "C" { - # code { - # datacopy(0, dataoffset("dummy"), datasize("dummy")) - # sstore(0, create2(0, 0, datasize("dummy"), 0)) - # stop() - # } - # object "dummy" { - # code { - # return(0,0x6000) - # } - # } - # } - contract_2 = pre.deploy_contract( # noqa: F841 - code=Op.CODECOPY(dest_offset=0x0, offset=0x13, size=0x6) - + Op.SSTORE( - key=0x0, - value=Op.CREATE2( - value=Op.DUP1, offset=Op.DUP2, size=0x6, salt=0x0 - ), + outcome = CREATE_FAILED + + elif initcode_outcome == "code-too-big": + initcode = Op.RETURN( + offset=0x0, + size=fork.max_code_size() + 1, + new_memory_size=fork.max_code_size() + 1, + code_deposit_size=fork.max_code_size() + 1, ) - + Op.STOP - + Op.INVALID - + Op.RETURN(offset=0x0, size=0x6000), - balance=4096, - nonce=1, - address=Address(0x00000000000000000000000000000020C0DE1006), # noqa: E501 - ) - # Source: yul - # berlin - # object "C" { - # code { - # datacopy(0, dataoffset("dummy"), datasize("dummy")) - # sstore(0, create(0, 0, datasize("dummy"))) - # stop() - # } - # object "dummy" { - # code { - # return(0,0x20) - # } - # } - # } - contract_3 = pre.deploy_contract( # noqa: F841 - code=Op.CODECOPY(dest_offset=0x0, offset=0x12, size=0x5) - + Op.SSTORE( - key=0x0, value=Op.CREATE(value=Op.DUP1, offset=0x0, size=0x5) + create_attempt = create_from( + create_opcode=create_opcode, initcode=initcode + ) + outcome = CREATE_FAILED + + elif initcode_outcome == "invalid-opcode": + initcode = Op.INVALID + create_attempt = create_from( + create_opcode=create_opcode, initcode=initcode + ) + outcome = CREATE_FAILED + + elif initcode_outcome in ["oog-constructor", "oog-post-constr"]: + deploy_size = 10 + initcode = Op.RETURN( + offset=0, + size=deploy_size, + new_memory_size=deploy_size, + code_deposit_size=deploy_size, + ) + pre_create_code = Op.MSTORE( + offset=0, + value=Op.PUSH32[Hash(initcode, right_padding=True)], + new_memory_size=32, + ) + create_opcode( + value=0, + offset=0, + size=len(initcode), + init_code_size=len(initcode), + account_new=True, + ) + + initcode_success_gas = pre_create_code.gas_cost(fork) + ( + initcode.gas_cost(fork) * 64 // 63 + ) + callee_code_suffix: Bytecode | Op + if initcode_outcome == "oog-constructor": + # Run OOG at the code deposit + gas = initcode_success_gas - 1 + assert ( + (initcode_success_gas - pre_create_code.gas_cost(fork)) + * 63 + // 64 + ) < initcode.gas_cost(fork) + callee_code_suffix = Op.STOP + # Constructor runs out of gas, but the callee does the warming, so + # is not reverted. + outcome = CONSTRUCTOR_OUT_OF_GAS + + elif initcode_outcome == "oog-post-constr": + # Run OOG at the JUMPDEST + gas = initcode_success_gas + callee_code_suffix = ( + Op.MSTORE( + 2**12, + 1, + old_memory_size=32, + new_memory_size=2**12, + ) + + Op.STOP + ) + assert callee_code_suffix.gas_cost(fork) > ( + gas + - (pre_create_code.gas_cost(fork) + initcode.gas_cost(fork)) + ) + # Callee runs out of gas, the created contract warming is reverted. + outcome = CALLEE_OUT_OF_GAS + + else: + raise Exception(f"invalid initcode_outcome: {initcode_outcome}") + + callee_code = pre_create_code + callee_code_suffix + creator_address = pre.deploy_contract(code=callee_code) + + create_attempt = Op.SSTORE( + CALL_RESULT_SLOT, + Op.CALL(gas=gas, address=creator_address), + ) + + elif initcode_outcome == "high-nonce": + high_nonce = 2**64 - 1 + deploy_size = 10 + initcode = Op.RETURN( + offset=0, + size=deploy_size, + new_memory_size=deploy_size, + code_deposit_size=deploy_size, + ) + pre_create_code = Op.MSTORE( + offset=0, + value=Op.PUSH32[Hash(initcode, right_padding=True)], + ) + create_opcode( + value=0, + offset=0, + size=len(initcode), + init_code_size=len(initcode), + account_new=True, + ) + creator_address = pre.deploy_contract( + code=pre_create_code + Op.STOP, + nonce=high_nonce, ) - + Op.STOP - + Op.INVALID - + Op.RETURN(offset=0x0, size=0x20), - balance=4096, - nonce=18446744073709551615, - address=Address(0x00000000000000000000000000000000C0DEFFFF), # noqa: E501 + create_attempt = Op.SSTORE( + CALL_RESULT_SLOT, + Op.CALL(address=creator_address), + ) + creator_nonce = high_nonce + outcome = CALLEE_COULD_NOT_CREATE + + elif initcode_outcome == "0xef": + initcode = Op.MSTORE8(offset=0, value=0xEF) + Op.RETURN( + offset=0, size=1 + ) + create_attempt = create_from( + create_opcode=create_opcode, initcode=initcode + ) + outcome = CREATE_FAILED + + elif initcode_outcome == "success": + initcode = Op.RETURN( + offset=0x0, + size=0x1, + new_memory_size=0x1, + code_deposit_size=0x1, + ) + create_attempt = create_from( + create_opcode=create_opcode, initcode=initcode + ) + outcome = CREATE_SUCCEEDED + + else: + raise ValueError(f"unhandled case: d={initcode_outcome}") + + call = Op.CALL( + gas=0, + address=Op.CALLDATALOAD(0), + value=Op.CALLVALUE, + address_warm=outcome.probed_warm, + value_transfer=bool(value), + account_new=bool(value) and outcome.probed_deployed_code is None, ) - # Source: yul - # london - # object "C" { - # code { - # let failType := calldataload(4) - # let initcode_size - # - # // The return values of various actions. Done twice to see if there is a difference # noqa: E501 - # let create_1 := 0 - # let call_created_1 := 2 - # let call_created_2 := 3 - # let call_empty_1 := 4 - # let call_empty_2 := 5 - # - # // The costs of those operations - # let create_1_cost := 10 - # let call_created_1_cost := 12 - # let call_created_2_cost := 13 - # let call_empty_1_cost := 14 - # let call_empty_2_cost := 15 - # - # // Make the storage cells we use here are warm - # sstore(create_1, 0xdead60A7) - # sstore(call_created_1, 0xdead60A7) - # sstore(call_created_2, 0xdead60A7) - # sstore(call_empty_1, 0xdead60A7) - # sstore(call_empty_2, 0xdead60A7) - # sstore(call_created_1_cost, 0xdead60A7) - # sstore(call_created_2_cost, 0xdead60A7) - # sstore(call_empty_1_cost, 0xdead60A7) - # sstore(call_empty_2_cost, 0xdead60A7) - # ... (173 more lines) - contract_0 = pre.deploy_contract( # noqa: F841 - code=bytes.fromhex( - "6004356000906002600390600493600593600c90600d96600e90600f9863dead60a7865563dead60a7875563dead60a7885563dead60a7825563dead60a7895563dead60a7855563dead60a7815563dead60a7835563dead60a78a5573d4e7ae083132925a4927c1f5816238ba17b82a00938060001461044c5780600a1461040e57806001146103dc5780600b146103a357806002146103715780600c1461033257806003146102f757806004146102bb578060051461027f5780600d146102435780600e1461020657806006146101d4578060101461019b5780600714610169576011146100ed57600080fd5b60009788808080809b9a819b9a829b73f7fef4b66b1570a057d7d5cec5c58846befa5b5c92615a1760058061049488398680f590555b5a825583808080348782f190555a81540390555a8755349082f190555a81540390555a825583808080348782f190555a81540390555a8755349082f190555a8154039055005b5060009788808080809b9a819b9a829b6000805160206104998339815191529260058061049487398580f09055610123565b5060009788808080809b9a819b9a829b73562d97e3e4d6d3c6e791ea64bb73d820871aa2199284600a8061048a83398180f59055610123565b5060009788808080809b9a819b9a829b60008051602061049983398151915292600a8061048a87398580f09055610123565b5060009788808080809b9a819b9a829b73d70df326038a3c7ca8fac785a99162bfe75ccc469284808080806420c0de100662010000f19055610123565b5060009788808080809b9a819b9a829b73d70df326038a3c7ca8fac785a99162bfe75ccc469284808080806420c0de1006617000f19055610123565b5060009788808080809b9a819b9a829b73b2050fc27ab6d6d42dc0ce6f7c0bf9481a4c3fc392848080808063c0deffff62010000f19055610123565b5060009788808080809b9a819b9a829b73a5a6a95fd9554f15ab6986a57519092be209512592848080808063c0de100662010000f19055610123565b5060009788808080809b9a819b9a829b73a5a6a95fd9554f15ab6986a57519092be209512592848080808063c0de1006617000f19055610123565b5060009788808080809b9a819b9a829b73a13d43586820e5d97a3fd1960625d537c86dc4e79284600665fe60106000f360d01b82528180f59055610123565b5060009788808080809b9a819b9a829b6000805160206104998339815191529260018061048987398580f09055610123565b5060009788808080809b9a819b9a829b73014001fdbede82315f4b8c2a7d45e980a8a4a12e928460068061048383398180f59055610123565b5060009788808080809b9a819b9a829b6000805160206104998339815191529260068061048387398580f09055610123565b5060009788808080809b9a819b9a829b7343255ee039968e0254887fc8c7172736983d878c928460056460006000fd60d81b82528180f59055610123565b5060009788808080809b9a819b9a829b6000805160206104998339815191529260048061047f87398580f0905561012356fe600080fd6160016000f3fe60ef60005360106000f360016000f3000000000000000000000000d4e7ae083132925a4927c1f5816238ba17b82a65" # noqa: E501 - ), - balance=4096, - nonce=0, - address=Address(0x00000000000000000000000000000000000C0DEC), # noqa: E501 + measure_call = CodeGasMeasure( + code=call, + extra_stack_items=1, + sstore_key=FIRST_CREATED_CALL_COST_SLOT, + ) + entry_contract = pre.deploy_contract( + code=create_attempt + measure_call + Op.STOP ) + if creator_address is None: + creator_address = entry_contract - # The create address access after a failed CREATE is cold here; - # EIP-8038 reprices a cold account access from 2 600 to 3 000. - # Derive the delta from the fork so it is 0 pre-EIP-8037. - cold_account_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 - - expect_entries_: list[dict] = [ - { - "indexes": {"data": [0, 2, 11, 4], "gas": -1, "value": [0]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: 0, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 328, - 13: 316, - 14: 2828 + cold_account_delta, - 15: 316, - }, - nonce=1, - ), - compute_create_address( - address=contract_0, nonce=0 - ): Account.NONEXISTENT, - }, - }, - { - "indexes": {"data": [0, 2, 11, 4], "gas": -1, "value": [1]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: 0, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 32028, - 13: 7016, - 14: 34528, - 15: 7016, - }, - nonce=1, - ), - compute_create_address(address=contract_0, nonce=0): Account( - code=b"", balance=2, nonce=0 - ), - Address(0xD4E7AE083132925A4927C1F5816238BA17B82A00): Account( - code=b"", balance=2, nonce=0 - ), - }, - }, - { - "indexes": {"data": [1], "gas": -1, "value": [0]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: 0, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 328, - 13: 316, - 14: 2828 + cold_account_delta, - 15: 316, - }, - nonce=1, - ), - compute_create_address( - address=contract_0, nonce=0 - ): Account.NONEXISTENT, - }, - }, - { - "indexes": {"data": [1], "gas": -1, "value": [1]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: 0, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 32028, - 13: 7016, - 14: 34528, - 15: 7016, - }, - nonce=1, - ), - Address(0x43255EE039968E0254887FC8C7172736983D878C): Account( - code=b"", balance=2, nonce=0 - ), - Address(0xD4E7AE083132925A4927C1F5816238BA17B82A00): Account( - code=b"", balance=2, nonce=0 - ), - }, - }, - { - "indexes": {"data": [12], "gas": -1, "value": [0]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: 0, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 328, - 13: 316, - 14: 2828 + cold_account_delta, - 15: 316, - }, - nonce=1, - ), - Address( - 0x562D97E3E4D6D3C6E791EA64BB73D820871AA219 - ): Account.NONEXISTENT, - }, - }, - { - "indexes": {"data": [12], "gas": -1, "value": [1]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: 0, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 32028, - 13: 7016, - 14: 34528, - 15: 7016, - }, - nonce=1, - ), - Address(0x562D97E3E4D6D3C6E791EA64BB73D820871AA219): Account( - code=b"", balance=2, nonce=0 - ), - Address(0xD4E7AE083132925A4927C1F5816238BA17B82A00): Account( - code=b"", balance=2, nonce=0 - ), - }, - }, - { - "indexes": {"data": [3], "gas": -1, "value": [0]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: 0, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 328, - 13: 316, - 14: 2828 + cold_account_delta, - 15: 316, - }, - nonce=1, - ), - Address( - 0x014001FDBEDE82315F4B8C2A7D45E980A8A4A12E - ): Account.NONEXISTENT, - }, - }, - { - "indexes": {"data": [3], "gas": -1, "value": [1]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: 0, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 32028, - 13: 7016, - 14: 34528, - 15: 7016, - }, - nonce=1, - ), - Address(0x014001FDBEDE82315F4B8C2A7D45E980A8A4A12E): Account( - code=b"", balance=2, nonce=0 - ), - Address(0xD4E7AE083132925A4927C1F5816238BA17B82A00): Account( - code=b"", balance=2, nonce=0 - ), - }, - }, - { - "indexes": {"data": [5], "gas": -1, "value": [0]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: 0, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 328, - 13: 316, - 14: 2828 + cold_account_delta, - 15: 316, - }, - nonce=1, - ), - Address( - 0xA13D43586820E5D97A3FD1960625D537C86DC4E7 - ): Account.NONEXISTENT, - }, - }, - { - "indexes": {"data": [5], "gas": -1, "value": [1]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: 0, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 32028, - 13: 7016, - 14: 34528, - 15: 7016, - }, - nonce=1, - ), - Address(0xA13D43586820E5D97A3FD1960625D537C86DC4E7): Account( - code=b"", balance=2, nonce=0 - ), - Address(0xD4E7AE083132925A4927C1F5816238BA17B82A00): Account( - code=b"", balance=2, nonce=0 - ), - }, - }, - { - "indexes": {"data": [10], "gas": -1, "value": [0]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: 1, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 2828 + cold_account_delta, - 13: 316, - 14: 2828 + cold_account_delta, - 15: 316, - }, - nonce=0, - ), - Address( - 0xB2050FC27AB6D6D42DC0CE6F7C0BF9481A4C3FC3 - ): Account.NONEXISTENT, - Address( - 0xD4E7AE083132925A4927C1F5816238BA17B82A00 - ): Account.NONEXISTENT, - }, - }, - { - "indexes": {"data": [10], "gas": -1, "value": [1]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: 1, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 34528, - 13: 7016, - 14: 34528, - 15: 7016, - }, - nonce=0, - ), - Address(0xD4E7AE083132925A4927C1F5816238BA17B82A00): Account( - code=b"", balance=2, nonce=0 - ), - Address(0xB2050FC27AB6D6D42DC0CE6F7C0BF9481A4C3FC3): Account( - code=b"", balance=2, nonce=0 - ), - }, - }, - { - "indexes": {"data": [8, 9, 6, 7], "gas": -1, "value": [0]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: 0, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 2828 + cold_account_delta, - 13: 316, - 14: 2828 + cold_account_delta, - 15: 316, - }, - nonce=0, - ), - compute_create_address( - address=contract_0, nonce=0 - ): Account.NONEXISTENT, - }, - }, - { - "indexes": {"data": [8, 9, 6, 7], "gas": -1, "value": [1]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: 0, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 34528, - 13: 7016, - 14: 34528, - 15: 7016, - }, - nonce=0, - ), - Address(0xD4E7AE083132925A4927C1F5816238BA17B82A00): Account( - code=b"", balance=2, nonce=0 - ), - }, - }, - { - "indexes": {"data": [13], "gas": -1, "value": [0]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: compute_create_address(address=contract_0, nonce=0), - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 328, - 13: 316, - 14: 2828 + cold_account_delta, - 15: 316, - }, - nonce=1, - ), - compute_create_address(address=contract_0, nonce=0): Account( - code=bytes.fromhex("00") - ), - }, - }, - { - "indexes": {"data": [13], "gas": -1, "value": [1]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: compute_create_address(address=contract_0, nonce=0), - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 7028, - 13: 7016, - 14: 34528, - 15: 7016, - }, - nonce=1, - ), - compute_create_address(address=contract_0, nonce=0): Account( - code=bytes.fromhex("00"), balance=2, nonce=1 - ), - Address(0xD4E7AE083132925A4927C1F5816238BA17B82A00): Account( - code=b"", balance=2, nonce=0 - ), - }, - }, - { - "indexes": {"data": [14], "gas": -1, "value": [0]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: 0xF7FEF4B66B1570A057D7D5CEC5C58846BEFA5B5C, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 328, - 13: 316, - 14: 2828 + cold_account_delta, - 15: 316, - }, - nonce=1, - ), - Address(0xF7FEF4B66B1570A057D7D5CEC5C58846BEFA5B5C): Account( - code=bytes.fromhex("00"), nonce=1 - ), - }, - }, - { - "indexes": {"data": [14], "gas": -1, "value": [1]}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account( - storage={ - 0: 0xF7FEF4B66B1570A057D7D5CEC5C58846BEFA5B5C, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 12: 7028, - 13: 7016, - 14: 34528, - 15: 7016, - }, - nonce=1, - ), - Address(0xF7FEF4B66B1570A057D7D5CEC5C58846BEFA5B5C): Account( - code=bytes.fromhex("00"), balance=2, nonce=1 - ), - Address(0xD4E7AE083132925A4927C1F5816238BA17B82A00): Account( - code=b"", balance=2, nonce=0 - ), + probed_address = compute_create_address( + address=creator_address, + nonce=creator_nonce, + initcode=initcode, + opcode=create_opcode, + ) + + probed_post: Account | None = ( + Account(code=outcome.probed_deployed_code, balance=value, nonce=1) + if outcome.probed_deployed_code + else Account(code=b"", balance=value, nonce=0) + if value != 0 + else Account.NONEXISTENT + ) + + gas_costs = fork.gas_costs() + probe_cost = call.gas_cost(fork) - (gas_costs.CALL_STIPEND if value else 0) + + post = { + sender: Account(nonce=1), + entry_contract: Account( + storage={ + CREATE_RESULT_SLOT: ( + probed_address if outcome.create_result_stored else 0 + ), + CALL_RESULT_SLOT: outcome.call_result, + FIRST_CREATED_CALL_COST_SLOT: probe_cost, }, - }, - ] - - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) - - tx_data = [ - Bytes("52c3fd24") + Hash(0x0), - Bytes("52c3fd24") + Hash(0xA), - Bytes("52c3fd24") + Hash(0x1), - Bytes("52c3fd24") + Hash(0xB), - Bytes("52c3fd24") + Hash(0x2), - Bytes("52c3fd24") + Hash(0xC), - Bytes("52c3fd24") + Hash(0x3), - Bytes("52c3fd24") + Hash(0x4), - Bytes("52c3fd24") + Hash(0xD), - Bytes("52c3fd24") + Hash(0xE), - Bytes("52c3fd24") + Hash(0x5), - Bytes("52c3fd24") + Hash(0x6), - Bytes("52c3fd24") + Hash(0x10), - Bytes("52c3fd24") + Hash(0x7), - Bytes("52c3fd24") + Hash(0x11), - ] - # The dispatcher writes to ~14 fresh storage slots; under EIP-8037 - # each slot's 32-byte cost is settled at frame end out of the - # reservoir/`gas_left` (~37_500 gas/slot on Amsterdam). Add that - # headroom — `sstore_state_gas` is 0 pre-EIP-8037, so the budget - # is unchanged on older forks. - tx_gas = [16777216 + 14 * Op.SSTORE(new_value=1).state_cost(fork)] - tx_value = [0, 1] + nonce=1 + int(outcome.entry_nonce_bump), + ), + probed_address: probed_post, + } tx = Transaction( sender=sender, - to=contract_0, - data=tx_data[d], - gas_limit=tx_gas[g], - value=tx_value[v], - error=_exc, + to=entry_contract, + data=Hash(probed_address, left_padding=True), + state_gas_reservoir=0, + value=value, ) - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stEIP150Specific/test_new_gas_price_for_codes.py b/tests/ported_static/stEIP150Specific/test_new_gas_price_for_codes.py index 0be45107816..e749779db81 100644 --- a/tests/ported_static/stEIP150Specific/test_new_gas_price_for_codes.py +++ b/tests/ported_static/stEIP150Specific/test_new_gas_price_for_codes.py @@ -1,18 +1,36 @@ """ -Test_new_gas_price_for_codes. +Verify the EIP-150 repriced code/account operations in one frame: +EXTCODESIZE, EXTCODECOPY, SLOAD, failing value CALL/CALLCODE (insufficient +balance), DELEGATECALL that writes the caller's storage, a call to a +nonexistent account, BALANCE, and the whole window's measured gas -- +with and without argument windows that expand memory. Ported from: state_tests/stEIP150Specific/NewGasPriceForCodesFiller.json +state_tests/stMemExpandingEIP150Calls/NewGasPriceForCodesWithMemExpandingCallsFiller.json + +@manually-enhanced: Do not overwrite. The two fillers are one frame that +differs only in whether the calls carry an argument/return window, so +they collapse to one test parametrized on `mem_expansion`. The window +delta, the mid-execution sender balance, and the copied code word are +derived (opcode metadata, fee formula, the deployed bytes); the +delegate's budget is derived so its store -- state-priced under EIP-8037 +-- fits inside the grant (a reservoir-less sub-call pays state gas from +its regular grant); each failed value call returns its stipend. + +The mem-expanding filler stored a raw `GAS` reading, which pins +`gas_limit - intrinsic - overhead` and shifts with EIP-2780's intrinsic +change; it now shares the base filler's `SUB(entry, GAS)` delta. That +forced the entry snapshot down to `GAS_SCRATCH`: the ported slot at +0x3E7 grew memory to 1031 bytes, which already exceeds the 510-byte call +window, so keeping it would have made the expanding arm expand nothing. """ import pytest from execution_testing import ( - EOA, Account, - Address, Alloc, - Bytes, - Environment, + Fork, StateTestFiller, Transaction, ) @@ -21,133 +39,246 @@ REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +EXTCODE_BYTES = bytes.fromhex( + "1122334455667788991011121314151617181920212223242526272829303132" +) +COPY_SIZE = 0x14 +DELEGATE_VALUE = 0x11 +# Budget for the calls whose outcome does not depend on it (the value +# calls fail on insufficient balance; the absent target runs nothing). +FORWARDED_GAS = 0x7530 +# Memory word holding the entry gas reading. Kept low so the calls' +# window, not the snapshot, is what drives the memory expansion. +GAS_SCRATCH = 0x20 +SCRATCH_MEM = GAS_SCRATCH + 0x20 +# The ported calls' argument window, driving the memory expansion. +MEM_OFFSET = 0xFF +MEM_SIZE = 0xFF +GAS_PRICE = 10 +INITIAL_BALANCE = 10**15 + @pytest.mark.ported_from( - ["state_tests/stEIP150Specific/NewGasPriceForCodesFiller.json"], + [ + "state_tests/stEIP150Specific/NewGasPriceForCodesFiller.json", + "state_tests/stMemExpandingEIP150Calls/NewGasPriceForCodesWithMemExpandingCallsFiller.json", # noqa: E501 + ], +) +@pytest.mark.valid_from("Berlin") +@pytest.mark.parametrize( + "mem_expansion", + [ + pytest.param(True, id="mem_expanding_calls"), + pytest.param(False, id="empty_call_window"), + ], ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable def test_new_gas_price_for_codes( state_test: StateTestFiller, pre: Alloc, + fork: Fork, + mem_expansion: bool, ) -> None: - """Test_new_gas_price_for_codes.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = EOA( - key=0x4F31B3206FBF0E0E598B9B1A7D8AC86302A0FF1D8930738F1BEBAE9B67173E52 + """Measure a frame exercising every repriced code/account operation.""" + sender = pre.fund_eoa(amount=INITIAL_BALANCE) + code_target = pre.deploy_contract(code=EXTCODE_BYTES, balance=111) + delegate_store = Op.SSTORE( + key=0x64, + value=DELEGATE_VALUE, + key_warm=False, + original_value=0, + new_value=DELEGATE_VALUE, ) + storage_writer = pre.deploy_contract(code=delegate_store + Op.STOP) + absent = pre.nonexistent_account() - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=10000000, - ) + # The delegate must succeed: with a zero reservoir its state-priced + # store is paid from the regular grant, so the budget is derived. + delegate_budget = delegate_store.gas_cost(fork) + 2_000 - pre[sender] = Account(balance=0xE8D4A51000) - # Source: raw - # 0x1122334455667788991011121314151617181920212223242526272829303132 - addr = pre.deploy_contract( # noqa: F841 - code=bytes.fromhex( - "1122334455667788991011121314151617181920212223242526272829303132" - ), - balance=111, - nonce=0, - address=Address(0xC572A70AFAAB9D01D0A2AFB855BFBAFB47C8211B), # noqa: E501 - ) - # Source: lll - # { (SSTORE 100 0x11) } - addr_2 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x64, value=0x11) + Op.STOP, - nonce=0, - address=Address(0xAD9D325B811CB0701839C07C6F139F3799476798), # noqa: E501 - ) - # Source: lll - # { [999] (GAS) (SSTORE 1 (EXTCODESIZE )) (EXTCODECOPY 0 0 20) (SSTORE 2 (MLOAD 0)) (SSTORE 4 (SLOAD 0)) (SSTORE 5 (CALL 30000 1 0 0 0 0)) (SSTORE 6 (CALLCODE 30000 1 0 0 0 0)) (SSTORE 7 (DELEGATECALL 30000 0 0 0 0)) (SSTORE 8 (CALL 30000 0x1000000000000000000000000000000000000013 0 0 0 0 0)) (SSTORE 3 (BALANCE )) (SSTORE 10 (SUB (MLOAD 999) (GAS))) } # noqa: E501 - target = pre.deploy_contract( # noqa: F841 - code=Op.MSTORE(offset=0x3E7, value=Op.GAS) - + Op.SSTORE(key=0x1, value=Op.EXTCODESIZE(address=addr)) - + Op.EXTCODECOPY(address=addr, dest_offset=0x0, offset=0x0, size=0x14) - + Op.SSTORE(key=0x2, value=Op.MLOAD(offset=0x0)) - + Op.SSTORE(key=0x4, value=Op.SLOAD(key=0x0)) + # The calls either carry the ported argument/return window -- growing + # memory past the entry snapshot -- or leave every operand zero. + call_window = { + "args_offset": MEM_OFFSET, + "args_size": MEM_SIZE, + "ret_offset": MEM_OFFSET, + "ret_size": MEM_SIZE, + } + peak_mem = MEM_OFFSET + MEM_SIZE if mem_expansion else SCRATCH_MEM + if not mem_expansion: + call_window = {} + + # The measured window: entry GAS snapshot through the closing GAS. + # The value-bearing CALL and CALLCODE fail on insufficient balance + # (this contract holds nothing), costing their access and transfer + # charges minus the returned stipend; the DELEGATECALL runs the + # writer against this contract's storage. + window = ( + # Put the starting gas into memory + Op.MSTORE( + offset=GAS_SCRATCH, value=Op.GAS, new_memory_size=SCRATCH_MEM + ) + # Store the size of the target + + Op.SSTORE( + key=0x1, + value=Op.EXTCODESIZE(address=code_target, address_warm=False), + key_warm=False, + original_value=0, + new_value=1, + ) + # Copy the target's code into memory + + Op.EXTCODECOPY( + address=code_target, + dest_offset=0x0, + offset=0x0, + size=COPY_SIZE, + address_warm=True, + data_size=COPY_SIZE, + new_memory_size=SCRATCH_MEM, + old_memory_size=SCRATCH_MEM, + ) + # Store the target's code + + Op.SSTORE( + key=0x2, + value=Op.MLOAD(offset=0x0), + key_warm=False, + original_value=0, + new_value=1, + ) + # Re-store the value from key 0 + + Op.SSTORE( + key=0x4, + value=Op.SLOAD(key=0x0, key_warm=False), + key_warm=False, + original_value=0, + new_value=1, + ) + Op.SSTORE( key=0x5, value=Op.CALL( - gas=0x7530, - address=addr_2, + gas=FORWARDED_GAS, + address=storage_writer, value=0x1, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, + address_warm=False, + value_transfer=True, + account_new=False, + # The first call is the one that grows memory. + new_memory_size=peak_mem, + old_memory_size=SCRATCH_MEM, + **call_window, # type: ignore[arg-type] ), + key_warm=False, + original_value=0, + new_value=0, ) + Op.SSTORE( key=0x6, value=Op.CALLCODE( - gas=0x7530, - address=addr_2, + gas=FORWARDED_GAS, + address=storage_writer, value=0x1, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, + address_warm=True, + value_transfer=True, + account_new=False, + new_memory_size=peak_mem, + old_memory_size=peak_mem, + **call_window, # type: ignore[arg-type] ), + key_warm=False, + original_value=0, + new_value=0, ) + Op.SSTORE( key=0x7, value=Op.DELEGATECALL( - gas=0x7530, - address=addr_2, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, + gas=delegate_budget, + address=storage_writer, + address_warm=True, + new_memory_size=peak_mem, + old_memory_size=peak_mem, + **call_window, # type: ignore[arg-type] ), + key_warm=False, + original_value=0, + new_value=1, ) + Op.SSTORE( key=0x8, value=Op.CALL( - gas=0x7530, - address=0x1000000000000000000000000000000000000013, + gas=FORWARDED_GAS, + address=absent, value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, + address_warm=False, + value_transfer=False, + account_new=False, + new_memory_size=peak_mem, + old_memory_size=peak_mem, + **call_window, # type: ignore[arg-type] ), + key_warm=False, + original_value=0, + new_value=1, + ) + + Op.SSTORE( + key=0x3, + value=Op.BALANCE(address=sender, address_warm=True), + key_warm=False, + original_value=0, + new_value=1, ) - + Op.SSTORE(key=0x3, value=Op.BALANCE(address=sender)) - + Op.SSTORE(key=0xA, value=Op.SUB(Op.MLOAD(offset=0x3E7), Op.GAS)) - + Op.STOP, + ) + delta_store = Op.SSTORE( + key=0xA, + value=Op.SUB(Op.MLOAD(offset=GAS_SCRATCH), Op.GAS), + key_warm=False, + original_value=0, + new_value=1, + ) + target = pre.deploy_contract( + code=window + delta_store + Op.STOP, storage={0: 18}, - nonce=0, - address=Address(0xFD9AFC8315A88141164E2A753157EA3E0F72C707), # noqa: E501 ) + # Window delta: everything from the entry GAS read to the closing + # one; the lead GAS and the closing GAS cancel out of the composite, + # the delegate's work is added on top, and each failed value call + # hands back its stipend along with the unused grant. + measured = ( + window.gas_cost(fork) + + delegate_store.gas_cost(fork) + - 2 * fork.gas_costs().CALL_STIPEND + ) + + # Fork-derived budget with an EIP-2200 stipend margin for the + # trailing delta store. + intrinsic = fork.transaction_intrinsic_cost_calculator()() + gas_limit = intrinsic + measured + delta_store.gas_cost(fork) + 5_000 + tx = Transaction( sender=sender, to=target, - data=Bytes(""), - gas_limit=600000, + gas_limit=gas_limit, + gas_price=GAS_PRICE, ) + copied_word = int.from_bytes( + EXTCODE_BYTES[:COPY_SIZE].ljust(0x20, b"\x00"), "big" + ) post = { target: Account( storage={ - 0: 18, - 1: 32, - 2: 0x1122334455667788991011121314151617181920000000000000000000000000, # noqa: E501 - 3: 0xE8D4498280, - 4: 18, - 7: 1, - 8: 1, - 10: 0x2CB0A, - 100: 17, + 0x0: 18, + 0x1: len(EXTCODE_BYTES), + 0x2: copied_word, + # Mid-execution balance: the full fee is charged upfront. + 0x3: INITIAL_BALANCE - gas_limit * GAS_PRICE, + 0x4: 18, + # Slots 5 and 6 stay zero: the value calls failed. + 0x7: 1, + 0x8: 1, + 0xA: measured, + 0x64: DELEGATE_VALUE, }, ), } - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost.py deleted file mode 100644 index d84fd092041..00000000000 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost.py +++ /dev/null @@ -1,1274 +0,0 @@ -""" -Ori Pomerantz qbzzt1@gmail.com. - -Ported from: -state_tests/stEIP150singleCodeGasPrices/gasCostFiller.yml - -@manually-enhanced: Do not overwrite. This crafts a one-opcode -contract, CALLs it, and stores the opcode's measured gas via `Op.GAS`. -EIP-8038 reprices state access, so four opcodes shift: `BALANCE` and -`SELFDESTRUCT` (cold account, `COLD_ACCOUNT_ACCESS` 2600 -> 3000, +400), -`EXTCODESIZE` (cold account plus the extra `WARM_ACCESS` charged for -the opcode's second read of the code, +500), and `SSTORE` to a cold -fresh slot (`COLD_STORAGE_ACCESS` 2100 -> 3000, +900). `BALANCE` and -`EXTCODESIZE` share a Cancun baseline but need different deltas, so -their expect-entries are split. Every delta is derived from the fork's -own constants and is exactly 0 pre-EIP-8038; do not hardcode it. -""" - -import pytest -from execution_testing import ( - EOA, - Account, - Address, - Alloc, - Bytes, - Environment, - StateTestFiller, - Storage, - Transaction, -) -from execution_testing.forks import Fork -from execution_testing.vm import Op - -from tests.ported_static.post_state_resolution import ( - resolve_expect_post, -) - -REFERENCE_SPEC_GIT_PATH = "N/A" -REFERENCE_SPEC_VERSION = "N/A" - - -def _storage_with_any(base: dict, any_keys: list) -> Storage: - """Create Storage with set_expect_any for specified keys.""" - s = Storage(base) - for k in any_keys: - s.set_expect_any(k) - return s - - -@pytest.mark.ported_from( - ["state_tests/stEIP150singleCodeGasPrices/gasCostFiller.yml"], -) -@pytest.mark.valid_from("Cancun") -@pytest.mark.parametrize( - "d, g, v", - [ - pytest.param( - 0, - 0, - 0, - id="d0", - ), - pytest.param( - 1, - 0, - 0, - id="d1", - ), - pytest.param( - 2, - 0, - 0, - id="d2", - ), - pytest.param( - 3, - 0, - 0, - id="d3", - ), - pytest.param( - 4, - 0, - 0, - id="d4", - ), - pytest.param( - 5, - 0, - 0, - id="d5", - ), - pytest.param( - 6, - 0, - 0, - id="d6", - ), - pytest.param( - 7, - 0, - 0, - id="d7", - ), - pytest.param( - 8, - 0, - 0, - id="d8", - ), - pytest.param( - 9, - 0, - 0, - id="d9", - ), - pytest.param( - 10, - 0, - 0, - id="d10", - ), - pytest.param( - 11, - 0, - 0, - id="d11", - ), - pytest.param( - 12, - 0, - 0, - id="d12", - ), - pytest.param( - 13, - 0, - 0, - id="d13", - ), - pytest.param( - 14, - 0, - 0, - id="d14", - ), - pytest.param( - 15, - 0, - 0, - id="d15", - ), - pytest.param( - 16, - 0, - 0, - id="d16", - ), - pytest.param( - 17, - 0, - 0, - id="d17", - ), - pytest.param( - 18, - 0, - 0, - id="d18", - ), - pytest.param( - 19, - 0, - 0, - id="d19", - ), - pytest.param( - 20, - 0, - 0, - id="d20", - ), - pytest.param( - 21, - 0, - 0, - id="d21", - ), - pytest.param( - 22, - 0, - 0, - id="d22", - ), - pytest.param( - 23, - 0, - 0, - id="d23", - ), - pytest.param( - 24, - 0, - 0, - id="d24", - ), - pytest.param( - 25, - 0, - 0, - id="d25", - ), - pytest.param( - 26, - 0, - 0, - id="d26", - ), - pytest.param( - 27, - 0, - 0, - id="d27", - ), - pytest.param( - 28, - 0, - 0, - id="d28", - ), - pytest.param( - 29, - 0, - 0, - id="d29", - ), - pytest.param( - 30, - 0, - 0, - id="d30", - ), - pytest.param( - 31, - 0, - 0, - id="d31", - ), - pytest.param( - 32, - 0, - 0, - id="d32", - ), - pytest.param( - 33, - 0, - 0, - id="d33", - ), - pytest.param( - 34, - 0, - 0, - id="d34", - ), - pytest.param( - 35, - 0, - 0, - id="d35", - ), - pytest.param( - 36, - 0, - 0, - id="d36", - ), - pytest.param( - 37, - 0, - 0, - id="d37", - ), - pytest.param( - 38, - 0, - 0, - id="d38", - ), - pytest.param( - 39, - 0, - 0, - id="d39", - ), - pytest.param( - 40, - 0, - 0, - id="d40", - ), - pytest.param( - 41, - 0, - 0, - id="d41", - ), - pytest.param( - 42, - 0, - 0, - id="d42", - ), - pytest.param( - 43, - 0, - 0, - id="d43", - ), - pytest.param( - 44, - 0, - 0, - id="d44", - ), - pytest.param( - 45, - 0, - 0, - id="d45", - ), - pytest.param( - 46, - 0, - 0, - id="d46", - ), - pytest.param( - 47, - 0, - 0, - id="d47", - ), - pytest.param( - 48, - 0, - 0, - id="d48", - ), - pytest.param( - 49, - 0, - 0, - id="d49", - ), - pytest.param( - 50, - 0, - 0, - id="d50", - ), - pytest.param( - 51, - 0, - 0, - id="d51", - ), - pytest.param( - 52, - 0, - 0, - id="d52", - ), - pytest.param( - 53, - 0, - 0, - id="d53", - ), - pytest.param( - 54, - 0, - 0, - id="d54", - ), - pytest.param( - 55, - 0, - 0, - id="d55", - ), - pytest.param( - 56, - 0, - 0, - id="d56", - ), - pytest.param( - 57, - 0, - 0, - id="d57", - ), - pytest.param( - 58, - 0, - 0, - id="d58", - ), - pytest.param( - 59, - 0, - 0, - id="d59", - ), - pytest.param( - 60, - 0, - 0, - id="d60", - ), - pytest.param( - 61, - 0, - 0, - id="d61", - ), - pytest.param( - 62, - 0, - 0, - id="d62", - ), - pytest.param( - 63, - 0, - 0, - id="d63", - ), - pytest.param( - 64, - 0, - 0, - id="d64", - ), - pytest.param( - 65, - 0, - 0, - id="d65", - ), - pytest.param( - 66, - 0, - 0, - id="d66", - ), - pytest.param( - 67, - 0, - 0, - id="d67", - ), - pytest.param( - 68, - 0, - 0, - id="d68", - ), - pytest.param( - 69, - 0, - 0, - id="d69", - ), - pytest.param( - 70, - 0, - 0, - id="d70", - ), - pytest.param( - 71, - 0, - 0, - id="d71", - ), - pytest.param( - 72, - 0, - 0, - id="d72", - ), - pytest.param( - 73, - 0, - 0, - id="d73", - ), - pytest.param( - 74, - 0, - 0, - id="d74", - ), - pytest.param( - 75, - 0, - 0, - id="d75", - ), - pytest.param( - 76, - 0, - 0, - id="d76", - ), - pytest.param( - 77, - 0, - 0, - id="d77", - ), - pytest.param( - 78, - 0, - 0, - id="d78", - ), - pytest.param( - 79, - 0, - 0, - id="d79", - ), - pytest.param( - 80, - 0, - 0, - id="d80", - ), - pytest.param( - 81, - 0, - 0, - id="d81", - ), - pytest.param( - 82, - 0, - 0, - id="d82", - ), - pytest.param( - 83, - 0, - 0, - id="d83", - ), - pytest.param( - 84, - 0, - 0, - id="d84", - ), - pytest.param( - 85, - 0, - 0, - id="d85", - ), - pytest.param( - 86, - 0, - 0, - id="d86", - ), - pytest.param( - 87, - 0, - 0, - id="d87", - ), - pytest.param( - 88, - 0, - 0, - id="d88", - ), - pytest.param( - 89, - 0, - 0, - id="d89", - ), - pytest.param( - 90, - 0, - 0, - id="d90", - ), - pytest.param( - 91, - 0, - 0, - id="d91", - ), - pytest.param( - 92, - 0, - 0, - id="d92", - ), - pytest.param( - 93, - 0, - 0, - id="d93", - ), - pytest.param( - 94, - 0, - 0, - id="d94", - ), - pytest.param( - 95, - 0, - 0, - id="d95", - ), - pytest.param( - 96, - 0, - 0, - id="d96", - ), - pytest.param( - 97, - 0, - 0, - id="d97", - ), - pytest.param( - 98, - 0, - 0, - id="d98", - ), - pytest.param( - 99, - 0, - 0, - id="d99", - ), - pytest.param( - 100, - 0, - 0, - id="d100", - ), - pytest.param( - 101, - 0, - 0, - id="d101", - ), - pytest.param( - 102, - 0, - 0, - id="d102", - ), - pytest.param( - 103, - 0, - 0, - id="d103", - ), - pytest.param( - 104, - 0, - 0, - id="d104", - ), - pytest.param( - 105, - 0, - 0, - id="d105", - ), - pytest.param( - 106, - 0, - 0, - id="d106", - ), - pytest.param( - 107, - 0, - 0, - id="d107", - ), - pytest.param( - 108, - 0, - 0, - id="d108", - ), - pytest.param( - 109, - 0, - 0, - id="d109", - ), - ], -) -@pytest.mark.pre_alloc_mutable -def test_gas_cost( - state_test: StateTestFiller, - pre: Alloc, - fork: Fork, - d: int, - g: int, - v: int, -) -> None: - """Ori Pomerantz qbzzt1@gmail.""" - gas_costs = fork.gas_costs() - # EIP-8038 access repricing; each term is 0 on earlier forks. - cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 - cold_storage_delta = gas_costs.COLD_STORAGE_ACCESS - 2100 - # EXTCODESIZE also gains an extra warm access for its code read. - code_read_delta = cold_account_delta + ( - gas_costs.WARM_ACCESS if fork.is_eip_enabled(8037) else 0 - ) - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = EOA( - key=0x40AC0FC28C27E961EE46EC43355A094DE205856EDBD4654CF2577C2608D4EC1E - ) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=100000000, - ) - - pre[sender] = Account(balance=0xBA1A9CE0BA1A9CE) - # Source: lll - # { ; LLL doesn't let us call arbitrary code, so we craft - # ; a new contract with the opcode and then call it to see - # ; how much the contract cost - # ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - # ; Initialization - # - # ; Variables (0x20 byte wide) - # (def 'opcode 0x200) - # (def 'contractLength 0x220) - # (def 'constructorLength 0x240) - # (def 'i 0x260) - # (def 'addr 0x280) - # (def 'gasB4 0x300) - # (def 'gasAfter 0x320) - # (def 'expectedCost 0x340) - # - # ; Maximum length of contract - # (def 'maxLength 0x100) - # - # ; Code in memory - # (def 'constructorCode 0x000) - # (def 'contractCode (+ constructorCode maxLength)) - # ; contractCode has to be immediately after constructoCode - # ; for us to send it as part of the constructor code - # - # ; Cost of everything around the opcode - # (def 'sysCost 0x311) - # - # - # ; Understand the input - # ... (55 more lines) - addr = pre.deploy_contract( # noqa: F841 - code=Op.MSTORE( - offset=0x200, - value=Op.DIV(Op.CALLDATALOAD(offset=0x0), Op.EXP(0x2, 0xF8)), - ) - + Op.MSTORE( - offset=0x340, - value=Op.AND( - Op.DIV(Op.CALLDATALOAD(offset=0x0), Op.EXP(0x2, 0xE8)), 0xFFFF - ), - ) - + Op.MSTORE(offset=0x260, value=0x11) - + Op.JUMPDEST - + Op.JUMPI(pc=0x76, condition=Op.ISZERO(Op.MLOAD(offset=0x260))) - + Op.MSTORE(offset=0x260, value=Op.SUB(Op.MLOAD(offset=0x260), 0x1)) - + Op.MSTORE8( - offset=Op.ADD(Op.ADD(0x0, 0x100), Op.MLOAD(offset=0x220)), - value=0x61, - ) - + Op.MSTORE8( - offset=Op.ADD( - Op.ADD(Op.ADD(0x0, 0x100), Op.MLOAD(offset=0x220)), 0x1 - ), - value=0xDA, - ) - + Op.MSTORE8( - offset=Op.ADD( - Op.ADD(Op.ADD(0x0, 0x100), Op.MLOAD(offset=0x220)), 0x2 - ), - value=0x7A, - ) - + Op.MSTORE(offset=0x220, value=Op.ADD(Op.MLOAD(offset=0x220), 0x3)) - + Op.JUMP(pc=0x24) - + Op.JUMPDEST - + Op.MSTORE8( - offset=Op.ADD(Op.ADD(0x0, 0x100), Op.MLOAD(offset=0x220)), - value=Op.MLOAD(offset=0x200), - ) - + Op.MSTORE8( - offset=Op.ADD( - Op.ADD(Op.ADD(0x0, 0x100), Op.MLOAD(offset=0x220)), 0x1 - ), - value=0x0, - ) - + Op.MSTORE(offset=0x220, value=Op.ADD(Op.MLOAD(offset=0x220), 0x2)) - + Op.PUSH1[0x1B] - + Op.CODECOPY(dest_offset=0x0, offset=Op.PUSH2[0xFB], size=Op.DUP1) - + Op.PUSH2[0x240] - + Op.MSTORE - + Op.MSTORE( - offset=0x280, - value=Op.CREATE(value=0x0, offset=0x0, size=Op.MUL(0x100, 0x2)), - ) - + Op.MSTORE(offset=0x300, value=Op.GAS) - + Op.POP( - Op.CALL( - gas=0x10000, - address=Op.MLOAD(offset=0x280), - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - ) - + Op.MSTORE(offset=0x320, value=Op.GAS) - + Op.SSTORE( - key=0x0, - value=Op.SUB( - Op.SUB( - Op.SUB(Op.MLOAD(offset=0x300), Op.MLOAD(offset=0x320)), - 0x311, - ), - Op.MLOAD(offset=0x340), - ), - ) - + Op.SSTORE(key=0x1, value=Op.MLOAD(offset=0x340)) - + Op.STOP - + Op.INVALID - + Op.CODECOPY( - dest_offset=Op.ADD(0x0, 0x100), - offset=Op.ADD(0x0, 0x100), - size=0x100, - ) - + Op.RETURN(offset=Op.ADD(0x0, 0x100), size=0x100) - + Op.STOP, - storage={0: 24743}, - balance=0xBA1A9CE0BA1A9CE, - nonce=0, - address=Address(0xCCDCF3FF42C8382ABEEF05BB8949F975A6BC345C), # noqa: E501 - ) - - expect_entries_: list[dict] = [ - { - "indexes": { - "data": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 24, - 25, - 26, - 27, - 28, - 29, - 30, - 32, - 33, - 34, - 35, - 36, - 37, - 38, - 41, - 42, - 43, - 44, - 46, - 47, - 48, - 49, - 50, - 51, - 52, - 53, - 54, - 55, - 56, - 57, - 58, - 59, - 60, - 61, - 62, - 63, - 64, - 65, - 66, - 67, - 68, - 69, - 70, - 71, - 72, - 73, - 74, - 75, - 76, - 77, - 78, - 79, - 80, - 81, - 82, - 83, - 84, - 85, - 86, - 87, - 88, - 89, - 90, - 91, - 92, - 93, - 94, - 95, - 96, - 97, - 98, - 99, - 100, - 101, - 102, - 103, - 104, - 105, - 106, - 107, - 108, - 109, - 110, - 111, - 112, - 113, - 114, - 115, - 116, - 117, - 118, - 119, - 120, - 121, - 122, - 123, - 124, - 125, - 126, - 127, - 128, - 129, - 130, - 131, - 132, - 133, - 134, - 135, - 136, - 137, - 138, - 139, - 140, - 141, - 142, - 143, - 144, - 145, - 146, - 147, - 148, - 149, - 150, - 151, - 152, - 153, - 154, - 155, - 156, - 157, - 158, - 159, - 160, - 161, - 162, - 163, - 164, - 165, - 166, - 167, - 168, - 169, - 170, - 171, - 172, - 173, - 174, - 175, - 176, - 177, - 178, - 179, - 180, - 181, - 182, - 183, - 184, - 185, - 186, - 187, - 188, - 189, - 190, - 191, - 192, - 193, - 194, - 195, - 196, - 197, - 198, - 199, - 200, - ], - "gas": -1, - "value": -1, - }, - "network": [">=Cancun"], - "result": { - addr: Account( - storage=_storage_with_any( - { - 0: 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFDA8, # noqa: E501 - }, - [1], - ), - ), - }, - }, - { - # SSTORE to a cold fresh slot: cold storage repricing. - "indexes": {"data": [39], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - addr: Account( - storage=_storage_with_any( - {0: 700 + cold_storage_delta}, [1] - ) - ) - }, - }, - { - "indexes": {"data": [40], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - addr: Account(storage=_storage_with_any({0: 1500}, [1])) - }, - }, - { - # SELFDESTRUCT to a cold (zero) beneficiary: cold account. - "indexes": {"data": [45], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - addr: Account( - storage=_storage_with_any( - {0: 2000 + cold_account_delta}, [1] - ) - ) - }, - }, - { - # BALANCE on a cold (zero) address: cold account. - "indexes": {"data": [23], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - addr: Account( - storage=_storage_with_any( - {0: 1300 + cold_account_delta}, [1] - ) - ) - }, - }, - { - # EXTCODESIZE on a cold (zero) address: cold account plus the - # extra warm access for the opcode's code read. - "indexes": {"data": [31], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - addr: Account( - storage=_storage_with_any({0: 1300 + code_read_delta}, [1]) - ) - }, - }, - ] - - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) - - tx_data = [ - Bytes("000000"), - Bytes("010003"), - Bytes("020005"), - Bytes("030003"), - Bytes("040005"), - Bytes("050005"), - Bytes("060005"), - Bytes("070005"), - Bytes("080008"), - Bytes("090008"), - Bytes("0b0005"), - Bytes("100003"), - Bytes("110003"), - Bytes("120003"), - Bytes("130003"), - Bytes("140003"), - Bytes("150003"), - Bytes("160003"), - Bytes("170003"), - Bytes("180003"), - Bytes("190003"), - Bytes("1a0003"), - Bytes("300002"), - Bytes("3102bc"), - Bytes("320002"), - Bytes("330002"), - Bytes("340002"), - Bytes("350003"), - Bytes("360002"), - Bytes("380002"), - Bytes("3a0002"), - Bytes("3b02bc"), - Bytes("400014"), - Bytes("410002"), - Bytes("420002"), - Bytes("430002"), - Bytes("440002"), - Bytes("450002"), - Bytes("500002"), - Bytes("540320"), - Bytes("554e20"), - Bytes("580002"), - Bytes("590002"), - Bytes("5a0002"), - Bytes("5b0001"), - Bytes("ff1388"), - Bytes("600003"), - Bytes("610003"), - Bytes("620003"), - Bytes("630003"), - Bytes("640003"), - Bytes("650003"), - Bytes("660003"), - Bytes("670003"), - Bytes("680003"), - Bytes("690003"), - Bytes("6a0003"), - Bytes("6b0003"), - Bytes("6c0003"), - Bytes("6d0003"), - Bytes("6e0003"), - Bytes("6f0003"), - Bytes("700003"), - Bytes("710003"), - Bytes("720003"), - Bytes("730003"), - Bytes("740003"), - Bytes("750003"), - Bytes("760003"), - Bytes("770003"), - Bytes("780003"), - Bytes("790003"), - Bytes("7a0003"), - Bytes("7b0003"), - Bytes("7c0003"), - Bytes("7d0003"), - Bytes("7e0003"), - Bytes("7f0003"), - Bytes("800003"), - Bytes("810003"), - Bytes("820003"), - Bytes("830003"), - Bytes("840003"), - Bytes("850003"), - Bytes("860003"), - Bytes("870003"), - Bytes("880003"), - Bytes("890003"), - Bytes("8a0003"), - Bytes("8b0003"), - Bytes("8c0003"), - Bytes("8d0003"), - Bytes("8e0003"), - Bytes("8f0003"), - Bytes("900003"), - Bytes("910003"), - Bytes("920003"), - Bytes("930003"), - Bytes("940003"), - Bytes("950003"), - Bytes("960003"), - Bytes("970003"), - Bytes("980003"), - Bytes("990003"), - Bytes("9a0003"), - Bytes("9b0003"), - Bytes("9c0003"), - Bytes("9d0003"), - Bytes("9e0003"), - Bytes("9f0003"), - ] - tx_gas = [16777216] - tx_value = [1] - - tx = Transaction( - sender=sender, - to=addr, - data=tx_data[d], - gas_limit=tx_gas[g], - value=tx_value[v], - error=_exc, - ) - - state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_berlin.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_berlin.py deleted file mode 100644 index 5a92e62ca28..00000000000 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_berlin.py +++ /dev/null @@ -1,1003 +0,0 @@ -""" -Ori Pomerantz qbzzt1@gmail.com. - -Ported from: -state_tests/stEIP150singleCodeGasPrices/gasCostBerlinFiller.yml - -@manually-enhanced: Do not overwrite. This crafts a one-opcode -contract, CALLs it, and stores the opcode's measured gas minus the -data's hardcoded Cancun-era expected cost (so the net is normally 0). -EIP-8038 reprices state access, so four opcodes now exceed their old -expected cost by a fork-derived delta: `BALANCE` and `SELFDESTRUCT` -(cold account, `COLD_ACCOUNT_ACCESS` 2600 -> 3000, +400), `EXTCODESIZE` -(cold account plus the extra `WARM_ACCESS` for the opcode's code read, -+500), and `SLOAD` (cold storage, `COLD_STORAGE_ACCESS` 2100 -> 3000, -+900). The stored net for those four data indices becomes that delta; -every other index stays 0. Each delta is derived from the fork's own -constants and is exactly 0 pre-EIP-8038; do not hardcode it. -""" - -import pytest -from execution_testing import ( - Account, - Address, - Alloc, - Bytes, - Environment, - StateTestFiller, - Storage, - Transaction, -) -from execution_testing.forks import Fork -from execution_testing.vm import Op - -REFERENCE_SPEC_GIT_PATH = "N/A" -REFERENCE_SPEC_VERSION = "N/A" - - -def _storage_with_any(base: dict, any_keys: list) -> Storage: - """Create Storage with set_expect_any for specified keys.""" - s = Storage(base) - for k in any_keys: - s.set_expect_any(k) - return s - - -@pytest.mark.ported_from( - ["state_tests/stEIP150singleCodeGasPrices/gasCostBerlinFiller.yml"], -) -@pytest.mark.valid_from("Cancun") -@pytest.mark.parametrize( - "d, g, v", - [ - pytest.param( - 0, - 0, - 0, - id="d0", - ), - pytest.param( - 1, - 0, - 0, - id="d1", - ), - pytest.param( - 2, - 0, - 0, - id="d2", - ), - pytest.param( - 3, - 0, - 0, - id="d3", - ), - pytest.param( - 4, - 0, - 0, - id="d4", - ), - pytest.param( - 5, - 0, - 0, - id="d5", - ), - pytest.param( - 6, - 0, - 0, - id="d6", - ), - pytest.param( - 7, - 0, - 0, - id="d7", - ), - pytest.param( - 8, - 0, - 0, - id="d8", - ), - pytest.param( - 9, - 0, - 0, - id="d9", - ), - pytest.param( - 10, - 0, - 0, - id="d10", - ), - pytest.param( - 11, - 0, - 0, - id="d11", - ), - pytest.param( - 12, - 0, - 0, - id="d12", - ), - pytest.param( - 13, - 0, - 0, - id="d13", - ), - pytest.param( - 14, - 0, - 0, - id="d14", - ), - pytest.param( - 15, - 0, - 0, - id="d15", - ), - pytest.param( - 16, - 0, - 0, - id="d16", - ), - pytest.param( - 17, - 0, - 0, - id="d17", - ), - pytest.param( - 18, - 0, - 0, - id="d18", - ), - pytest.param( - 19, - 0, - 0, - id="d19", - ), - pytest.param( - 20, - 0, - 0, - id="d20", - ), - pytest.param( - 21, - 0, - 0, - id="d21", - ), - pytest.param( - 22, - 0, - 0, - id="d22", - ), - pytest.param( - 23, - 0, - 0, - id="d23", - ), - pytest.param( - 24, - 0, - 0, - id="d24", - ), - pytest.param( - 25, - 0, - 0, - id="d25", - ), - pytest.param( - 26, - 0, - 0, - id="d26", - ), - pytest.param( - 27, - 0, - 0, - id="d27", - ), - pytest.param( - 28, - 0, - 0, - id="d28", - ), - pytest.param( - 29, - 0, - 0, - id="d29", - ), - pytest.param( - 30, - 0, - 0, - id="d30", - ), - pytest.param( - 31, - 0, - 0, - id="d31", - ), - pytest.param( - 32, - 0, - 0, - id="d32", - ), - pytest.param( - 33, - 0, - 0, - id="d33", - ), - pytest.param( - 34, - 0, - 0, - id="d34", - ), - pytest.param( - 35, - 0, - 0, - id="d35", - ), - pytest.param( - 36, - 0, - 0, - id="d36", - ), - pytest.param( - 37, - 0, - 0, - id="d37", - ), - pytest.param( - 38, - 0, - 0, - id="d38", - ), - pytest.param( - 39, - 0, - 0, - id="d39", - ), - pytest.param( - 40, - 0, - 0, - id="d40", - ), - pytest.param( - 41, - 0, - 0, - id="d41", - ), - pytest.param( - 42, - 0, - 0, - id="d42", - ), - pytest.param( - 43, - 0, - 0, - id="d43", - ), - pytest.param( - 44, - 0, - 0, - id="d44", - ), - pytest.param( - 45, - 0, - 0, - id="d45", - ), - pytest.param( - 46, - 0, - 0, - id="d46", - ), - pytest.param( - 47, - 0, - 0, - id="d47", - ), - pytest.param( - 48, - 0, - 0, - id="d48", - ), - pytest.param( - 49, - 0, - 0, - id="d49", - ), - pytest.param( - 50, - 0, - 0, - id="d50", - ), - pytest.param( - 51, - 0, - 0, - id="d51", - ), - pytest.param( - 52, - 0, - 0, - id="d52", - ), - pytest.param( - 53, - 0, - 0, - id="d53", - ), - pytest.param( - 54, - 0, - 0, - id="d54", - ), - pytest.param( - 55, - 0, - 0, - id="d55", - ), - pytest.param( - 56, - 0, - 0, - id="d56", - ), - pytest.param( - 57, - 0, - 0, - id="d57", - ), - pytest.param( - 58, - 0, - 0, - id="d58", - ), - pytest.param( - 59, - 0, - 0, - id="d59", - ), - pytest.param( - 60, - 0, - 0, - id="d60", - ), - pytest.param( - 61, - 0, - 0, - id="d61", - ), - pytest.param( - 62, - 0, - 0, - id="d62", - ), - pytest.param( - 63, - 0, - 0, - id="d63", - ), - pytest.param( - 64, - 0, - 0, - id="d64", - ), - pytest.param( - 65, - 0, - 0, - id="d65", - ), - pytest.param( - 66, - 0, - 0, - id="d66", - ), - pytest.param( - 67, - 0, - 0, - id="d67", - ), - pytest.param( - 68, - 0, - 0, - id="d68", - ), - pytest.param( - 69, - 0, - 0, - id="d69", - ), - pytest.param( - 70, - 0, - 0, - id="d70", - ), - pytest.param( - 71, - 0, - 0, - id="d71", - ), - pytest.param( - 72, - 0, - 0, - id="d72", - ), - pytest.param( - 73, - 0, - 0, - id="d73", - ), - pytest.param( - 74, - 0, - 0, - id="d74", - ), - pytest.param( - 75, - 0, - 0, - id="d75", - ), - pytest.param( - 76, - 0, - 0, - id="d76", - ), - pytest.param( - 77, - 0, - 0, - id="d77", - ), - pytest.param( - 78, - 0, - 0, - id="d78", - ), - pytest.param( - 79, - 0, - 0, - id="d79", - ), - pytest.param( - 80, - 0, - 0, - id="d80", - ), - pytest.param( - 81, - 0, - 0, - id="d81", - ), - pytest.param( - 82, - 0, - 0, - id="d82", - ), - pytest.param( - 83, - 0, - 0, - id="d83", - ), - pytest.param( - 84, - 0, - 0, - id="d84", - ), - pytest.param( - 85, - 0, - 0, - id="d85", - ), - pytest.param( - 86, - 0, - 0, - id="d86", - ), - pytest.param( - 87, - 0, - 0, - id="d87", - ), - pytest.param( - 88, - 0, - 0, - id="d88", - ), - pytest.param( - 89, - 0, - 0, - id="d89", - ), - pytest.param( - 90, - 0, - 0, - id="d90", - ), - pytest.param( - 91, - 0, - 0, - id="d91", - ), - pytest.param( - 92, - 0, - 0, - id="d92", - ), - pytest.param( - 93, - 0, - 0, - id="d93", - ), - pytest.param( - 94, - 0, - 0, - id="d94", - ), - pytest.param( - 95, - 0, - 0, - id="d95", - ), - pytest.param( - 96, - 0, - 0, - id="d96", - ), - pytest.param( - 97, - 0, - 0, - id="d97", - ), - pytest.param( - 98, - 0, - 0, - id="d98", - ), - pytest.param( - 99, - 0, - 0, - id="d99", - ), - pytest.param( - 100, - 0, - 0, - id="d100", - ), - pytest.param( - 101, - 0, - 0, - id="d101", - ), - pytest.param( - 102, - 0, - 0, - id="d102", - ), - pytest.param( - 103, - 0, - 0, - id="d103", - ), - pytest.param( - 104, - 0, - 0, - id="d104", - ), - pytest.param( - 105, - 0, - 0, - id="d105", - ), - pytest.param( - 106, - 0, - 0, - id="d106", - ), - pytest.param( - 107, - 0, - 0, - id="d107", - ), - pytest.param( - 108, - 0, - 0, - id="d108", - ), - pytest.param( - 109, - 0, - 0, - id="d109", - ), - ], -) -@pytest.mark.pre_alloc_mutable -def test_gas_cost_berlin( - state_test: StateTestFiller, - pre: Alloc, - fork: Fork, - d: int, - g: int, - v: int, -) -> None: - """Ori Pomerantz qbzzt1@gmail.""" - gas_costs = fork.gas_costs() - # EIP-8038 access repricing; each term is 0 on earlier forks. - cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 - cold_storage_delta = gas_costs.COLD_STORAGE_ACCESS - 2100 - # EXTCODESIZE also gains an extra warm access for its code read. - code_read_delta = cold_account_delta + ( - gas_costs.WARM_ACCESS if fork.is_eip_enabled(8037) else 0 - ) - # Each measured opcode subtracts its Cancun-era expected cost, so the - # net is the (Amsterdam - Cancun) repricing of the one state access - # it performs (cold address 0 / cold fresh slot), keyed by data index. - measured_delta = { - 23: cold_account_delta, # BALANCE - 31: code_read_delta, # EXTCODESIZE - 39: cold_storage_delta, # SLOAD - 45: cold_account_delta, # SELFDESTRUCT - }.get(d, 0) - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = pre.fund_eoa(amount=0xBA1A9CE0BA1A9CE) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=100000000, - ) - - # Source: lll - # { ; LLL doesn't let us call arbitrary code, so we craft - # ; a new contract with the opcode and then call it to see - # ; how much the contract cost - # ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - # ; Initialization - # - # ; Variables (0x20 byte wide) - # (def 'opcode 0x200) - # (def 'contractLength 0x220) - # (def 'constructorLength 0x240) - # (def 'i 0x260) - # (def 'addr 0x280) - # (def 'gasB4 0x300) - # (def 'gasAfter 0x320) - # (def 'expectedCost 0x340) - # - # ; Maximum length of contract - # (def 'maxLength 0x100) - # - # ; Code in memory - # (def 'constructorCode 0x000) - # (def 'contractCode (+ constructorCode maxLength)) - # ; contractCode has to be immediately after constructoCode - # ; for us to send it as part of the constructor code - # - # ; Cost of everything around the opcode - # (def 'sysCost 0xb9) - # - # - # ; Understand the input - # ... (55 more lines) - addr = pre.deploy_contract( # noqa: F841 - code=Op.MSTORE( - offset=0x200, - value=Op.DIV(Op.CALLDATALOAD(offset=0x0), Op.EXP(0x2, 0xF8)), - ) - + Op.MSTORE( - offset=0x340, - value=Op.AND( - Op.DIV(Op.CALLDATALOAD(offset=0x0), Op.EXP(0x2, 0xE8)), 0xFFFF - ), - ) - + Op.MSTORE(offset=0x260, value=0x11) - + Op.JUMPDEST - + Op.JUMPI(pc=0x76, condition=Op.ISZERO(Op.MLOAD(offset=0x260))) - + Op.MSTORE(offset=0x260, value=Op.SUB(Op.MLOAD(offset=0x260), 0x1)) - + Op.MSTORE8( - offset=Op.ADD(Op.ADD(0x0, 0x100), Op.MLOAD(offset=0x220)), - value=0x61, - ) - + Op.MSTORE8( - offset=Op.ADD( - Op.ADD(Op.ADD(0x0, 0x100), Op.MLOAD(offset=0x220)), 0x1 - ), - value=0xDA, - ) - + Op.MSTORE8( - offset=Op.ADD( - Op.ADD(Op.ADD(0x0, 0x100), Op.MLOAD(offset=0x220)), 0x2 - ), - value=0x7A, - ) - + Op.MSTORE(offset=0x220, value=Op.ADD(Op.MLOAD(offset=0x220), 0x3)) - + Op.JUMP(pc=0x24) - + Op.JUMPDEST - + Op.MSTORE8( - offset=Op.ADD(Op.ADD(0x0, 0x100), Op.MLOAD(offset=0x220)), - value=Op.MLOAD(offset=0x200), - ) - + Op.MSTORE8( - offset=Op.ADD( - Op.ADD(Op.ADD(0x0, 0x100), Op.MLOAD(offset=0x220)), 0x1 - ), - value=0x0, - ) - + Op.MSTORE(offset=0x220, value=Op.ADD(Op.MLOAD(offset=0x220), 0x2)) - + Op.PUSH1[0x1B] - + Op.CODECOPY(dest_offset=0x0, offset=Op.PUSH2[0xFA], size=Op.DUP1) - + Op.PUSH2[0x240] - + Op.MSTORE - + Op.MSTORE( - offset=0x280, - value=Op.CREATE(value=0x0, offset=0x0, size=Op.MUL(0x100, 0x2)), - ) - + Op.MSTORE(offset=0x300, value=Op.GAS) - + Op.POP( - Op.CALL( - gas=0x10000, - address=Op.MLOAD(offset=0x280), - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - ) - + Op.MSTORE(offset=0x320, value=Op.GAS) - + Op.SSTORE( - key=0x0, - value=Op.SUB( - Op.SUB( - Op.SUB(Op.MLOAD(offset=0x300), Op.MLOAD(offset=0x320)), - 0xB9, - ), - Op.MLOAD(offset=0x340), - ), - ) - + Op.SSTORE(key=0x1, value=Op.MLOAD(offset=0x340)) - + Op.STOP - + Op.INVALID - + Op.CODECOPY( - dest_offset=Op.ADD(0x0, 0x100), - offset=Op.ADD(0x0, 0x100), - size=0x100, - ) - + Op.RETURN(offset=Op.ADD(0x0, 0x100), size=0x100) - + Op.STOP, - storage={0: 24743}, - balance=0xBA1A9CE0BA1A9CE, - nonce=0, - address=Address(0x2F170B2347023BB6BF3EEC84B53259B96E0268C3), # noqa: E501 - ) - - tx_data = [ - Bytes("000000"), - Bytes("010003"), - Bytes("020005"), - Bytes("030003"), - Bytes("040005"), - Bytes("050005"), - Bytes("060005"), - Bytes("070005"), - Bytes("080008"), - Bytes("090008"), - Bytes("0b0005"), - Bytes("100003"), - Bytes("110003"), - Bytes("120003"), - Bytes("130003"), - Bytes("140003"), - Bytes("150003"), - Bytes("160003"), - Bytes("170003"), - Bytes("180003"), - Bytes("190003"), - Bytes("1a0003"), - Bytes("300002"), - Bytes("310a28"), - Bytes("320002"), - Bytes("330002"), - Bytes("340002"), - Bytes("350003"), - Bytes("360002"), - Bytes("380002"), - Bytes("3a0002"), - Bytes("3b0a28"), - Bytes("400014"), - Bytes("410002"), - Bytes("420002"), - Bytes("430002"), - Bytes("440002"), - Bytes("450002"), - Bytes("500002"), - Bytes("540834"), - Bytes("555654"), - Bytes("580002"), - Bytes("590002"), - Bytes("5a0002"), - Bytes("5b0001"), - Bytes("ff1db0"), - Bytes("600003"), - Bytes("610003"), - Bytes("620003"), - Bytes("630003"), - Bytes("640003"), - Bytes("650003"), - Bytes("660003"), - Bytes("670003"), - Bytes("680003"), - Bytes("690003"), - Bytes("6a0003"), - Bytes("6b0003"), - Bytes("6c0003"), - Bytes("6d0003"), - Bytes("6e0003"), - Bytes("6f0003"), - Bytes("700003"), - Bytes("710003"), - Bytes("720003"), - Bytes("730003"), - Bytes("740003"), - Bytes("750003"), - Bytes("760003"), - Bytes("770003"), - Bytes("780003"), - Bytes("790003"), - Bytes("7a0003"), - Bytes("7b0003"), - Bytes("7c0003"), - Bytes("7d0003"), - Bytes("7e0003"), - Bytes("7f0003"), - Bytes("800003"), - Bytes("810003"), - Bytes("820003"), - Bytes("830003"), - Bytes("840003"), - Bytes("850003"), - Bytes("860003"), - Bytes("870003"), - Bytes("880003"), - Bytes("890003"), - Bytes("8a0003"), - Bytes("8b0003"), - Bytes("8c0003"), - Bytes("8d0003"), - Bytes("8e0003"), - Bytes("8f0003"), - Bytes("900003"), - Bytes("910003"), - Bytes("920003"), - Bytes("930003"), - Bytes("940003"), - Bytes("950003"), - Bytes("960003"), - Bytes("970003"), - Bytes("980003"), - Bytes("990003"), - Bytes("9a0003"), - Bytes("9b0003"), - Bytes("9c0003"), - Bytes("9d0003"), - Bytes("9e0003"), - Bytes("9f0003"), - ] - tx_gas = [16777216] - tx_value = [1] - - tx = Transaction( - sender=sender, - to=addr, - data=tx_data[d], - gas_limit=tx_gas[g], - value=tx_value[v], - ) - - post = {addr: Account(storage=_storage_with_any({0: measured_delta}, [1]))} - - state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stEIP158Specific/test_exp_empty.py b/tests/ported_static/stEIP158Specific/test_exp_empty.py index 9b7b323744c..d6ffd5d16f9 100644 --- a/tests/ported_static/stEIP158Specific/test_exp_empty.py +++ b/tests/ported_static/stEIP158Specific/test_exp_empty.py @@ -1,18 +1,24 @@ """ -Test_exp_empty. +Measure the gas cost of EXP with a zero base or a zero exponent across +exponent widths (the per-byte exponent charge applies only to the +exponent operand). Ported from: state_tests/stEIP158Specific/EXP_EmptyFiller.json + +@manually-enhanced: Do not overwrite. The eight measurement windows are +generated from one case list and every stored delta is derived from +opcode metadata (`exponent=` drives the per-byte charge); the transaction +budget is fork-derived. """ import pytest from execution_testing import ( Account, - Address, Alloc, - Bytes, - Environment, + Fork, StateTestFiller, + Storage, Transaction, ) from execution_testing.vm import Op @@ -24,96 +30,59 @@ @pytest.mark.ported_from( ["state_tests/stEIP158Specific/EXP_EmptyFiller.json"], ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize( + "base,exponent", + [ + # (base, exponent) pairs: a zero on either side, exponent widths 1-32. + (0x0, 0xC), + (0xC, 0x0), + (0x0, 2**64 - 1), + (0x0, 2**128 - 1), + (0x0, 2**256 - 1), + (2**64 - 1, 0x0), + (2**128 - 1, 0x0), + (2**256 - 1, 0x0), + ], +) +@pytest.mark.valid_from("Frontier") def test_exp_empty( state_test: StateTestFiller, pre: Alloc, + fork: Fork, + base: int, + exponent: int, ) -> None: - """Test_exp_empty.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = pre.fund_eoa(amount=0xE8D4A51000) + """Measure EXP's cost for zero-base and zero-exponent operands.""" + storage = Storage() - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=10000000, - ) + result = 1 if exponent == 0 else 0 - # Source: lll - # { [0](GAS) [[1]](EXP 0 12) [[2]](SUB @0 (GAS)) [0](GAS) [[3]](EXP 12 0) [[4]](SUB @0 (GAS)) [0](GAS) [[5]](EXP 0 0xffffffffffffffff) [[6]](SUB @0 (GAS)) [0](GAS) [[7]](EXP 0 0xffffffffffffffffffffffffffffffff) [[8]](SUB @0 (GAS)) [0](GAS) [[9]](EXP 0 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff) [[10]](SUB @0 (GAS)) [0](GAS) [[11]](EXP 0xffffffffffffffff 0) [[12]](SUB @0 (GAS)) [0](GAS) [[13]](EXP 0xffffffffffffffffffffffffffffffff 0) [[14]](SUB @0 (GAS)) [0] (GAS) [[15]](EXP 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff 0) [[100]] (SUB @0 (GAS)) } # noqa: E501 - target = pre.deploy_contract( # noqa: F841 - code=Op.MSTORE(offset=0x0, value=Op.GAS) - + Op.SSTORE(key=0x1, value=Op.EXP(0x0, 0xC)) - + Op.SSTORE(key=0x2, value=Op.SUB(Op.MLOAD(offset=0x0), Op.GAS)) - + Op.MSTORE(offset=0x0, value=Op.GAS) - + Op.SSTORE(key=0x3, value=Op.EXP(0xC, 0x0)) - + Op.SSTORE(key=0x4, value=Op.SUB(Op.MLOAD(offset=0x0), Op.GAS)) - + Op.MSTORE(offset=0x0, value=Op.GAS) - + Op.SSTORE(key=0x5, value=Op.EXP(0x0, 0xFFFFFFFFFFFFFFFF)) - + Op.SSTORE(key=0x6, value=Op.SUB(Op.MLOAD(offset=0x0), Op.GAS)) - + Op.MSTORE(offset=0x0, value=Op.GAS) - + Op.SSTORE( - key=0x7, value=Op.EXP(0x0, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - ) - + Op.SSTORE(key=0x8, value=Op.SUB(Op.MLOAD(offset=0x0), Op.GAS)) - + Op.MSTORE(offset=0x0, value=Op.GAS) + exp_code = Op.EXP(base, exponent, exponent=exponent) + code = ( + Op.GAS + + exp_code + + Op.GAS # [gas_1, exp_result, gas_2] + Op.SSTORE( - key=0x9, - value=Op.EXP( - 0x0, - 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, # noqa: E501 - ), - ) - + Op.SSTORE(key=0xA, value=Op.SUB(Op.MLOAD(offset=0x0), Op.GAS)) - + Op.MSTORE(offset=0x0, value=Op.GAS) - + Op.SSTORE(key=0xB, value=Op.EXP(0xFFFFFFFFFFFFFFFF, 0x0)) - + Op.SSTORE(key=0xC, value=Op.SUB(Op.MLOAD(offset=0x0), Op.GAS)) - + Op.MSTORE(offset=0x0, value=Op.GAS) + key=storage.store_next(result), value=Op.SWAP1 + ) # [gas_1, gas_2] + + Op.SWAP1 # [gas_2, gas_1] + Op.SSTORE( - key=0xD, value=Op.EXP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, 0x0) - ) - + Op.SSTORE(key=0xE, value=Op.SUB(Op.MLOAD(offset=0x0), Op.GAS)) - + Op.MSTORE(offset=0x0, value=Op.GAS) - + Op.SSTORE( - key=0xF, - value=Op.EXP( - 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, # noqa: E501 - 0x0, + key=storage.store_next( + Op.GAS.gas_cost(fork) + exp_code.gas_cost(fork) ), + value=Op.SUB, ) - + Op.SSTORE(key=0x64, value=Op.SUB(Op.MLOAD(offset=0x0), Op.GAS)) - + Op.STOP, - nonce=0, + + Op.STOP ) + target = pre.deploy_contract(code=code, storage=storage.canary()) + tx = Transaction( - sender=sender, + sender=pre.fund_eoa(), to=target, - data=Bytes(""), - gas_limit=600000, + protected=fork.supports_protected_txs(), ) - post = { - target: Account( - storage={ - 2: 2280, - 3: 1, - 4: 22127, - 6: 2627, - 8: 3027, - 10: 3827, - 11: 1, - 12: 22127, - 13: 1, - 14: 22127, - 15: 1, - 100: 22127, - }, - ), - } + post = {target: Account(storage=storage)} - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stHomesteadSpecific/test_contract_creation_oo_gdont_leave_empty_contract_via_transaction.py b/tests/ported_static/stHomesteadSpecific/test_contract_creation_oo_gdont_leave_empty_contract_via_transaction.py index 82d92551fc3..3b6f0c2564a 100644 --- a/tests/ported_static/stHomesteadSpecific/test_contract_creation_oo_gdont_leave_empty_contract_via_transaction.py +++ b/tests/ported_static/stHomesteadSpecific/test_contract_creation_oo_gdont_leave_empty_contract_via_transaction.py @@ -1,17 +1,23 @@ """ -Test_contract_creation_oo_gdont_leave_empty_contract_via_transaction. +Verify an out-of-gas contract creation leaves no account behind (the +Homestead-era bug left empty shells), while a sufficient budget creates a +codeless account whose init code called out to a storage writer. Ported from: state_tests/stHomesteadSpecific/contractCreationOOGdontLeaveEmptyContractViaTransactionFiller.json + +@manually-enhanced: Do not overwrite. The ported single case had silently +become success-only (its OOG arm was gone); both arms are restored with +fork-derived budgets, the init code's call budget is derived (the writer's +store is state-priced under EIP-8037), and the writer's slot plus the +created account's fields are asserted. """ import pytest from execution_testing import ( - EOA, Account, - Address, Alloc, - Environment, + Fork, StateTestFiller, Transaction, compute_create_address, @@ -21,78 +27,82 @@ REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +RETENTION_MARGIN = 64 + @pytest.mark.ported_from( [ "state_tests/stHomesteadSpecific/contractCreationOOGdontLeaveEmptyContractViaTransactionFiller.json" # noqa: E501 ], ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable -def test_contract_creation_oo_gdont_leave_empty_contract_via_transaction( +@pytest.mark.valid_from("Berlin") # Istanbul and before require EIP-2929 +@pytest.mark.parametrize( + "enough_gas", + [ + pytest.param(True, id="created"), + pytest.param(False, id="oog_no_account"), + ], +) +def test_contract_creation_oog_dont_leave_empty_contract_via_transaction( state_test: StateTestFiller, pre: Alloc, + fork: Fork, + enough_gas: bool, ) -> None: - """Test_contract_creation_oo_gdont_leave_empty_contract_via_transaction.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) - contract_1 = Address(0x1000000000000000000000000000000000000001) - sender = EOA( - key=0x45A915E4D060149EB4365960E6A7A45F334393093061116B197E3240065FF2D8 + """An OOG creation must not leave an account behind.""" + writer_store = Op.SSTORE( + key=0x1, value=0x1, key_warm=False, original_value=0, new_value=1 ) + writer = pre.deploy_contract(code=writer_store + Op.STOP) - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=1000000, + # The init code calls the writer and deposits nothing. The forwarded + # budget is derived: with a zero reservoir the writer's state-priced + # store must fit inside its grant. + writer_needed = writer_store.gas_cost(fork) + call_code = Op.CALL( + gas=writer_needed, + address=writer, + args_size=0x40, + ret_size=0x40, + address_warm=False, + value_transfer=False, + account_new=False, + new_memory_size=0x40, ) + padding_gas = -(-writer_needed // 63) + RETENTION_MARGIN + padding = Op.JUMPDEST * padding_gas - pre[sender] = Account(balance=0x10C8E0) - # Source: lll - # {(SSTORE 1 1)} - contract_1 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x1, value=0x1) + Op.STOP, - nonce=0, - address=Address(0x1000000000000000000000000000000000000001), # noqa: E501 - ) - # Source: lll - # {(CALL 50000 0x1000000000000000000000000000000000000001 0 0 64 0 64)} - contract_0 = pre.deploy_contract( # noqa: F841 - code=Op.CALL( - gas=0xC350, - address=0x1000000000000000000000000000000000000001, - value=0x0, - args_offset=0x0, - args_size=0x40, - ret_offset=0x0, - ret_size=0x40, - ) - + Op.STOP, - balance=0x186A0, - nonce=0, - address=Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B), # noqa: E501 - ) + execution = call_code.gas_cost(fork) + writer_needed + padding_gas + initcode = call_code + padding + Op.STOP + + overhead = fork.transaction_intrinsic_cost_calculator()( + calldata=initcode, + contract_creation=True, + return_cost_deducted_prior_execution=True, + ) + fork.transaction_top_frame_state_gas(contract_creation=True) + gas_limit = overhead + execution + if not enough_gas: + gas_limit -= 1 + sender = pre.fund_eoa() tx = Transaction( sender=sender, to=None, - data=Op.CALL( - gas=0xC350, - address=contract_1, - value=0x0, - args_offset=0x0, - args_size=0x40, - ret_offset=0x0, - ret_size=0x40, - ), - gas_limit=96000, + data=initcode, + gas_limit=gas_limit, ) + created = compute_create_address(address=sender, nonce=0) + if enough_gas: + created_account: Account | None = Account(nonce=1, code=b"", balance=0) + writer_storage = {1: 1} + else: + created_account = Account.NONEXISTENT + writer_storage = {1: 0} post = { - compute_create_address(address=sender, nonce=0): Account(balance=0) + sender: Account(nonce=1), + created: created_account, + writer: Account(storage=writer_storage), } - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stMemExpandingEIP150Calls/test_new_gas_price_for_codes_with_mem_expanding_calls.py b/tests/ported_static/stMemExpandingEIP150Calls/test_new_gas_price_for_codes_with_mem_expanding_calls.py deleted file mode 100644 index 47aed815167..00000000000 --- a/tests/ported_static/stMemExpandingEIP150Calls/test_new_gas_price_for_codes_with_mem_expanding_calls.py +++ /dev/null @@ -1,155 +0,0 @@ -""" -Test_new_gas_price_for_codes_with_mem_expanding_calls. - -Ported from: -state_tests/stMemExpandingEIP150Calls/NewGasPriceForCodesWithMemExpandingCallsFiller.json -""" - -import pytest -from execution_testing import ( - EOA, - Account, - Address, - Alloc, - Bytes, - Environment, - StateTestFiller, - Transaction, -) -from execution_testing.vm import Op - -REFERENCE_SPEC_GIT_PATH = "N/A" -REFERENCE_SPEC_VERSION = "N/A" - - -@pytest.mark.ported_from( - [ - "state_tests/stMemExpandingEIP150Calls/NewGasPriceForCodesWithMemExpandingCallsFiller.json" # noqa: E501 - ], -) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable -def test_new_gas_price_for_codes_with_mem_expanding_calls( - state_test: StateTestFiller, - pre: Alloc, -) -> None: - """Test_new_gas_price_for_codes_with_mem_expanding_calls.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = EOA( - key=0x3956FC06BD55836ACDB92DA0E38A15F2E568C088022CF2278180477F3F7702A - ) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=10000000, - ) - - pre[sender] = Account(balance=0xE8D4A5100000) - # Source: hex - # 0x1122334455667788991011121314151617181920212223242526272829303132 - addr = pre.deploy_contract( # noqa: F841 - code=bytes.fromhex( - "1122334455667788991011121314151617181920212223242526272829303132" - ), - balance=111, - nonce=0, - address=Address(0x6B6AF3C6E1714081C8C3085ACBAC8C2B21FADF0B), # noqa: E501 - ) - # Source: hex - # 0x6011606455 - addr_2 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x64, value=0x11), - nonce=0, - address=Address(0x7B8C83E74CC8DFADB03138C2743C70588ACE4222), # noqa: E501 - ) - # Source: hex - # 0x733b600155601460006000733c60005160025560005460045560ff60ff60ff60ff600173617530f160055560ff60ff60ff60ff600173617530f260065560ff60ff60ff60ff73617530f460075560ff60ff60ff60ff6000731000000000000000000000000000000000000013617530f160085573316003555a600a55 # noqa: E501 - target = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x1, value=Op.EXTCODESIZE(address=addr)) - + Op.EXTCODECOPY(address=addr, dest_offset=0x0, offset=0x0, size=0x14) - + Op.SSTORE(key=0x2, value=Op.MLOAD(offset=0x0)) - + Op.SSTORE(key=0x4, value=Op.SLOAD(key=0x0)) - + Op.SSTORE( - key=0x5, - value=Op.CALL( - gas=0x7530, - address=addr_2, - value=0x1, - args_offset=0xFF, - args_size=0xFF, - ret_offset=0xFF, - ret_size=0xFF, - ), - ) - + Op.SSTORE( - key=0x6, - value=Op.CALLCODE( - gas=0x7530, - address=addr_2, - value=0x1, - args_offset=0xFF, - args_size=0xFF, - ret_offset=0xFF, - ret_size=0xFF, - ), - ) - + Op.SSTORE( - key=0x7, - value=Op.DELEGATECALL( - gas=0x7530, - address=addr_2, - args_offset=0xFF, - args_size=0xFF, - ret_offset=0xFF, - ret_size=0xFF, - ), - ) - + Op.SSTORE( - key=0x8, - value=Op.CALL( - gas=0x7530, - address=0x1000000000000000000000000000000000000013, - value=0x0, - args_offset=0xFF, - args_size=0xFF, - ret_offset=0xFF, - ret_size=0xFF, - ), - ) - + Op.SSTORE(key=0x3, value=Op.BALANCE(address=sender)) - + Op.SSTORE(key=0xA, value=Op.GAS), - storage={0: 18}, - nonce=0, - address=Address(0x23A2EC54F5F8589778DA7C2199CAF3B179A24CB9), # noqa: E501 - ) - - tx = Transaction( - sender=sender, - to=target, - data=Bytes(""), - gas_limit=600000, - ) - - post = { - addr: Account(balance=111), - target: Account( - storage={ - 0: 18, - 1: 32, - 2: 0x1122334455667788991011121314151617181920000000000000000000000000, # noqa: E501 - 3: 0xE8D4A4B47280, - 4: 18, - 7: 1, - 8: 1, - 10: 0x60AE9, - 100: 17, - }, - ), - sender: Account(nonce=1), - } - - state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stMemoryTest/test_oog.py b/tests/ported_static/stMemoryTest/test_oog.py index 5efece6b89d..97ec8674339 100644 --- a/tests/ported_static/stMemoryTest/test_oog.py +++ b/tests/ported_static/stMemoryTest/test_oog.py @@ -1,816 +1,446 @@ """ -Ori Pomerantz qbzzt1@gmail.com. +Verify that each memory-touching operation runs out of gas at its budget: +a caller forwards a fixed amount to a contract holding one operation +whose memory reach exceeds it, and stores whether the sub-call survived. Ported from: state_tests/stMemoryTest/oogFiller.yml -@manually-enhanced: Do not overwrite. Each parametrization forwards a -fixed in-bytecode gas budget to an inner operation and asserts whether -it succeeds. The `0x3E` (RETURNDATACOPY) success case routes through a -nested value-0 CALL to a cold contract; EIP-8038's cold account access -reprice consumes the budget's slack and OOGs the copy. Bump only that -budget by the fork-derived `COLD_ACCOUNT_ACCESS - 2600` so the success -path stays funded; the value is exactly 0 before EIP-8038 and all -other budgets are untouched. +@manually-enhanced: Do not overwrite. The ported fan of 22 pre-deployed +contracts collapses to one subject built per case, and every ported gas +constant becomes a budget derived from that subject's own bytecode: its +exact cost, or one gas short. The two RETURNDATACOPY arms instead +withhold one of EIP-211's two gas terms each, which is what the filler +starved with its pinned pair before EIP-2929 moved the CALL leg past it. """ +from typing import Generator + import pytest from execution_testing import ( Account, - Address, Alloc, - Bytes, - Environment, + Bytecode, + Fork, Hash, + Op, StateTestFiller, Transaction, ) -from execution_testing.forks import Fork -from execution_testing.vm import Op - -from tests.ported_static.post_state_resolution import ( - resolve_expect_post, -) REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +# Memory offsets the operations reach for, past what the starved budgets +# can pay to expand to. +REACH = 0x1000 +FAR_REACH = 0x10000 +# Handed back by the return-data source for RETURNDATACOPY to copy. +RETURN_WORD = 0x102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F20 +# Size of the argument and return windows the calls hand out. +WINDOW = 0x20 +# Proves the caller ran to completion when the observable is zero. +CANARY = 0xC0DE + +OPERATIONS_BY_OPCODE = { + Op.CALL: [ + "call", + "call_args", + "call_return", + ], + Op.CALLCODE: [ + "callcode", + "callcode_args", + "callcode_return", + ], + Op.DELEGATECALL: [ + "delegatecall", + "delegatecall_args", + "delegatecall_return", + ], + Op.STATICCALL: [ + "staticcall", + "staticcall_args", + "staticcall_return", + ], +} + + +# Memory-touching opcodes that `test_oog`'s success flag cannot observe, +# and so are covered by a test of their own. +SPECIAL_CASED = {Op.REVERT} + + +def operations_by_fork(fork: Fork) -> Generator[str, None, None]: + """Return the list of operations per opcode that modifies the memory.""" + for opcode in fork.valid_opcodes(): + if "new_memory_size" in opcode.metadata: + if opcode in SPECIAL_CASED: + continue + if opcode not in OPERATIONS_BY_OPCODE: + operations = [opcode._name_.lower()] + else: + operations = OPERATIONS_BY_OPCODE[opcode] + for operation in operations: + yield operation + + +@pytest.fixture +def subject_code(operation: str, pre: Alloc, fork: Fork) -> Bytecode: + """Build the body exercising `operation` past its memory reach.""" + if operation == "sha3": + code = ( + Op.SHA3( + offset=0x0, + size=REACH, + data_size=REACH, + new_memory_size=REACH, + ) + + Op.STOP + ) + return code + if operation == "calldatacopy": + code = ( + Op.CALLDATACOPY( + dest_offset=0x0, + offset=0x0, + size=REACH, + data_size=REACH, + new_memory_size=REACH, + ) + + Op.STOP + ) + return code + if operation == "codecopy": + code = ( + Op.CODECOPY( + dest_offset=0x0, + offset=0x0, + size=REACH, + data_size=REACH, + new_memory_size=REACH, + ) + + Op.STOP + ) + return code + if operation == "extcodecopy": + code = ( + Op.EXTCODECOPY( + address=Op.ADDRESS, + dest_offset=0x0, + offset=0x0, + size=REACH, + address_warm=True, + data_size=REACH, + new_memory_size=REACH, + ) + + Op.STOP + ) + return code + if operation == "returndatacopy": + callee_code = Op.MSTORE( + offset=0x0, value=RETURN_WORD, new_memory_size=0x20 + ) + Op.RETURN( + offset=0x0, size=0x20, new_memory_size=0x20, old_memory_size=0x20 + ) + return_data_source = pre.deploy_contract(code=callee_code) + return ( + # Give RETURNDATACOPY something to copy. `inner_call_cost` + # folds the callee's own gas into this frame's cost. + Op.POP( + Op.CALL( + gas=Op.GAS, + address=return_data_source, + value=0x0, + args_offset=0x0, + args_size=WINDOW, + ret_offset=0x0, + ret_size=WINDOW, + address_warm=False, + value_transfer=False, + new_memory_size=0x20, + inner_call_cost=callee_code.gas_cost(fork), + ) + ) + + Op.RETURNDATACOPY( + dest_offset=REACH, + offset=0x0, + size=0x10, + data_size=0x10, + old_memory_size=0x20, + new_memory_size=REACH + 0x10, + ) + + Op.STOP + ) + if operation == "mload": + code = Op.MLOAD(offset=REACH, new_memory_size=REACH + 0x20) + Op.STOP + return code + if operation == "mstore": + code = ( + Op.MSTORE(offset=REACH, value=0xFF, new_memory_size=REACH + 0x20) + + Op.STOP + ) + return code + if operation == "mstore8": + code = ( + Op.MSTORE8(offset=REACH, value=0xFF, new_memory_size=REACH + 0x1) + + Op.STOP + ) + return code + if operation == "mcopy": + code = ( + Op.MCOPY( + dest_offset=REACH, + offset=0x0, + size=WINDOW, + data_size=WINDOW, + new_memory_size=REACH + WINDOW, + ) + + Op.STOP + ) + return code + if operation.startswith("log"): + topics = [0x1, 0x2, 0x3, 0x4][: int(operation[3:])] + log_opcode = getattr(Op, operation.upper()) + code = ( + log_opcode( + FAR_REACH, + 0x20, + *topics, + data_size=0x20, + new_memory_size=FAR_REACH + 0x20, + ) + + Op.STOP + ) + return code + if operation == "create": + # Metadata leaves the bytes unchanged, so the budget is derived + # from the very code deployed. + code = ( + Op.CREATE( + value=0x0, + offset=FAR_REACH, + size=0x20, + new_memory_size=FAR_REACH + 0x20, + init_code_size=0x20, + ) + + Op.STOP + ) + return code + if operation == "create2": + code = ( + Op.CREATE2( + value=0x0, + offset=FAR_REACH, + size=0x20, + salt=0x5A17, + new_memory_size=FAR_REACH + 0x20, + init_code_size=0x20, + ) + + Op.STOP + ) + return code + if operation == "return": + code = Op.RETURN( + offset=FAR_REACH, size=0x20, new_memory_size=FAR_REACH + 0x20 + ) + return code + + stop_contract = pre.deploy_contract(code=Op.STOP) + call_op, _, window = operation.partition("_") + assert call_op in ("call", "callcode", "delegatecall", "staticcall"), ( + f"unknown operation {operation}" + ) + args_offset = 0x0 if window == "return" else FAR_REACH + ret_offset = 0x0 if window == "args" else FAR_REACH + if not window: + ret_offset = FAR_REACH + WINDOW + call_kwargs: dict = { + "gas": Op.GAS, + "address": stop_contract, + "args_offset": args_offset, + "args_size": WINDOW, + "ret_offset": ret_offset, + "ret_size": WINDOW, + "address_warm": False, + "new_memory_size": max(args_offset, ret_offset) + WINDOW, + } + if call_op in ("call", "callcode"): + call_kwargs["value"] = 0x0 + code = getattr(Op, call_op.upper())(**call_kwargs) + Op.STOP + return code + @pytest.mark.ported_from( ["state_tests/stMemoryTest/oogFiller.yml"], ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.parametrize( - "d, g, v", - [ - pytest.param( - 0, - 0, - 0, - id="success", - ), - pytest.param( - 1, - 0, - 0, - id="failure", - ), - pytest.param( - 2, - 0, - 0, - id="success", - ), - pytest.param( - 3, - 0, - 0, - id="failure", - ), - pytest.param( - 4, - 0, - 0, - id="success", - ), - pytest.param( - 5, - 0, - 0, - id="failure", - ), - pytest.param( - 6, - 0, - 0, - id="success", - ), - pytest.param( - 7, - 0, - 0, - id="failure", - ), - pytest.param( - 8, - 0, - 0, - id="success", - ), - pytest.param( - 9, - 0, - 0, - id="success", - ), - pytest.param( - 10, - 0, - 0, - id="failure", - ), - pytest.param( - 11, - 0, - 0, - id="failure", - ), - pytest.param( - 12, - 0, - 0, - id="success", - ), - pytest.param( - 13, - 0, - 0, - id="failure", - ), - pytest.param( - 14, - 0, - 0, - id="success", - ), - pytest.param( - 15, - 0, - 0, - id="failure", - ), - pytest.param( - 16, - 0, - 0, - id="success", - ), - pytest.param( - 17, - 0, - 0, - id="failure", - ), - pytest.param( - 18, - 0, - 0, - id="success", - ), - pytest.param( - 19, - 0, - 0, - id="failure", - ), - pytest.param( - 20, - 0, - 0, - id="success", - ), - pytest.param( - 21, - 0, - 0, - id="failure", - ), - pytest.param( - 22, - 0, - 0, - id="success", - ), - pytest.param( - 23, - 0, - 0, - id="failure", - ), - pytest.param( - 24, - 0, - 0, - id="success", - ), - pytest.param( - 25, - 0, - 0, - id="failure", - ), - pytest.param( - 26, - 0, - 0, - id="success", - ), - pytest.param( - 27, - 0, - 0, - id="failure", - ), - pytest.param( - 28, - 0, - 0, - id="success", - ), - pytest.param( - 29, - 0, - 0, - id="failure", - ), - pytest.param( - 30, - 0, - 0, - id="success", - ), - pytest.param( - 31, - 0, - 0, - id="failure", - ), - pytest.param( - 32, - 0, - 0, - id="success", - ), - pytest.param( - 33, - 0, - 0, - id="failure", - ), - pytest.param( - 34, - 0, - 0, - id="success", - ), - pytest.param( - 35, - 0, - 0, - id="failure", - ), - pytest.param( - 36, - 0, - 0, - id="success", - ), - pytest.param( - 37, - 0, - 0, - id="failure", - ), - pytest.param( - 38, - 0, - 0, - id="success", - ), - pytest.param( - 39, - 0, - 0, - id="failure", - ), - pytest.param( - 40, - 0, - 0, - id="success", - ), - pytest.param( - 41, - 0, - 0, - id="failure", - ), - ], -) -@pytest.mark.pre_alloc_mutable +@pytest.mark.valid_from("Berlin") +@pytest.mark.parametrize("succeeds", [True, False], ids=["enough", "oog"]) +@pytest.mark.parametrize_by_fork("operation", operations_by_fork) def test_oog( state_test: StateTestFiller, pre: Alloc, fork: Fork, - d: int, - g: int, - v: int, + subject_code: Bytecode, + succeeds: bool, ) -> None: - """Ori Pomerantz qbzzt1@gmail.""" - # EIP-8038 cold account access reprice; 0 before EIP-8038. The - # `0x3E` RETURNDATACOPY success case forwards just enough gas for a - # nested CALL to a cold contract plus the copy; the reprice eats the - # slack, so add it back to that one budget. - cold_account_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0x0000000000000000000000000000000000010020) - contract_1 = Address(0x0000000000000000000000000000000000010037) - contract_2 = Address(0x0000000000000000000000000000000000010039) - contract_3 = Address(0x000000000000000000000000000000000001003C) - contract_4 = Address(0x000000000000000000000000000000000001003E) - contract_5 = Address(0x000000000000000000000000000000000001113E) - contract_6 = Address(0x0000000000000000000000000000000000010051) - contract_7 = Address(0x0000000000000000000000000000000000010052) - contract_8 = Address(0x0000000000000000000000000000000000010053) - contract_9 = Address(0x00000000000000000000000000000000000100A0) - contract_10 = Address(0x00000000000000000000000000000000000100A1) - contract_11 = Address(0x00000000000000000000000000000000000100A2) - contract_12 = Address(0x00000000000000000000000000000000000100A3) - contract_13 = Address(0x00000000000000000000000000000000000100A4) - contract_14 = Address(0x00000000000000000000000000000000000100F0) - contract_15 = Address(0x00000000000000000000000000000000000100F5) - contract_16 = Address(0x00000000000000000000000000000000000100F3) - contract_17 = Address(0x00000000000000000000000000000000000100F1) - contract_18 = Address(0x00000000000000000000000000000000000100F2) - contract_19 = Address(0x00000000000000000000000000000000000100F4) - contract_20 = Address(0x00000000000000000000000000000000000100FA) - contract_21 = Address(0x00000000000000000000000000000000000111F1) - contract_22 = Address(0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC) - sender = pre.fund_eoa(amount=0xBA1A9CE0BA1A9CE, nonce=1) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=100000000, - ) - - # Source: yul - # berlin - # { - # // Instead of keccak256, which seems to be optimized into - # // not happening - # pop(verbatim_2i_1o(hex"20", 0, 0x1000)) - # } - contract_0 = pre.deploy_contract( # noqa: F841 - code=Op.SHA3(offset=0x0, size=0x1000) + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x0000000000000000000000000000000000010020), # noqa: E501 - ) - # Source: yul - # berlin - # { - # calldatacopy(0,0,0x1000) - # } - contract_1 = pre.deploy_contract( # noqa: F841 - code=Op.CALLDATACOPY(dest_offset=Op.DUP1, offset=0x0, size=0x1000) - + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x0000000000000000000000000000000000010037), # noqa: E501 - ) - # Source: yul - # berlin - # { - # codecopy(0,0,0x1000) - # } - contract_2 = pre.deploy_contract( # noqa: F841 - code=Op.CODECOPY(dest_offset=Op.DUP1, offset=0x0, size=0x1000) - + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x0000000000000000000000000000000000010039), # noqa: E501 - ) - # Source: yul - # berlin - # { - # extcodecopy(address(),0,0,0x1000) - # } - contract_3 = pre.deploy_contract( # noqa: F841 - code=Op.EXTCODECOPY( - address=Op.ADDRESS, dest_offset=Op.DUP1, offset=0x0, size=0x1000 + """Forward a fixed budget to one memory-touching operation.""" + exact = subject_code.gas_cost(fork) + forwarded_gas = exact if succeeds else exact - 1 + subject = pre.deploy_contract(code=subject_code) + # Reads subject and budget from calldata, stores whether it survived. + caller = pre.deploy_contract( + code=Op.SSTORE( + key=0x0, value=Op.CALL(gas=forwarded_gas, address=subject) ) + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x000000000000000000000000000000000001003C), # noqa: E501 - ) - # Source: yul - # berlin - # { - # mstore(0, 0x0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F20) # noqa: E501 - # return(0,0x20) - # } - contract_5 = pre.deploy_contract( # noqa: F841 - code=Op.MSTORE( - offset=0x0, - value=0x102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F20, # noqa: E501 - ) - + Op.RETURN(offset=0x0, size=0x20), - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x000000000000000000000000000000000001113E), # noqa: E501 - ) - # Source: yul - # berlin - # { - # pop(verbatim_1i_1o(hex"51", 0x1000)) - # } - contract_6 = pre.deploy_contract( # noqa: F841 - code=Op.MLOAD(offset=0x1000) + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x0000000000000000000000000000000000010051), # noqa: E501 - ) - # Source: yul - # berlin - # { - # mstore(0x1000, 0xFF) - # } - contract_7 = pre.deploy_contract( # noqa: F841 - code=Op.MSTORE(offset=0x1000, value=0xFF) + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x0000000000000000000000000000000000010052), # noqa: E501 - ) - # Source: yul - # berlin - # { - # mstore8(0x1000, 0xFF) - # } - contract_8 = pre.deploy_contract( # noqa: F841 - code=Op.MSTORE8(offset=0x1000, value=0xFF) + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x0000000000000000000000000000000000010053), # noqa: E501 - ) - # Source: yul - # berlin - # { - # log0(0x10000, 0x20) - # } - contract_9 = pre.deploy_contract( # noqa: F841 - code=Op.LOG0(offset=0x10000, size=0x20) + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x00000000000000000000000000000000000100A0), # noqa: E501 ) - # Source: yul - # berlin - # { - # log1(0x10000, 0x20, 0x1) - # } - contract_10 = pre.deploy_contract( # noqa: F841 - code=Op.LOG1(offset=0x10000, size=0x20, topic_1=0x1) + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x00000000000000000000000000000000000100A1), # noqa: E501 - ) - # Source: yul - # berlin - # { - # log2(0x10000, 0x20, 0x1, 0x2) - # } - contract_11 = pre.deploy_contract( # noqa: F841 - code=Op.LOG2(offset=0x10000, size=0x20, topic_1=0x1, topic_2=0x2) - + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x00000000000000000000000000000000000100A2), # noqa: E501 - ) - # Source: yul - # berlin - # { - # log3(0x10000, 0x20, 0x1, 0x2, 0x3) - # } - contract_12 = pre.deploy_contract( # noqa: F841 - code=Op.LOG3( - offset=0x10000, size=0x20, topic_1=0x1, topic_2=0x2, topic_3=0x3 - ) - + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x00000000000000000000000000000000000100A3), # noqa: E501 + tx = Transaction(sender=pre.fund_eoa(), to=caller, state_gas_reservoir=0) + post = {caller: Account(storage={0: 1 if succeeds else 0})} + state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.ported_from( + ["state_tests/stMemoryTest/oogFiller.yml"], +) +@pytest.mark.valid_from("Cancun") +@pytest.mark.parametrize("succeeds", [True, False], ids=["enough", "oog"]) +def test_oog_returndatacopy_expansion( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + succeeds: bool, +) -> None: + """ + Starve RETURNDATACOPY's memory expansion specifically. + + EIP-211 prices the opcode as + `_with_memory_expansion(_with_data_copy(...))`. `test_oog` withholds + one gas, leaving the copy term unpaid; this withholds the expansion + term whole, so the opcode is reached and dies on the other charge. + """ + callee_code = Op.MSTORE( + offset=0x0, value=RETURN_WORD, new_memory_size=0x20 + ) + Op.RETURN( + offset=0x0, size=0x20, new_memory_size=0x20, old_memory_size=0x20 ) - # Source: yul - # berlin - # { - # log4(0x10000, 0x20, 0x1, 0x2, 0x3, 0x4) - # } - contract_13 = pre.deploy_contract( # noqa: F841 - code=Op.LOG4( - offset=0x10000, - size=0x20, - topic_1=0x1, - topic_2=0x2, - topic_3=0x3, - topic_4=0x4, + return_data_source = pre.deploy_contract(code=callee_code) + # Give RETURNDATACOPY something to copy. `inner_call_cost` folds the + # callee's own gas into this frame's cost. + call_code = Op.POP( + Op.CALL( + gas=Op.GAS, + address=return_data_source, + value=0x0, + args_offset=0x0, + args_size=WINDOW, + ret_offset=0x0, + ret_size=WINDOW, + address_warm=False, + value_transfer=False, + new_memory_size=0x20, + inner_call_cost=callee_code.gas_cost(fork), ) - + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x00000000000000000000000000000000000100A4), # noqa: E501 ) - # Source: yul - # berlin - # { - # pop(create(0, 0x10000, 0x20)) - # } - contract_14 = pre.deploy_contract( # noqa: F841 - code=Op.CREATE(value=0x0, offset=0x10000, size=0x20) + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x00000000000000000000000000000000000100F0), # noqa: E501 + # Pricing the same opcode with and without growth isolates the + # expansion term, which is what this budget withholds. + returndatacopy = Op.RETURNDATACOPY( + dest_offset=REACH, + offset=0x0, + size=0x10, + data_size=0x10, + old_memory_size=0x20, + new_memory_size=REACH + 0x10, ) - # Source: yul - # berlin - # { - # pop(create2(0, 0x10000, 0x20, 0x5a17)) - # } - contract_15 = pre.deploy_contract( # noqa: F841 - code=Op.CREATE2(value=0x0, offset=0x10000, size=0x20, salt=0x5A17) - + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x00000000000000000000000000000000000100F5), # noqa: E501 + flat = Op.RETURNDATACOPY( + dest_offset=REACH, + offset=0x0, + size=0x10, + data_size=0x10, + old_memory_size=0x20, + new_memory_size=0x20, ) - # Source: yul - # berlin - # { - # return(0x10000, 0x20) - # } - contract_16 = pre.deploy_contract( # noqa: F841 - code=Op.RETURN(offset=0x10000, size=0x20), - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x00000000000000000000000000000000000100F3), # noqa: E501 - ) - # Source: yul - # berlin - # { - # stop() - # } - contract_21 = pre.deploy_contract( # noqa: F841 - code=Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x00000000000000000000000000000000000111F1), # noqa: E501 + expansion = returndatacopy.gas_cost(fork) - flat.gas_cost(fork) + + code = call_code + returndatacopy + Op.STOP + starved = code.gas_cost(fork) - expansion + # The budget still has to reach the opcode. The ported 0x7D0 no + # longer does: EIP-2929 repriced the cold account access and the + # CALL leg grew past it, so it starved the call instead. + assert starved > call_code.gas_cost(fork), ( + "budget no longer reaches RETURNDATACOPY" ) - # Source: yul - # berlin - # { - # let op := calldataload(0x04) - # let gasAmt := calldataload(0x24) - # - # // Call the function that actually goes OOG (or not) - # sstore(0, call(gasAmt, add(0x10000,op), 0, 0, 0, 0, 0)) - # } - contract_22 = pre.deploy_contract( # noqa: F841 + forwarded_gas = code.gas_cost(fork) if succeeds else starved + subject = pre.deploy_contract(code=code) + # Reads subject and budget from calldata, stores whether it survived. + caller = pre.deploy_contract( code=Op.SSTORE( key=0x0, value=Op.CALL( - gas=Op.CALLDATALOAD(offset=0x24), - address=Op.ADD(Op.CALLDATALOAD(offset=0x4), 0x10000), - value=Op.DUP1, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, + gas=Op.CALLDATALOAD(offset=0x20), + address=Op.CALLDATALOAD(offset=0x0), ), ) + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC), # noqa: E501 ) - # Source: yul - # berlin - # { - # // Make sure there is return data to be copied - # pop(call(gas(), 0x1113e, 0, 0, 0x20, 0, 0x20)) - # - # returndatacopy(0x1000,0,0x10) - # } - contract_4 = pre.deploy_contract( # noqa: F841 + tx = Transaction( + sender=pre.fund_eoa(), + to=caller, + data=Hash(subject, left_padding=True) + Hash(forwarded_gas), + state_gas_reservoir=0, + ) + post = {caller: Account(storage={0: 1 if succeeds else 0})} + state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.ported_from( + ["state_tests/stMemoryTest/oogFiller.yml"], +) +@pytest.mark.valid_from("Cancun") +@pytest.mark.parametrize("succeeds", [True, False], ids=["enough", "oog"]) +def test_oog_revert( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + succeeds: bool, +) -> None: + """ + Starve REVERT's memory expansion. + + A funded REVERT still makes the caller's CALL return 0, so the flag + the other cases assert on cannot tell it apart from running out of + gas. Its return data can: a REVERT that paid for its window hands + back `WINDOW` bytes, one that ran out hands back none. + """ + code = Op.REVERT( + offset=FAR_REACH, size=WINDOW, new_memory_size=FAR_REACH + WINDOW + ) + exact = code.gas_cost(fork) + forwarded_gas = exact if succeeds else exact - 1 + subject = pre.deploy_contract(code=code) + # Reads subject and budget from calldata, stores the size of the + # revert data, then a canary so a caller that never ran is not + # mistaken for a starved REVERT. + caller = pre.deploy_contract( code=Op.POP( Op.CALL( - gas=Op.GAS, - address=0x1113E, - value=Op.DUP1, - args_offset=Op.DUP2, - args_size=Op.DUP2, - ret_offset=0x0, - ret_size=0x20, + gas=Op.CALLDATALOAD(offset=0x20), + address=Op.CALLDATALOAD(offset=0x0), ) ) - + Op.RETURNDATACOPY(dest_offset=0x1000, offset=0x0, size=0x10) - + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x000000000000000000000000000000000001003E), # noqa: E501 - ) - # Source: yul - # berlin - # { - # pop(call(gas(), 0x111f1, 0, 0x10000, 0, 0, 0)) - # } - contract_17 = pre.deploy_contract( # noqa: F841 - code=Op.CALL( - gas=Op.GAS, - address=0x111F1, - value=Op.DUP2, - args_offset=0x10000, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) - + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x00000000000000000000000000000000000100F1), # noqa: E501 - ) - # Source: yul - # berlin - # { - # pop(staticcall(gas(), 0x111f1, 0x10000, 0, 0, 0)) - # } - contract_20 = pre.deploy_contract( # noqa: F841 - code=Op.STATICCALL( - gas=Op.GAS, - address=0x111F1, - args_offset=0x10000, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) + + Op.SSTORE(key=0x0, value=Op.RETURNDATASIZE) + + Op.SSTORE(key=0x1, value=CANARY) + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x00000000000000000000000000000000000100FA), # noqa: E501 ) - # Source: yul - # berlin - # { - # pop(delegatecall(gas(), 0x111f1, 0x10000, 0, 0, 0)) - # } - contract_19 = pre.deploy_contract( # noqa: F841 - code=Op.DELEGATECALL( - gas=Op.GAS, - address=0x111F1, - args_offset=0x10000, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) - + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x00000000000000000000000000000000000100F4), # noqa: E501 - ) - # Source: yul - # berlin - # { - # pop(callcode(gas(), 0x111f1, 0, 0x10000, 0, 0, 0)) - # } - contract_18 = pre.deploy_contract( # noqa: F841 - code=Op.CALLCODE( - gas=Op.GAS, - address=0x111F1, - value=Op.DUP2, - args_offset=0x10000, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) - + Op.STOP, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, - address=Address(0x00000000000000000000000000000000000100F2), # noqa: E501 - ) - - expect_entries_: list[dict] = [ - { - "indexes": { - "data": [ - 0, - 2, - 4, - 6, - 8, - 9, - 12, - 14, - 16, - 18, - 20, - 22, - 24, - 26, - 28, - 30, - 32, - 34, - 36, - 38, - 40, - ], - "gas": -1, - "value": -1, - }, - "network": [">=Cancun"], - "result": {contract_22: Account(storage={0: 1})}, - }, - { - "indexes": { - "data": [ - 1, - 3, - 5, - 7, - 10, - 11, - 13, - 15, - 17, - 19, - 21, - 23, - 25, - 27, - 29, - 31, - 33, - 35, - 37, - 39, - 41, - ], - "gas": -1, - "value": -1, - }, - "network": [">=Cancun"], - "result": {contract_22: Account(storage={0: 0})}, - }, - ] - - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) - - tx_data = [ - Bytes("1a8451e6") + Hash(0x20) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0x20) + Hash(0x4BA), - Bytes("1a8451e6") + Hash(0x37) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0x37) + Hash(0x32A), - Bytes("1a8451e6") + Hash(0x39) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0x39) + Hash(0x32A), - Bytes("1a8451e6") + Hash(0x3C) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0x3C) + Hash(0x2BC), - Bytes("1a8451e6") + Hash(0x3E) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0x3E) + Hash(0xC02 + cold_account_delta), - Bytes("1a8451e6") + Hash(0x3E) + Hash(0x7D0), - Bytes("1a8451e6") + Hash(0x3E) + Hash(0xC01), - Bytes("1a8451e6") + Hash(0x51) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0x51) + Hash(0x190), - Bytes("1a8451e6") + Hash(0x52) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0x52) + Hash(0x190), - Bytes("1a8451e6") + Hash(0x53) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0x53) + Hash(0x190), - Bytes("1a8451e6") + Hash(0xA0) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0xA0) + Hash(0x39D0), - Bytes("1a8451e6") + Hash(0xA1) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0xA1) + Hash(0x39D0), - Bytes("1a8451e6") + Hash(0xA2) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0xA2) + Hash(0x39D0), - Bytes("1a8451e6") + Hash(0xA3) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0xA3) + Hash(0x39D0), - Bytes("1a8451e6") + Hash(0xA4) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0xA4) + Hash(0x39D0), - Bytes("1a8451e6") + Hash(0xF0) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0xF0) + Hash(0x7D00), - Bytes("1a8451e6") + Hash(0xF5) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0xF5) + Hash(0x7D00), - Bytes("1a8451e6") + Hash(0xF3) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0xF3) + Hash(0x36B0), - Bytes("1a8451e6") + Hash(0xF1) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0xF1) + Hash(0x2BC), - Bytes("1a8451e6") + Hash(0xF2) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0xF2) + Hash(0x2BC), - Bytes("1a8451e6") + Hash(0xF4) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0xF4) + Hash(0x2BC), - Bytes("1a8451e6") + Hash(0xFA) + Hash(0xFFFF), - Bytes("1a8451e6") + Hash(0xFA) + Hash(0x2BC), - ] - tx_gas = [16777216] - tx = Transaction( - sender=sender, - to=contract_22, - data=tx_data[d], - gas_limit=tx_gas[g], - nonce=1, - error=_exc, + sender=pre.fund_eoa(), + to=caller, + data=Hash(subject, left_padding=True) + Hash(forwarded_gas), + state_gas_reservoir=0, ) - - state_test(env=env, pre=pre, post=post, tx=tx) + post = { + caller: Account( + storage={0: WINDOW if succeeds else 0, 1: CANARY}, + ) + } + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRefundTest/test_refund600.py b/tests/ported_static/stRefundTest/test_refund600.py index cce448a08c4..46c761fb472 100644 --- a/tests/ported_static/stRefundTest/test_refund600.py +++ b/tests/ported_static/stRefundTest/test_refund600.py @@ -1,84 +1,98 @@ """ -Test_refund600. +Verify the EIP-3529 refund cap over six storage clears: the sender's final +balance reflects the executed gas minus the capped refund. Ported from: state_tests/stRefundTest/refund600Filler.json + +@manually-enhanced: Do not overwrite. The sender's balance, the refund cap +and the transaction budget all derive from the fork (`code.gas_cost` / +`code.refund` composites), so EIP-8037's repriced stores and any future +refund change are tracked instead of pinned. """ import pytest from execution_testing import ( - EOA, Account, - Address, Alloc, - Bytes, - Environment, + Fork, + Op, StateTestFiller, Transaction, + TransactionReceipt, ) -from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +CONTRACT_BALANCE = 0x1 + @pytest.mark.ported_from( ["state_tests/stRefundTest/refund600Filler.json"], ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable +@pytest.mark.valid_from("Berlin") def test_refund600( - state_test: StateTestFiller, - pre: Alloc, + state_test: StateTestFiller, pre: Alloc, fork: Fork ) -> None: - """Test_refund600.""" - coinbase = Address(0xEB201D2887816E041F6E807E804F64F3A7A226FE) - sender = EOA( - key=0xDC4EFA209AECDD4C2D5201A419EA27506151B4EC687F14A613229E310932491B - ) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=1000000, + """Six storage clears refund gas up to the EIP-3529 cap.""" + code = ( + Op.POP(Op.SLOAD(key=0x1, key_warm=False)) + + Op.POP(Op.SLOAD(key=0x2, key_warm=False)) + # EXP(2, 0xFFFF) wraps to 0 mod 2^256, so this store is a no-op. + + Op.SSTORE( + key=0xA, + value=Op.EXP(0x2, 0xFFFF, exponent=0xFFFF), + key_warm=False, + original_value=0, + new_value=0, + ) + + Op.SSTORE( + key=0xB, + value=Op.BALANCE(address=Op.ADDRESS, address_warm=True), + key_warm=False, + original_value=0, + new_value=1, + ) + + Op.SSTORE( + key=0x1, value=0x0, key_warm=True, original_value=1, new_value=0 + ) + + Op.SSTORE( + key=0x2, value=0x0, key_warm=True, original_value=1, new_value=0 + ) + + Op.SSTORE( + key=0x3, value=0x0, key_warm=False, original_value=1, new_value=0 + ) + + Op.SSTORE( + key=0x4, value=0x0, key_warm=False, original_value=1, new_value=0 + ) + + Op.SSTORE( + key=0x5, value=0x0, key_warm=False, original_value=1, new_value=0 + ) + + Op.SSTORE( + key=0x6, value=0x0, key_warm=False, original_value=1, new_value=0 + ) ) - - pre[coinbase] = Account(balance=0, nonce=1) - pre[sender] = Account(balance=0x989680) - # Source: lll - # { @@1 @@2 [[ 10 ]] (EXP 2 0xffff) [[ 11 ]] (BALANCE (ADDRESS)) [[ 1 ]] 0 [[ 2 ]] 0 [[ 3 ]] 0 [[ 4 ]] 0 [[ 5 ]] 0 [[ 6 ]] 0 } # noqa: E501 - target = pre.deploy_contract( # noqa: F841 - code=Op.POP(Op.SLOAD(key=0x1)) - + Op.POP(Op.SLOAD(key=0x2)) - + Op.SSTORE(key=0xA, value=Op.EXP(0x2, 0xFFFF)) - + Op.SSTORE(key=0xB, value=Op.BALANCE(address=Op.ADDRESS)) - + Op.SSTORE(key=0x1, value=0x0) - + Op.SSTORE(key=0x2, value=0x0) - + Op.SSTORE(key=0x3, value=0x0) - + Op.SSTORE(key=0x4, value=0x0) - + Op.SSTORE(key=0x5, value=0x0) - + Op.SSTORE(key=0x6, value=0x0) - + Op.STOP, + target = pre.deploy_contract( + code=code + Op.STOP, storage={1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1}, - balance=0xDE0B6B3A7640000, - nonce=0, - address=Address(0xC09923E2275E4EE7822A1FEB5EEE1C18143575C7), # noqa: E501 + balance=CONTRACT_BALANCE, ) + intrinsic = fork.transaction_intrinsic_cost_calculator()() + executed = intrinsic + code.gas_cost(fork) + + sender = pre.fund_eoa() + + # EIP-3529 caps the refund at a fifth of the executed gas. + refund = min(code.refund(fork), executed // fork.max_refund_quotient()) + gas_used = executed - refund tx = Transaction( sender=sender, to=target, - data=Bytes(""), - gas_limit=100000, + expected_receipt=TransactionReceipt(cumulative_gas_used=gas_used), ) - post = { - target: Account(storage={11: 0xDE0B6B3A7640000}), - coinbase: Account(balance=0), - sender: Account(balance=0x8F5CF0), - } + post = {target: Account(storage={0xA: 0, 0xB: CONTRACT_BALANCE})} - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stSStoreTest/test_sstore_gas.py b/tests/ported_static/stSStoreTest/test_sstore_gas.py index 975d3765c56..97d5ac5ebbd 100644 --- a/tests/ported_static/stSStoreTest/test_sstore_gas.py +++ b/tests/ported_static/stSStoreTest/test_sstore_gas.py @@ -1,21 +1,31 @@ """ -Ori Pomerantz qbzzt1@gmail.com. +Measure the gas cost of every SSTORE transition class (cold/warm x +original/current/new value combinations) via inline GAS deltas (by Ori +Pomerantz qbzzt1@gmail.com). Ported from: state_tests/stSStoreTest/sstoreGasFiller.yml + +@manually-enhanced: Do not overwrite. Costs derive from SSTORE opcode +metadata, so repricings track automatically. Each figure is the whole +measured window (SSTORE plus its two operand pushes), not the bare +opcode the filler stored. Keep `state_gas_reservoir=0`, or EIP-8037 +state gas is hidden from `Op.GAS`. Berlin floor: no cold/warm before +EIP-2929. """ +from typing import Any + import pytest from execution_testing import ( Account, - Address, Alloc, - Bytes, - Environment, + CodeGasMeasure, + Fork, StateTestFiller, Transaction, ) -from execution_testing.vm import Op +from execution_testing.vm import Bytecode, Op REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" @@ -24,167 +34,100 @@ @pytest.mark.ported_from( ["state_tests/stSStoreTest/sstoreGasFiller.yml"], ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable +@pytest.mark.valid_from("Berlin") def test_sstore_gas( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: - """Ori Pomerantz qbzzt1@gmail.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = pre.fund_eoa(amount=0xBA1A9CE0BA1A9CE, nonce=1) + """Measure each SSTORE transition's gas against opcode metadata.""" + sender = pre.fund_eoa() - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=100000000, - ) + # Measured figures land in slots 0x1000+; slots 0-3 are written to. + gas_slot_base = 0x1000 + # Slots 0 and 1 start at this; later stores write it to 2 and 3. + stored_value = 0x60A7 + # Cost depends on the zero / non-zero class, not the magnitude. + nonzero_a = 0xBEEF + nonzero_b = 0xDEADBEEF + nonzero_c = 0x1234 + + # (slot, value, SSTORE metadata) - `new_value` is always the value. + # One table drives both the bytecode and the expected costs. + measurements: list[tuple[int, int, dict[str, Any]]] = [ + # slot 0 cold: nonzero -> other nonzero + ( + 0, + nonzero_a, + dict(key_warm=False, original_value=stored_value), + ), + # slot 0 warm dirty: nonzero -> nonzero + ( + 0, + nonzero_b, + dict( + key_warm=True, + original_value=stored_value, + current_value=nonzero_a, + ), + ), + # slot 0 warm dirty: nonzero -> zero + ( + 0, + 0, + dict( + key_warm=True, + original_value=stored_value, + current_value=nonzero_b, + ), + ), + # slot 0 warm dirty: zero -> zero + ( + 0, + 0, + dict(key_warm=True, original_value=stored_value, current_value=0), + ), + # slot 0 warm dirty: zero -> nonzero + ( + 0, + nonzero_c, + dict(key_warm=True, original_value=stored_value, current_value=0), + ), + # slot 1 cold: nonzero -> zero + (1, 0, dict(key_warm=False, original_value=stored_value)), + # slot 2 cold fresh: zero -> nonzero + (2, stored_value, dict(key_warm=False, original_value=0)), + # slot 3 cold fresh: zero -> zero + (3, 0, dict(key_warm=False, original_value=0)), + # slot 3 warm fresh: zero -> nonzero + (3, stored_value, dict(key_warm=True, original_value=0)), + ] - # Source: yul - # berlin - # { - # // Use storage of 0x1000 and above for gas figures - # let storageLoc := 0x1000 - # - # // Gas spent on the measurement (two PUSHs, GAS, and SWAPs as - # // needed for the variables) - # let measureGas := 8 - # - # let gas0, gas1 - # - # // Cold storage, non-zero to non-zero - # gas0 := gas() - # sstore(0, 0xBEEF) - # gas1 := gas() - # sstore(storageLoc, sub(sub(gas0, gas1), measureGas)) - # storageLoc := add(storageLoc, 1) - # - # // Warm storage, non-zero to non-zero - # gas0 := gas() - # sstore(0, 0xDEADBEEF) - # gas1 := gas() - # sstore(storageLoc, sub(sub(gas0, gas1), measureGas)) - # storageLoc := add(storageLoc, 1) - # - # // Warm storage, non-zero to zero - # gas0 := gas() - # sstore(0, 0) - # gas1 := gas() - # sstore(storageLoc, sub(sub(gas0, gas1), measureGas)) - # ... (50 more lines) - target = pre.deploy_contract( # noqa: F841 - code=Op.PUSH1[0x1] - + Op.PUSH1[0x8] - + Op.DUP2 - + Op.DUP1 * 7 - + Op.PUSH2[0x1000] - + Op.DUP10 - + Op.GAS - + Op.SSTORE(key=0x0, value=0xBEEF) - + Op.GAS - + Op.SWAP1 - + Op.SUB - + Op.SSTORE(key=Op.DUP2, value=Op.SUB) - + Op.ADD - + Op.DUP9 - + Op.GAS - + Op.SSTORE(key=0x0, value=0xDEADBEEF) - + Op.GAS - + Op.SWAP1 - + Op.SUB - + Op.SSTORE(key=Op.DUP2, value=Op.SUB) - + Op.ADD - + Op.DUP8 - + Op.GAS - + Op.SSTORE(key=Op.DUP1, value=0x0) - + Op.GAS - + Op.SWAP1 - + Op.SUB - + Op.SSTORE(key=Op.DUP2, value=Op.SUB) - + Op.ADD - + Op.DUP7 - + Op.GAS - + Op.SSTORE(key=Op.DUP1, value=0x0) - + Op.GAS - + Op.SWAP1 - + Op.SUB - + Op.SSTORE(key=Op.DUP2, value=Op.SUB) - + Op.ADD - + Op.DUP6 - + Op.GAS - + Op.SSTORE(key=0x0, value=0x1234) - + Op.GAS - + Op.SWAP1 - + Op.SUB - + Op.SSTORE(key=Op.DUP2, value=Op.SUB) - + Op.ADD - + Op.DUP5 - + Op.GAS - + Op.SSTORE(key=Op.DUP5, value=0x0) - + Op.GAS - + Op.SWAP1 - + Op.SUB - + Op.SSTORE(key=Op.DUP2, value=Op.SUB) - + Op.ADD - + Op.DUP4 - + Op.GAS - + Op.SSTORE(key=0x2, value=0x60A7) - + Op.GAS - + Op.SWAP1 - + Op.SUB - + Op.SSTORE(key=Op.DUP2, value=Op.SUB) - + Op.ADD - + Op.DUP3 - + Op.GAS - + Op.SSTORE(key=0x3, value=0x0) - + Op.GAS - + Op.SWAP1 - + Op.SUB - + Op.SSTORE(key=Op.DUP2, value=Op.SUB) - + Op.ADD - + Op.SWAP1 - + Op.GAS - + Op.SSTORE(key=0x3, value=0x60A7) - + Op.GAS - + Op.SWAP1 - + Op.SUB - + Op.SSTORE(key=Op.DUP2, value=Op.SUB) - + Op.POP * 2 - + Op.SSTORE(key=Op.DUP1, value=0x0) - + Op.SSTORE(key=0x1, value=0x0) - + Op.SSTORE(key=0x2, value=0x0) - + Op.SSTORE(key=0x3, value=0x0) - + Op.STOP, - storage={0: 24743, 1: 24743}, - balance=0xBA1A9CE0BA1A9CE, - nonce=1, + code = Bytecode() + expected_gas: dict[int, int] = {} + for index, (slot, value, metadata) in enumerate(measurements): + store = Op.SSTORE(key=slot, value=value, new_value=value, **metadata) + gas_slot = gas_slot_base + index + code += CodeGasMeasure(code=store, sstore_key=gas_slot) + expected_gas[gas_slot] = store.gas_cost(fork) + + # Clear the working slots; only the gas figures remain. + for slot in sorted({slot for slot, _, _ in measurements}): + code += Op.SSTORE(key=slot, value=0) + code += Op.STOP + + target = pre.deploy_contract( + code=code, + storage={0: stored_value, 1: stored_value}, ) tx = Transaction( sender=sender, to=target, - data=Bytes(""), - gas_limit=16777216, - nonce=1, + # Keep EIP-8037 state gas visible to Op.GAS. + state_gas_reservoir=0, ) - post = { - target: Account( - storage={ - 4096: 5000, - 4097: 100, - 4098: 100, - 4099: 100, - 4100: 100, - 4101: 5000, - 4102: 22100, - 4103: 2200, - 4104: 20000, - }, - ), - } + post = {target: Account(storage=expected_gas)} - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stSStoreTest/test_sstore_gas_left.py b/tests/ported_static/stSStoreTest/test_sstore_gas_left.py index 30686397044..250d9f4a38c 100644 --- a/tests/ported_static/stSStoreTest/test_sstore_gas_left.py +++ b/tests/ported_static/stSStoreTest/test_sstore_gas_left.py @@ -1,35 +1,30 @@ """ -Checks EIP-1706/EIP-2200 out of gas requirement for non-mutating SSTOREs. +Verify the EIP-2200 (EIP-1706) minimum-gas rule for SSTORE: a non-mutating +store fails unless the gas left exceeds the call stipend, across CALL, +CALLCODE and DELEGATECALL entry into the storing frame. Ported from: state_tests/stSStoreTest/sstore_gasLeftFiller.json -@manually-enhanced: Do not overwrite. Gas budget refactored to be -fork-aware (`tx_gas = [intrinsic + tx_data[d].gas_cost(fork)]`), and -each `Op.CALL` annotated with `inner_call_cost=` metadata so -`Bytecode.gas_cost(fork)` covers the forwarded inner-frame gas. -Required for the test to fill correctly under EIP-8037's two- -dimensional gas model. Hex `gas=` literals also converted to -human-readable decimals. +@manually-enhanced: Do not overwrite. The stored-to slot is warmed +before the boundary call, so the stipend check - not the cold-access +charge EIP-8037/8038 reprice - binds on every fork. Boundary gas is +stipend + the store's own operand pushes +/- 1; the indicator's budget +is its full composite cost. Success is signalled by +`flag * indicator_gas`, not the ported hardcoded-pc JUMPI, and the +canary slot proves the caller ran to completion. """ import pytest from execution_testing import ( - EOA, Account, - Address, Alloc, - Environment, + Fork, StateTestFiller, Transaction, ) -from execution_testing.forks import Fork from execution_testing.vm import Op -from tests.ported_static.post_state_resolution import ( - resolve_expect_post, -) - REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" @@ -37,404 +32,96 @@ @pytest.mark.ported_from( ["state_tests/stSStoreTest/sstore_gasLeftFiller.json"], ) -@pytest.mark.valid_from("Cancun") +@pytest.mark.valid_from("Istanbul") +@pytest.mark.parametrize( + "opcode", + [ + pytest.param(Op.CALL, id="call"), + pytest.param(Op.CALLCODE, id="callcode"), + pytest.param(Op.DELEGATECALL, id="delegatecall"), + ], +) @pytest.mark.parametrize( - "d, g, v", + "gas_offset, store_succeeds", [ - pytest.param( - 0, - 0, - 0, - id="d0", - ), - pytest.param( - 1, - 0, - 0, - id="d1", - ), - pytest.param( - 2, - 0, - 0, - id="d2", - ), - pytest.param( - 3, - 0, - 0, - id="d3", - ), - pytest.param( - 4, - 0, - 0, - id="d4", - ), - pytest.param( - 5, - 0, - 0, - id="d5", - ), - pytest.param( - 6, - 0, - 0, - id="d6", - ), - pytest.param( - 7, - 0, - 0, - id="d7", - ), - pytest.param( - 8, - 0, - 0, - id="d8", - ), + pytest.param(-1, False, id="below_boundary"), + pytest.param(0, False, id="at_boundary"), + pytest.param(1, True, id="above_boundary"), ], ) -@pytest.mark.pre_alloc_mutable def test_sstore_gas_left( state_test: StateTestFiller, pre: Alloc, fork: Fork, - d: int, - g: int, - v: int, + opcode: Op, + gas_offset: int, + store_succeeds: bool, ) -> None: - """Checks EIP-1706/EIP-2200 out of gas requirement for non-mutating...""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = EOA( - key=0x4F31B3206FBF0E0E598B9B1A7D8AC86302A0FF1D8930738F1BEBAE9B67173E52 - ) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=10000000, + """A non-mutating SSTORE needs gas left above the call stipend.""" + # The storing frame: push the operands, then a no-op SSTORE. Gas left + # when it executes is the forwarded amount less these pushes, and + # EIP-2200 requires that to exceed the stipend. + store_operands = Op.PUSH1[0x1] * 2 + store_code = store_operands + Op.SSTORE + storer = pre.deploy_contract(code=store_code + Op.STOP, storage={1: 1}) + boundary_gas = ( + fork.gas_costs().CALL_STIPEND + + store_operands.gas_cost(fork) + + gas_offset ) - pre[sender] = Account(balance=0xE8D4A51000) - # Source: lll - # { [[1]] 1 } - addr = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x1, value=0x1) + Op.STOP, - storage={1: 1}, - nonce=0, - address=Address(0xB0409D84AB61455CB8BEC14B94F635146AB55613), # noqa: E501 + # Written only if the boundary call succeeded. Forward its full + # composite cost so EIP-8037 state gas is covered outright. + indicator_code = Op.SSTORE( + key=0x1, value=0x1, key_warm=False, original_value=0, new_value=1 ) - # Source: lll - # { [[1]] 1 } - addr_2 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x1, value=0x1) + Op.STOP, - nonce=0, - address=Address(0x4092B3905CFEA2485EA53222F41EB26E67587802), # noqa: E501 - ) - - expect_entries_: list[dict] = [ - { - "indexes": {"data": [0, 1, 3, 4, 6, 7], "gas": 0, "value": -1}, - "network": [">=Cancun"], - "result": {addr_2: Account(storage={1: 0})}, - }, - { - "indexes": {"data": [8, 2, 5], "gas": 0, "value": -1}, - "network": [">=Cancun"], - "result": {addr_2: Account(storage={1: 1})}, - }, - ] + indicator = pre.deploy_contract(code=indicator_code) + indicator_gas = indicator_code.gas_cost(fork) - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) + if opcode == Op.CALL: + # Warm the storer's slot (and pre-write it back to 1) with an + # unbounded call, so the boundary call's SSTORE is a warm no-op + # and only the stipend check can fail it. + prelude = Op.POP(Op.CALL(address=storer)) + boundary_call = opcode(gas=boundary_gas, address=storer) + else: + # CALLCODE/DELEGATECALL store into the caller's own slot 1: the + # pre-write makes the boundary store a warm no-op. + prelude = Op.SSTORE(key=0x1, value=0x1) + if opcode == Op.CALLCODE: + boundary_call = opcode(gas=boundary_gas, address=storer, value=0) + else: + boundary_call = opcode(gas=boundary_gas, address=storer) - tx_data = [ - Op.JUMPI( - pc=0x4B, - condition=Op.ISZERO( - Op.CALL( - gas=2305, - address=addr, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - inner_call_cost=2305, - ) - ), - ) + # The indicator gets gas only if the boundary call succeeded, so no + # jump destinations are needed. Without the canary a failure arm + # would also pass if the caller never reached the indicator. + canary_slot = 0xC0DE + caller = pre.deploy_contract( + code=prelude + Op.POP( Op.CALL( - gas=30_000, - address=addr_2, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - inner_call_cost=30_000, + gas=Op.MUL(indicator_gas, boundary_call), + address=indicator, ) ) - + Op.JUMPDEST + + Op.SSTORE(key=canary_slot, value=0x1) + Op.STOP, - Op.JUMPI( - pc=0x4B, - condition=Op.ISZERO( - Op.CALL( - gas=2306, - address=addr, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - inner_call_cost=2306, - ) - ), - ) - + Op.POP( - Op.CALL( - gas=30_000, - address=addr_2, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - inner_call_cost=30_000, - ) - ) - + Op.JUMPDEST - + Op.STOP, - Op.JUMPI( - pc=0x4B, - condition=Op.ISZERO( - Op.CALL( - gas=2307, - address=addr, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - inner_call_cost=2307, - ) - ), - ) - + Op.POP( - Op.CALL( - gas=30_000, - address=addr_2, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - inner_call_cost=30_000, - ) - ) - + Op.JUMPDEST - + Op.STOP, - Op.SSTORE(key=0x1, value=0x1) - + Op.JUMPI( - pc=0x50, - condition=Op.ISZERO( - Op.CALLCODE( - gas=2305, - address=addr, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - inner_call_cost=2305, - ) - ), - ) - + Op.POP( - Op.CALL( - gas=30_000, - address=addr_2, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - inner_call_cost=30_000, - ) - ) - + Op.JUMPDEST - + Op.STOP, - Op.SSTORE(key=0x1, value=0x1) - + Op.JUMPI( - pc=0x50, - condition=Op.ISZERO( - Op.CALLCODE( - gas=2306, - address=addr, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - inner_call_cost=2306, - ) - ), - ) - + Op.POP( - Op.CALL( - gas=30_000, - address=addr_2, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - inner_call_cost=30_000, - ) - ) - + Op.JUMPDEST - + Op.STOP, - Op.SSTORE(key=0x1, value=0x1) - + Op.JUMPI( - pc=0x50, - condition=Op.ISZERO( - Op.CALLCODE( - gas=2307, - address=addr, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - inner_call_cost=2307, - ) - ), - ) - + Op.POP( - Op.CALL( - gas=30_000, - address=addr_2, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - inner_call_cost=30_000, - ) - ) - + Op.JUMPDEST - + Op.STOP, - Op.SSTORE(key=0x1, value=0x1) - + Op.JUMPI( - pc=0x4E, - condition=Op.ISZERO( - Op.DELEGATECALL( - gas=2305, - address=addr, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - ), - ) - + Op.POP( - Op.CALL( - gas=30_000, - address=addr_2, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - inner_call_cost=30_000, - ) - ) - + Op.JUMPDEST - + Op.STOP, - Op.SSTORE(key=0x1, value=0x1) - + Op.JUMPI( - pc=0x4E, - condition=Op.ISZERO( - Op.DELEGATECALL( - gas=2306, - address=addr, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - ), - ) - + Op.POP( - Op.CALL( - gas=30_000, - address=addr_2, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - inner_call_cost=30_000, - ) - ) - + Op.JUMPDEST - + Op.STOP, - Op.SSTORE(key=0x1, value=0x1) - + Op.JUMPI( - pc=0x4E, - condition=Op.ISZERO( - Op.DELEGATECALL( - gas=2307, - address=addr, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - ), - ) - + Op.POP( - Op.CALL( - gas=30_000, - address=addr_2, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - inner_call_cost=30_000, - ) - ) - + Op.JUMPDEST - + Op.STOP, - ] - # Fork-aware gas budget: contract-creation intrinsic from the - # fork's calculator, plus the bytecode's own gas cost (which - # already includes the gas forwarded to inner CALLs via opcode - # metadata). Any future fork-cost change is automatically - # respected. - intrinsic = fork.transaction_intrinsic_cost_calculator()( - calldata=tx_data[d], - contract_creation=True, ) - tx_gas = [intrinsic + tx_data[d].gas_cost(fork)] - tx_value = [1] + + # CALLCODE / DELEGATECALL store into the caller's own slot 1. + caller_storage = {canary_slot: 1} + if opcode != Op.CALL: + caller_storage[1] = 1 tx = Transaction( - sender=sender, - to=None, - data=tx_data[d], - gas_limit=tx_gas[g], - value=tx_value[v], - error=_exc, + sender=pre.fund_eoa(), + to=caller, ) - state_test(env=env, pre=pre, post=post, tx=tx) + post = { + indicator: Account(storage={1: 1 if store_succeeds else 0}), + caller: Account(storage=caller_storage), + } + + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stSolidityTest/test_recursive_create_contracts.py b/tests/ported_static/stSolidityTest/test_recursive_create_contracts.py index 836f16024d1..c881d564f4f 100644 --- a/tests/ported_static/stSolidityTest/test_recursive_create_contracts.py +++ b/tests/ported_static/stSolidityTest/test_recursive_create_contracts.py @@ -1,22 +1,26 @@ """ -Test_recursive_create_contracts. +Verify recursively self-creating Solidity contracts stop when the +transaction budget runs dry, leaving exactly one child. Ported from: state_tests/stSolidityTest/RecursiveCreateContractsFiller.json + +@manually-enhanced: Do not overwrite. The EIP-8037 state gas of the +in-test creations is added to the ported budget as a fork-derived +surcharge (exactly 0 before EIP-8037), preserving the ported +behavior on every fork. """ import pytest from execution_testing import ( Account, - Address, Alloc, - Bytes, - Environment, Hash, StateTestFiller, Transaction, compute_create_address, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -26,244 +30,259 @@ @pytest.mark.ported_from( ["state_tests/stSolidityTest/RecursiveCreateContractsFiller.json"], ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable +@pytest.mark.valid_from("SpuriousDragon") def test_recursive_create_contracts( - state_test: StateTestFiller, - pre: Alloc, + state_test: StateTestFiller, pre: Alloc, fork: Fork ) -> None: - """Test_recursive_create_contracts.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0x095E7BAEA6A6C7C4C2DFEB977EFAC326AF552D87) - sender = pre.fund_eoa(amount=0x1DCD6500) + """Recursive contract creation runs dry at the expected depth.""" + sender = pre.fund_eoa() - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=100000000, - ) + # Source: solidity + # contract recursiveCreate1 + # { + # uint depp; + # function recursiveCreate1(address a, uint depth) + # { + # depth = depth - 1; + # depp = depth; + # if (depth > 0) + # main(a).create2(depth); // CALL back into main + # } + # } + # - # Source: raw - # 0x60003560e060020a90048063820b13f614610021578063a444f5e91461003257005b61002c600435610093565b60006000f35b61003d600435610043565b60006000f35b600073095e7baea6a6c7c4c2dfeb977efac326af552d8760008190555081600181905550606b6101ad600039606b600054600160a060020a0316815260200182815260200160006000f090505050565b600060c86100e560003960c8600054600160a060020a0316815260200182815260200160006000f0905080600160a060020a0316600060026000600060006000848787f16100dd57005b50505050505600604060c860043960045160245160006001820391508160008190555060008211602657604c565b606b605d600039606b83600160a060020a0316815260200182815260200160006000f090505b505050600180605c6000396000f300006040606b6004396004516024516001810390508060008190555060008111602457605b565b81600160a060020a031663820b13f6600060008260e060020a026000526004858152602001600060008660325a03f1605857005b50505b5050600180606a6000396000f300006040606b6004396004516024516001810390508060008190555060008111602457605b565b81600160a060020a031663820b13f6600060008260e060020a026000526004858152602001600060008660325a03f1605857005b50505b5050600180606a6000396000f30000 # noqa: E501 - contract_0 = pre.deploy_contract( # noqa: F841 - code=Op.CALLDATALOAD(offset=0x0) - + Op.EXP(0x2, 0xE0) - + Op.SWAP1 - + Op.DIV - + Op.JUMPI(pc=Op.PUSH2[0x21], condition=Op.EQ(0x820B13F6, Op.DUP1)) - + Op.JUMPI(pc=Op.PUSH2[0x32], condition=Op.EQ(0xA444F5E9, Op.DUP1)) + constructor_args_size = 0x40 # (address a, uint depth) + + return_contract_code = Op.JUMPDEST + Op.MSTORE(0, 0) + Op.RETURN(0, 1) + args_memory_offset = 0x40 + depth = Op.SUB(Op.MLOAD(offset=args_memory_offset + 0x20), 1) + initcode_1_p1 = ( + Op.CODECOPY( + dest_offset=args_memory_offset, + offset=Op.PUSH1(data_placeholder="initcode_size"), + size=constructor_args_size, + ) + + Op.SSTORE(0, depth) + + Op.JUMPI( + pc=Op.PUSH1(data_placeholder="callback_jumpdest"), + condition=Op.GT(depth, 0), + ) + + return_contract_code + ) + initcode_1_p2 = ( + Op.JUMPDEST # Callback jumpdest + + Op.MSTORE( + offset=0, + value=depth, + ) + + Op.JUMPI( + pc=Op.PUSH1(data_placeholder="return_contract_jumpdest"), + condition=Op.CALL( + gas=Op.SUB(Op.GAS, 0x32), + address=Op.MLOAD(offset=args_memory_offset), + args_offset=0, + args_size=0x20, + ), + ) + Op.STOP - + Op.JUMPDEST - + Op.PUSH2[0x2C] - + Op.CALLDATALOAD(offset=0x4) - + Op.JUMP(pc=Op.PUSH2[0x93]) - + Op.JUMPDEST - + Op.RETURN(offset=0x0, size=0x0) - + Op.JUMPDEST - + Op.PUSH2[0x3D] - + Op.CALLDATALOAD(offset=0x4) - + Op.JUMP(pc=Op.PUSH2[0x43]) - + Op.JUMPDEST - + Op.RETURN(offset=0x0, size=0x0) - + Op.JUMPDEST - + Op.PUSH1[0x0] - + Op.PUSH20[0x95E7BAEA6A6C7C4C2DFEB977EFAC326AF552D87] - + Op.PUSH1[0x0] - + Op.DUP2 - + Op.SWAP1 - + Op.SSTORE - + Op.POP - + Op.DUP2 - + Op.PUSH1[0x1] - + Op.DUP2 - + Op.SWAP1 - + Op.SSTORE - + Op.POP - + Op.CODECOPY(dest_offset=0x0, offset=0x1AD, size=0x6B) - + Op.PUSH1[0x6B] + + return_contract_code + ) + initcode_1 = initcode_1_p1 + initcode_1_p2 + initcode_1.substitute( + initcode_size=len(initcode_1), + return_contract_jumpdest=len(initcode_1) - len(return_contract_code), + callback_jumpdest=len(initcode_1_p1), + ) + + # Source: solidity + # contract recursiveCreate2 + # { + # uint depp; + # function recursiveCreate2(address a, uint depth) + # { + # depth = depth - 1; + # depp = depth; + # if (depth > 0) + # recursiveCreate1 rec1 = new recursiveCreate1(a, depth); + # } + # } + + args_memory_offset = len(initcode_1) + constructor_args_size + address = Op.MLOAD(offset=args_memory_offset) + depth = Op.SUB(Op.MLOAD(offset=args_memory_offset + 0x20), 1) + initcode_2_p1 = ( + Op.CODECOPY( + dest_offset=args_memory_offset, + offset=Op.PUSH1(data_placeholder="calldata_offset"), + size=constructor_args_size, + ) + + Op.SSTORE(0, depth) + + Op.JUMPI( + pc=Op.PUSH1(data_placeholder="create_jumpdest"), + condition=Op.GT(depth, 0), + ) + + return_contract_code + ) + initcode_2_p2 = ( + Op.JUMPDEST # create jumpdest + + Op.CODECOPY( + dest_offset=0, + offset=Op.PUSH1(data_placeholder="initcode_1_offset"), + size=len(initcode_1), + ) + Op.MSTORE( - offset=Op.DUP2, - value=Op.AND(Op.SUB(Op.EXP(0x2, 0xA0), 0x1), Op.SLOAD(key=0x0)), + offset=len(initcode_1), + value=address, ) - + Op.PUSH1[0x20] - + Op.ADD - + Op.MSTORE(offset=Op.DUP2, value=Op.DUP3) - + Op.PUSH1[0x20] - + Op.CREATE(value=0x0, offset=0x0, size=Op.ADD) - + Op.SWAP1 - + Op.POP * 3 - + Op.JUMP - + Op.JUMPDEST - + Op.PUSH1[0x0] - + Op.CODECOPY(dest_offset=0x0, offset=Op.PUSH2[0xE5], size=0xC8) - + Op.PUSH1[0xC8] + Op.MSTORE( - offset=Op.DUP2, - value=Op.AND(Op.SUB(Op.EXP(0x2, 0xA0), 0x1), Op.SLOAD(key=0x0)), + offset=len(initcode_1) + 0x20, + value=depth, + ) + + Op.POP( + Op.CREATE( + value=0, + offset=0, + size=len(initcode_1) + constructor_args_size, + ) + ) + + return_contract_code + ) + initcode_2 = initcode_2_p1 + initcode_2_p2 + initcode_2.substitute( + create_jumpdest=len(initcode_2_p1), + initcode_1_offset=len(initcode_2), + calldata_offset=len(initcode_2) + len(initcode_1), + ) + + # Source: solidity + # contract main + # { + # address maincontract; + # uint depp; + # function run(uint depth) + # { + # maincontract = 0x095e7baea6a6c7c4c2dfeb977efac326af552d87; + # depp = depth; + # recursiveCreate1 rec1 = new recursiveCreate1(maincontract,depth); + # } + # + # function create2(uint depth) + # { + # recursiveCreate2 rec2 = new recursiveCreate2(maincontract,depth); + # address(rec2).send(2); + # } + # } + + dispatcher = Op.JUMPI( + pc=Op.PUSH2(data_placeholder="tx_entry_func_offset"), + condition=Op.EQ(1, Op.CALLVALUE), + ) + + depth = Op.CALLDATALOAD(offset=0) + + # tx_entry_func -> initcode_1 -> re_entry_func -> initcode_2 + # ^ | + # |_______________________________| + + re_entry_func_p1 = ( + Op.JUMPDEST + + Op.CODECOPY( + dest_offset=0, + offset=Op.PUSH2(data_placeholder="initcode_2_offset"), + size=len(initcode_2) + len(initcode_1), + ) + + Op.MSTORE( + offset=len(initcode_2) + len(initcode_1), + value=Op.ADDRESS, + ) + + Op.MSTORE( + offset=len(initcode_2) + len(initcode_1) + 0x20, + value=depth, ) - + Op.PUSH1[0x20] - + Op.ADD - + Op.MSTORE(offset=Op.DUP2, value=Op.DUP3) - + Op.PUSH1[0x20] - + Op.CREATE(value=0x0, offset=0x0, size=Op.ADD) - + Op.SWAP1 - + Op.POP - + Op.AND(Op.SUB(Op.EXP(0x2, 0xA0), 0x1), Op.DUP1) - + Op.PUSH1[0x0] - + Op.PUSH1[0x2] + Op.JUMPI( - pc=Op.PUSH2[0xDD], + pc=Op.PUSH2(data_placeholder="re_entry_func_send_ok_offset"), condition=Op.CALL( - gas=Op.DUP8, - address=Op.DUP8, - value=Op.DUP5, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, + gas=0, + address=Op.CREATE( + value=0, + offset=0, + size=len(initcode_2) + len(initcode_1) + 0x40, + ), + value=1, ), ) - + Op.STOP - + Op.JUMPDEST - + Op.POP * 5 - + Op.JUMP - + Op.STOP - + Op.CODECOPY(dest_offset=0x4, offset=0xC8, size=0x40) - + Op.MLOAD(offset=0x4) - + Op.MLOAD(offset=0x24) - + Op.PUSH1[0x0] - + Op.SUB(Op.DUP3, 0x1) - + Op.SWAP2 - + Op.POP - + Op.DUP2 - + Op.PUSH1[0x0] - + Op.DUP2 - + Op.SWAP1 - + Op.SSTORE - + Op.POP - + Op.JUMPI(pc=0x26, condition=Op.GT(Op.DUP3, 0x0)) - + Op.JUMP(pc=0x4C) - + Op.JUMPDEST - + Op.CODECOPY(dest_offset=0x0, offset=0x5D, size=0x6B) - + Op.PUSH1[0x6B] + + Op.INVALID # Send fails + ) + re_entry_func_p2 = Op.JUMPDEST + Op.RETURN(offset=0, size=0) + re_entry_func = re_entry_func_p1 + re_entry_func_p2 + + tx_entry_func = ( + Op.JUMPDEST + + Op.SSTORE(0, Op.ADDRESS) + + Op.SSTORE(1, depth) + + Op.CODECOPY( + dest_offset=0, + offset=Op.PUSH2(data_placeholder="initcode_1_offset"), + size=len(initcode_1), + ) + Op.MSTORE( - offset=Op.DUP2, - value=Op.AND(Op.SUB(Op.EXP(0x2, 0xA0), 0x1), Op.DUP4), + offset=len(initcode_1), + value=Op.ADDRESS, ) - + Op.PUSH1[0x20] - + Op.ADD - + Op.MSTORE(offset=Op.DUP2, value=Op.DUP3) - + Op.PUSH1[0x20] - + Op.CREATE(value=0x0, offset=0x0, size=Op.ADD) - + Op.SWAP1 - + Op.POP - + Op.JUMPDEST - + Op.POP * 3 - + Op.PUSH1[0x1] - + Op.CODECOPY(dest_offset=0x0, offset=0x5C, size=Op.DUP1) - + Op.PUSH1[0x0] - + Op.RETURN - + Op.STOP * 2 - + Op.CODECOPY(dest_offset=0x4, offset=0x6B, size=0x40) - + Op.MLOAD(offset=0x4) - + Op.MLOAD(offset=0x24) - + Op.SUB(Op.DUP2, 0x1) - + Op.SWAP1 - + Op.POP - + Op.DUP1 - + Op.PUSH1[0x0] - + Op.DUP2 - + Op.SWAP1 - + Op.SSTORE - + Op.POP - + Op.JUMPI(pc=0x24, condition=Op.GT(Op.DUP2, 0x0)) - + Op.JUMP(pc=0x5B) - + Op.JUMPDEST - + Op.AND(Op.SUB(Op.EXP(0x2, 0xA0), 0x1), Op.DUP2) - + Op.PUSH4[0x820B13F6] - + Op.PUSH1[0x0] * 2 - + Op.MSTORE(offset=0x0, value=Op.MUL(Op.EXP(0x2, 0xE0), Op.DUP3)) - + Op.PUSH1[0x4] - + Op.MSTORE(offset=Op.DUP2, value=Op.DUP6) - + Op.PUSH1[0x20] - + Op.ADD - + Op.PUSH1[0x0] * 2 - + Op.DUP7 - + Op.SUB(Op.GAS, 0x32) - + Op.JUMPI(pc=0x58, condition=Op.CALL) - + Op.STOP - + Op.JUMPDEST - + Op.POP * 2 - + Op.JUMPDEST - + Op.POP * 2 - + Op.PUSH1[0x1] - + Op.CODECOPY(dest_offset=0x0, offset=0x6A, size=Op.DUP1) - + Op.PUSH1[0x0] - + Op.RETURN - + Op.STOP * 2 - + Op.CODECOPY(dest_offset=0x4, offset=0x6B, size=0x40) - + Op.MLOAD(offset=0x4) - + Op.MLOAD(offset=0x24) - + Op.SUB(Op.DUP2, 0x1) - + Op.SWAP1 - + Op.POP - + Op.DUP1 - + Op.PUSH1[0x0] - + Op.DUP2 - + Op.SWAP1 - + Op.SSTORE - + Op.POP - + Op.JUMPI(pc=0x24, condition=Op.GT(Op.DUP2, 0x0)) - + Op.JUMP(pc=0x5B) - + Op.JUMPDEST - + Op.AND(Op.SUB(Op.EXP(0x2, 0xA0), 0x1), Op.DUP2) - + Op.PUSH4[0x820B13F6] - + Op.PUSH1[0x0] * 2 - + Op.MSTORE(offset=0x0, value=Op.MUL(Op.EXP(0x2, 0xE0), Op.DUP3)) - + Op.PUSH1[0x4] - + Op.MSTORE(offset=Op.DUP2, value=Op.DUP6) - + Op.PUSH1[0x20] - + Op.ADD - + Op.PUSH1[0x0] * 2 - + Op.DUP7 - + Op.SUB(Op.GAS, 0x32) - + Op.JUMPI(pc=0x58, condition=Op.CALL) - + Op.STOP - + Op.JUMPDEST - + Op.POP * 2 - + Op.JUMPDEST - + Op.POP * 2 - + Op.PUSH1[0x1] - + Op.CODECOPY(dest_offset=0x0, offset=0x6A, size=Op.DUP1) - + Op.PUSH1[0x0] - + Op.RETURN - + Op.STOP * 2, - balance=0x314DC6448D9338C15B0A00000000, - nonce=0, - address=Address(0x095E7BAEA6A6C7C4C2DFEB977EFAC326AF552D87), # noqa: E501 + + Op.MSTORE( + offset=len(initcode_1) + 0x20, + value=depth, + ) + + Op.POP( + Op.CREATE( + value=0, + offset=0, + size=len(initcode_1) + 0x40, + ) + ) + + Op.RETURN(offset=0, size=0) ) + factory_code = ( + dispatcher + re_entry_func + tx_entry_func + initcode_2 + initcode_1 + ) + + jump_targets = { + "re_entry_func_send_ok_offset": ( + len(dispatcher) + len(re_entry_func_p1) + ), + "tx_entry_func_offset": (len(dispatcher) + len(re_entry_func)), + } + + initcode_2_offset = ( + len(dispatcher) + len(tx_entry_func) + len(re_entry_func) + ) + initcode_1_offset = initcode_2_offset + len(initcode_2) + factory_code.substitute( + **jump_targets, + initcode_2_offset=initcode_2_offset, + initcode_1_offset=initcode_1_offset, + ) + + factory = pre.deploy_contract(code=factory_code, balance=0x20000000) + + max_depth = 772 tx = Transaction( sender=sender, - to=contract_0, - data=Bytes("a444f5e9") + Hash(0x304), - gas_limit=300000, + to=factory, + data=Hash(max_depth), value=1, ) post = { - contract_0: Account( - storage={0: contract_0, 1: 772}, - balance=0x314DC6448D9338C15B0A00000001, - nonce=1, + factory: Account( + storage={0: factory, 1: max_depth}, ), sender: Account(nonce=1), - compute_create_address(address=contract_0, nonce=0): Account( - storage={0: 771}, nonce=1 + # Check only first created contract + compute_create_address(address=factory, nonce=1): Account( + storage={0: max_depth - 1}, nonce=1 ), } - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stSolidityTest/test_test_contract_interaction.py b/tests/ported_static/stSolidityTest/test_test_contract_interaction.py index 0bcc63132a1..1700f30c0af 100644 --- a/tests/ported_static/stSolidityTest/test_test_contract_interaction.py +++ b/tests/ported_static/stSolidityTest/test_test_contract_interaction.py @@ -1,20 +1,27 @@ """ -Test_test_contract_interaction. +Verify a Solidity contract creating a child and interacting with it +through its dispatcher within the same transaction. Ported from: state_tests/stSolidityTest/TestContractInteractionFiller.json + +@manually-enhanced: Do not overwrite. Every absolute jump target and +CODECOPY offset is a `data_placeholder` resolved from the section +lengths, so the two contracts can be edited without hand-maintaining +offsets. The original Solidity was recovered from the filler. The +ported `valid_from` was lowered from Cancun to Frontier; the tx needs +`protected=fork.supports_protected_txs()` to reach the pre-EIP-155 +forks, so do not drop it. """ import pytest from execution_testing import ( Account, - Address, Alloc, - Bytes, - Environment, StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -24,91 +31,226 @@ @pytest.mark.ported_from( ["state_tests/stSolidityTest/TestContractInteractionFiller.json"], ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable +@pytest.mark.valid_from("Frontier") def test_test_contract_interaction( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: - """Test_test_contract_interaction.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = pre.fund_eoa(amount=0x5F5E100) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=100000000, - ) - - # Source: raw - # 0x7c01000000000000000000000000000000000000000000000000000000006000350463c04062268114610039578063ed973fe91461004b57005b6100416100ea565b8060005260206000f35b61005361005d565b8060005260206000f35b60006000608161011a600039608160006000f0905073ffffffffffffffffffffffffffffffffffffffff811663b9c3d0a5602060007fb9c3d0a50000000000000000000000000000000000000000000000000000000081526004600060008660325a03f16100c757005b505060005160e1146100d8576100e1565b600191506100e6565b600091505b5090565b60006100f461005d565b600060006101000a81548160ff0219169083021790555060ff600160005404169050905600607580600c6000396000f3007c01000000000000000000000000000000000000000000000000000000006000350462f55d9d81146036578063b9c3d0a514604557005b603f6004356055565b60006000f35b604b6070565b8060005260206000f35b8073ffffffffffffffffffffffffffffffffffffffff16ff50565b60e19056 # noqa: E501 - target = pre.deploy_contract( # noqa: F841 - code=Op.DIV( - Op.CALLDATALOAD(offset=0x0), - 0x100000000000000000000000000000000000000000000000000000000, + """Create a child contract and interact with it in one transaction.""" + sender = pre.fund_eoa() + + # Source: solidity + # contract TestContract + # { + # function testMethod() returns (int res) + # { + # return 225; + # } + # + # function destroy(address sendFoundsTo) + # { + # suicide(sendFoundsTo); + # } + # } + # + # contract main + # { + # bool returnValue; + # function run() returns (bool) + # { + # returnValue = testContractInteraction(); + # return returnValue; + # } + # + # function testContractInteraction() returns (bool res) + # { + # TestContract a = new TestContract(); + # if (a.testMethod() == 225) + # return true; + # return false; + # } + # } + run_selector = 0xC0406226 # run() + interaction_selector = 0xED973FE9 # testContractInteraction() + test_method_selector = 0xB9C3D0A5 # testMethod() + destroy_selector = 0xF55D9D # destroy(address) + test_method_result = 0xE1 # the 225 that testMethod returns + # A selector occupies the top four bytes of the first calldata word. + selector_shift = 2**224 + address_mask = 2**160 - 1 + + # --- TestContract: dispatcher, two entry stubs, two bodies. Its own + # jump targets are offsets into its runtime, not the factory's code. + child_dispatcher = ( + Op.DIV(Op.CALLDATALOAD(offset=0x0), selector_shift) + + Op.JUMPI( + pc=Op.PUSH1(data_placeholder="destroy_stub"), + condition=Op.EQ(Op.DUP2, destroy_selector), + ) + + Op.JUMPI( + pc=Op.PUSH1(data_placeholder="test_method_stub"), + condition=Op.EQ(test_method_selector, Op.DUP1), ) - + Op.JUMPI(pc=Op.PUSH2[0x39], condition=Op.EQ(Op.DUP2, 0xC0406226)) - + Op.JUMPI(pc=Op.PUSH2[0x4B], condition=Op.EQ(0xED973FE9, Op.DUP1)) + Op.STOP - + Op.JUMPDEST - + Op.PUSH2[0x41] - + Op.JUMP(pc=Op.PUSH2[0xEA]) - + Op.JUMPDEST + ) + destroy_stub = ( + Op.JUMPDEST + + Op.PUSH1(data_placeholder="destroy_return") + + Op.CALLDATALOAD(offset=0x4) + + Op.JUMP(pc=Op.PUSH1(data_placeholder="destroy_body")) + ) + destroy_return = Op.JUMPDEST + Op.RETURN(offset=0x0, size=0x0) + test_method_stub = ( + Op.JUMPDEST + + Op.PUSH1(data_placeholder="test_method_return") + + Op.JUMP(pc=Op.PUSH1(data_placeholder="test_method_body")) + ) + test_method_return = ( + Op.JUMPDEST + Op.MSTORE(offset=0x0, value=Op.DUP1) + Op.RETURN(offset=0x0, size=0x20) - + Op.JUMPDEST - + Op.PUSH2[0x53] - + Op.JUMP(pc=Op.PUSH2[0x5D]) - + Op.JUMPDEST + ) + destroy_body = ( + Op.JUMPDEST + + Op.SELFDESTRUCT(address=Op.AND(address_mask, Op.DUP1)) + + Op.POP + + Op.JUMP + ) + test_method_body = ( + Op.JUMPDEST + Op.PUSH1[test_method_result] + Op.SWAP1 + Op.JUMP + ) + child_runtime = ( + child_dispatcher + + destroy_stub + + destroy_return + + test_method_stub + + test_method_return + + destroy_body + + test_method_body + ) + child_targets = {} + _cursor = 0 + for _name, _section in ( + ("destroy_stub", child_dispatcher), + ("destroy_return", destroy_stub), + ("test_method_stub", destroy_return), + ("test_method_return", test_method_stub), + ("destroy_body", test_method_return), + ("test_method_body", destroy_body), + ): + _cursor += len(_section) + child_targets[_name] = _cursor + child_runtime.substitute(**child_targets) + + # TestContract's creation code: return the runtime that follows it. + child_init = ( + Op.PUSH1[len(child_runtime)] + + Op.CODECOPY( + dest_offset=0x0, + offset=Op.PUSH1(data_placeholder="child_runtime_offset"), + size=Op.DUP1, + ) + + Op.PUSH1[0x0] + + Op.RETURN + + Op.STOP + ) + child_init.substitute(child_runtime_offset=len(child_init)) + child_code = child_init + child_runtime + + # --- main: selector dispatch, then a stub per function that pushes a + # return address, jumps into the body, and returns the result word. + parent_dispatcher = ( + Op.DIV(Op.CALLDATALOAD(offset=0x0), selector_shift) + + Op.JUMPI( + pc=Op.PUSH2(data_placeholder="run_stub"), + condition=Op.EQ(Op.DUP2, run_selector), + ) + + Op.JUMPI( + pc=Op.PUSH2(data_placeholder="interaction_stub"), + condition=Op.EQ(interaction_selector, Op.DUP1), + ) + + Op.STOP + ) + run_stub = ( + Op.JUMPDEST + + Op.PUSH2(data_placeholder="run_return") + + Op.JUMP(pc=Op.PUSH2(data_placeholder="run_body")) + ) + run_return = ( + Op.JUMPDEST + + Op.MSTORE(offset=0x0, value=Op.DUP1) + + Op.RETURN(offset=0x0, size=0x20) + ) + interaction_stub = ( + Op.JUMPDEST + + Op.PUSH2(data_placeholder="interaction_return") + + Op.JUMP(pc=Op.PUSH2(data_placeholder="interaction_body_from_stub")) + ) + interaction_return = ( + Op.JUMPDEST + Op.MSTORE(offset=0x0, value=Op.DUP1) + Op.RETURN(offset=0x0, size=0x20) - + Op.JUMPDEST + ) + + # testContractInteraction(): CREATE the child from the payload + # appended to this code, then CALL its testMethod(). + interaction_body = ( + Op.JUMPDEST + Op.PUSH1[0x0] * 2 - + Op.CODECOPY(dest_offset=0x0, offset=0x11A, size=0x81) - + Op.CREATE(value=0x0, offset=0x0, size=0x81) + + Op.CODECOPY( + dest_offset=0x0, + offset=Op.PUSH2(data_placeholder="child_code_offset"), + size=len(child_code), + ) + + Op.CREATE(value=0x0, offset=0x0, size=len(child_code)) + Op.SWAP1 + Op.POP - + Op.AND(Op.DUP2, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.PUSH4[0xB9C3D0A5] + + Op.AND(Op.DUP2, address_mask) + + Op.PUSH4[test_method_selector] + Op.PUSH1[0x20] + Op.PUSH1[0x0] + Op.MSTORE( - offset=Op.DUP2, - value=0xB9C3D0A500000000000000000000000000000000000000000000000000000000, # noqa: E501 + offset=Op.DUP2, value=test_method_selector * selector_shift ) + Op.PUSH1[0x4] + Op.PUSH1[0x0] * 2 + Op.DUP7 + Op.SUB(Op.GAS, 0x32) - + Op.JUMPI(pc=Op.PUSH2[0xC7], condition=Op.CALL) + + Op.JUMPI(pc=Op.PUSH2(data_placeholder="call_ok"), condition=Op.CALL) + Op.STOP - + Op.JUMPDEST + ) + + # Compare the returned word against 225 and yield true / false. + call_ok = ( + Op.JUMPDEST + Op.POP * 2 + Op.JUMPI( - pc=Op.PUSH2[0xD8], condition=Op.EQ(0xE1, Op.MLOAD(offset=0x0)) + pc=Op.PUSH2(data_placeholder="result_true"), + condition=Op.EQ(test_method_result, Op.MLOAD(offset=0x0)), ) - + Op.JUMP(pc=Op.PUSH2[0xE1]) - + Op.JUMPDEST + + Op.JUMP(pc=Op.PUSH2(data_placeholder="result_false")) + ) + result_true = ( + Op.JUMPDEST + Op.PUSH1[0x1] + Op.SWAP2 + Op.POP - + Op.JUMP(pc=Op.PUSH2[0xE6]) - + Op.JUMPDEST - + Op.PUSH1[0x0] - + Op.SWAP2 - + Op.POP - + Op.JUMPDEST - + Op.POP - + Op.SWAP1 - + Op.JUMP - + Op.JUMPDEST + + Op.JUMP(pc=Op.PUSH2(data_placeholder="interaction_join")) + ) + result_false = Op.JUMPDEST + Op.PUSH1[0x0] + Op.SWAP2 + Op.POP + interaction_join = Op.JUMPDEST + Op.POP + Op.SWAP1 + Op.JUMP + + # run(): call testContractInteraction internally, then pack the bool + # it returns into byte 0 of slot 0 (`returnValue`). + run_body = ( + Op.JUMPDEST + Op.PUSH1[0x0] - + Op.PUSH2[0xF4] - + Op.JUMP(pc=Op.PUSH2[0x5D]) - + Op.JUMPDEST + + Op.PUSH2(data_placeholder="run_store") + + Op.JUMP(pc=Op.PUSH2(data_placeholder="interaction_body_from_run")) + ) + run_store = ( + Op.JUMPDEST + Op.PUSH1[0x0] + Op.EXP(0x100, 0x0) + Op.AND(Op.NOT(Op.MUL(0xFF, Op.DUP2)), Op.SLOAD(key=Op.DUP2)) @@ -123,52 +265,80 @@ def test_test_contract_interaction( + Op.SWAP1 + Op.JUMP + Op.STOP - + Op.PUSH1[0x75] - + Op.CODECOPY(dest_offset=0x0, offset=0xC, size=Op.DUP1) - + Op.PUSH1[0x0] - + Op.RETURN - + Op.STOP - + Op.DIV( - Op.CALLDATALOAD(offset=0x0), - 0x100000000000000000000000000000000000000000000000000000000, - ) - + Op.JUMPI(pc=0x36, condition=Op.EQ(Op.DUP2, 0xF55D9D)) - + Op.JUMPI(pc=0x45, condition=Op.EQ(0xB9C3D0A5, Op.DUP1)) - + Op.STOP - + Op.JUMPDEST - + Op.PUSH1[0x3F] - + Op.CALLDATALOAD(offset=0x4) - + Op.JUMP(pc=0x55) - + Op.JUMPDEST - + Op.RETURN(offset=0x0, size=0x0) - + Op.JUMPDEST - + Op.PUSH1[0x4B] - + Op.JUMP(pc=0x70) - + Op.JUMPDEST - + Op.MSTORE(offset=0x0, value=Op.DUP1) - + Op.RETURN(offset=0x0, size=0x20) - + Op.JUMPDEST - + Op.SELFDESTRUCT( - address=Op.AND(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, Op.DUP1) + ) + parent_runtime = ( + parent_dispatcher + + run_stub + + run_return + + interaction_stub + + interaction_return + + interaction_body + + call_ok + + result_true + + result_false + + interaction_join + + run_body + + run_store + ) + + # Resolve each absolute position from the sections that precede it. + # `interaction_body` is reached from two sites, so it carries two + # placeholder names - a name may only appear once per concatenation. + parent_targets = {} + _cursor = 0 + for _name, _section in ( + ("run_stub", parent_dispatcher), + ("run_return", run_stub), + ("interaction_stub", run_return), + ("interaction_return", interaction_stub), + ("interaction_body", interaction_return), + ("call_ok", interaction_body), + ("result_true", call_ok), + ("result_false", result_true), + ("interaction_join", result_false), + ("run_body", interaction_join), + ("run_store", run_body), + ): + _cursor += len(_section) + parent_targets[_name] = _cursor + interaction_body_offset = parent_targets.pop("interaction_body") + parent_runtime.substitute( + **parent_targets, + interaction_body_from_stub=interaction_body_offset, + interaction_body_from_run=interaction_body_offset, + child_code_offset=len(parent_runtime), + ) + factory_code = parent_runtime + child_code + + # Placeholders keep the offsets consistent with the section lengths + # but not with their contents, so check each target still lands on a + # JUMPDEST - that catches a section reshuffled at an unchanged size. + code_bytes = bytes(factory_code) + for _name, _target in ( + list(parent_targets.items()) + + [("interaction_body", interaction_body_offset)] + + [ + (f"child.{k}", len(parent_runtime) + len(child_init) + v) + for k, v in child_targets.items() + ] + ): + assert code_bytes[_target] == bytes(Op.JUMPDEST)[0], ( + f"{_name} (0x{_target:x}) is no longer a JUMPDEST" ) - + Op.POP - + Op.JUMP - + Op.JUMPDEST - + Op.PUSH1[0xE1] - + Op.SWAP1 - + Op.JUMP, + + target = pre.deploy_contract( + code=factory_code, balance=0x186A0, - nonce=0, ) tx = Transaction( sender=sender, to=target, - data=Bytes("c0406226"), - gas_limit=350000, + data=run_selector.to_bytes(length=4), value=1, + protected=fork.supports_protected_txs(), ) - post = {target: Account(storage={0: 1}, nonce=1)} + post = {target: Account(storage={0: 1}, nonce=2)} - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stSolidityTest/test_test_contract_suicide.py b/tests/ported_static/stSolidityTest/test_test_contract_suicide.py index 1dd26ffea76..5594a91212f 100644 --- a/tests/ported_static/stSolidityTest/test_test_contract_suicide.py +++ b/tests/ported_static/stSolidityTest/test_test_contract_suicide.py @@ -1,20 +1,27 @@ """ -Test_test_contract_suicide. +Verify a Solidity contract that creates a child, tells it to +self-destruct, and re-calls it within the same transaction. Ported from: state_tests/stSolidityTest/TestContractSuicideFiller.json + +@manually-enhanced: Do not overwrite. Every absolute jump target and +CODECOPY offset is a `data_placeholder` resolved from the section +lengths, so the two contracts can be edited without hand-maintaining +offsets. The original Solidity was recovered from the filler. The +ported `valid_from` was lowered from Cancun to Frontier; the tx needs +`protected=fork.supports_protected_txs()` to reach the pre-EIP-155 +forks, so do not drop it. """ import pytest from execution_testing import ( Account, - Address, Alloc, - Bytes, - Environment, StateTestFiller, Transaction, ) +from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -24,113 +31,262 @@ @pytest.mark.ported_from( ["state_tests/stSolidityTest/TestContractSuicideFiller.json"], ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable +@pytest.mark.valid_from("Frontier") def test_test_contract_suicide( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: - """Test_test_contract_suicide.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = pre.fund_eoa(amount=0x5F5E100) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=100000000, - ) - - # Source: raw - # 0x7c01000000000000000000000000000000000000000000000000000000006000350463a60eedda8114610039578063c04062261461004b57005b61004161005d565b8060005260206000f35b61005361015a565b8060005260206000f35b60006000608161018a600039608160006000f0905073ffffffffffffffffffffffffffffffffffffffff811662f55d9d6000807ef55d9d00000000000000000000000000000000000000000000000000000000825260044173ffffffffffffffffffffffffffffffffffffffff168152602001600060008660325a03f16100e057005b505073ffffffffffffffffffffffffffffffffffffffff811663b9c3d0a5602060007fb9c3d0a50000000000000000000000000000000000000000000000000000000081526004600060008660325a03f161013757005b505060005160e11461014857610151565b60019150610156565b600091505b5090565b600061016461005d565b600060006101000a81548160ff0219169083021790555060ff600160005404169050905600607580600c6000396000f3007c01000000000000000000000000000000000000000000000000000000006000350462f55d9d81146036578063b9c3d0a514604557005b603f600435605a565b60006000f35b604b6055565b8060005260206000f35b60e190565b8073ffffffffffffffffffffffffffffffffffffffff16ff5056 # noqa: E501 - target = pre.deploy_contract( # noqa: F841 - code=Op.DIV( - Op.CALLDATALOAD(offset=0x0), - 0x100000000000000000000000000000000000000000000000000000000, + """Create a child, destroy it, and call it again in one transaction.""" + sender = pre.fund_eoa() + + # Source: solidity + # contract TestContract + # { + # function testMethod() returns (int res) + # { + # return 225; + # } + # + # function destroy(address sendFoundsTo) + # { + # suicide(sendFoundsTo); + # } + # } + # + # contract main + # { + # bool returnValue; + # function run() returns (bool) + # { + # returnValue = testContractSuicide(); + # return returnValue; + # } + # + # function testContractSuicide() returns (bool res) + # { + # TestContract a = new TestContract(); + # a.destroy(block.coinbase); + # if (a.testMethod() == 225) //we should be able to call it + # return true; + # return false; + # } + # } + run_selector = 0xC0406226 # run() + suicide_selector = 0xA60EEDDA # testContractSuicide() + test_method_selector = 0xB9C3D0A5 # testMethod() + destroy_selector = 0xF55D9D # destroy(address) + test_method_result = 0xE1 # the 225 that testMethod returns + # A selector occupies the top four bytes of the first calldata word. + selector_shift = 2**224 + address_mask = 2**160 - 1 + + def offsets( + pairs: tuple[tuple[str, object], ...], + ) -> dict[str, int]: + """Map each target name to the total length of all code before it.""" + resolved: dict[str, int] = {} + cursor = 0 + for name, section in pairs: + cursor += len(section) # type: ignore[arg-type] + resolved[name] = cursor + return resolved + + # --- TestContract: dispatcher, two entry stubs, two bodies. Its jump + # targets are offsets into its own runtime, not the factory's code. + child_dispatcher = ( + Op.DIV(Op.CALLDATALOAD(offset=0x0), selector_shift) + + Op.JUMPI( + pc=Op.PUSH1(data_placeholder="destroy_stub"), + condition=Op.EQ(Op.DUP2, destroy_selector), + ) + + Op.JUMPI( + pc=Op.PUSH1(data_placeholder="test_method_stub"), + condition=Op.EQ(test_method_selector, Op.DUP1), ) - + Op.JUMPI(pc=Op.PUSH2[0x39], condition=Op.EQ(Op.DUP2, 0xA60EEDDA)) - + Op.JUMPI(pc=Op.PUSH2[0x4B], condition=Op.EQ(0xC0406226, Op.DUP1)) + Op.STOP - + Op.JUMPDEST - + Op.PUSH2[0x41] - + Op.JUMP(pc=Op.PUSH2[0x5D]) - + Op.JUMPDEST + ) + destroy_stub = ( + Op.JUMPDEST + + Op.PUSH1(data_placeholder="destroy_return") + + Op.CALLDATALOAD(offset=0x4) + + Op.JUMP(pc=Op.PUSH1(data_placeholder="destroy_body")) + ) + destroy_return = Op.JUMPDEST + Op.RETURN(offset=0x0, size=0x0) + test_method_stub = ( + Op.JUMPDEST + + Op.PUSH1(data_placeholder="test_method_return") + + Op.JUMP(pc=Op.PUSH1(data_placeholder="test_method_body")) + ) + test_method_return = ( + Op.JUMPDEST + Op.MSTORE(offset=0x0, value=Op.DUP1) + Op.RETURN(offset=0x0, size=0x20) - + Op.JUMPDEST - + Op.PUSH2[0x53] - + Op.JUMP(pc=0x15A) - + Op.JUMPDEST + ) + test_method_body = ( + Op.JUMPDEST + Op.PUSH1[test_method_result] + Op.SWAP1 + Op.JUMP + ) + destroy_body = ( + Op.JUMPDEST + + Op.SELFDESTRUCT(address=Op.AND(address_mask, Op.DUP1)) + + Op.POP + + Op.JUMP + ) + child_runtime = ( + child_dispatcher + + destroy_stub + + destroy_return + + test_method_stub + + test_method_return + + test_method_body + + destroy_body + ) + child_targets = offsets( + ( + ("destroy_stub", child_dispatcher), + ("destroy_return", destroy_stub), + ("test_method_stub", destroy_return), + ("test_method_return", test_method_stub), + ("test_method_body", test_method_return), + ("destroy_body", test_method_body), + ) + ) + child_runtime.substitute(**child_targets) + + # TestContract's creation code: return the runtime that follows it. + child_init = ( + Op.PUSH1[len(child_runtime)] + + Op.CODECOPY( + dest_offset=0x0, + offset=Op.PUSH1(data_placeholder="child_runtime_offset"), + size=Op.DUP1, + ) + + Op.PUSH1[0x0] + + Op.RETURN + + Op.STOP + ) + child_init.substitute(child_runtime_offset=len(child_init)) + child_code = child_init + child_runtime + + # --- main: selector dispatch, then a stub per function that pushes a + # return address, jumps into the body, and returns the result word. + parent_dispatcher = ( + Op.DIV(Op.CALLDATALOAD(offset=0x0), selector_shift) + + Op.JUMPI( + pc=Op.PUSH2(data_placeholder="suicide_stub"), + condition=Op.EQ(Op.DUP2, suicide_selector), + ) + + Op.JUMPI( + pc=Op.PUSH2(data_placeholder="run_stub"), + condition=Op.EQ(run_selector, Op.DUP1), + ) + + Op.STOP + ) + suicide_stub = ( + Op.JUMPDEST + + Op.PUSH2(data_placeholder="suicide_return") + + Op.JUMP(pc=Op.PUSH2(data_placeholder="suicide_body_from_stub")) + ) + suicide_return = ( + Op.JUMPDEST + + Op.MSTORE(offset=0x0, value=Op.DUP1) + + Op.RETURN(offset=0x0, size=0x20) + ) + run_stub = ( + Op.JUMPDEST + + Op.PUSH2(data_placeholder="run_return") + + Op.JUMP(pc=Op.PUSH2(data_placeholder="run_body")) + ) + run_return = ( + Op.JUMPDEST + Op.MSTORE(offset=0x0, value=Op.DUP1) + Op.RETURN(offset=0x0, size=0x20) - + Op.JUMPDEST + ) + + # testContractSuicide(): CREATE the child, then CALL its + # destroy(block.coinbase). + suicide_body = ( + Op.JUMPDEST + Op.PUSH1[0x0] * 2 - + Op.CODECOPY(dest_offset=0x0, offset=0x18A, size=0x81) - + Op.CREATE(value=0x0, offset=0x0, size=0x81) + + Op.CODECOPY( + dest_offset=0x0, + offset=Op.PUSH2(data_placeholder="child_code_offset"), + size=len(child_code), + ) + + Op.CREATE(value=0x0, offset=0x0, size=len(child_code)) + Op.SWAP1 + Op.POP - + Op.AND(Op.DUP2, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.PUSH3[0xF55D9D] + + Op.AND(Op.DUP2, address_mask) + + Op.PUSH3[destroy_selector] + Op.PUSH1[0x0] + Op.DUP1 - + Op.MSTORE( - offset=Op.DUP3, - value=0xF55D9D00000000000000000000000000000000000000000000000000000000, # noqa: E501 - ) + + Op.MSTORE(offset=Op.DUP3, value=destroy_selector * selector_shift) + Op.PUSH1[0x4] - + Op.MSTORE( - offset=Op.DUP2, - value=Op.AND( - 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, Op.COINBASE - ), - ) + + Op.MSTORE(offset=Op.DUP2, value=Op.AND(address_mask, Op.COINBASE)) + Op.PUSH1[0x20] + Op.ADD + Op.PUSH1[0x0] * 2 + Op.DUP7 + Op.SUB(Op.GAS, 0x32) - + Op.JUMPI(pc=Op.PUSH2[0xE0], condition=Op.CALL) + + Op.JUMPI( + pc=Op.PUSH2(data_placeholder="destroy_call_ok"), + condition=Op.CALL, + ) + Op.STOP - + Op.JUMPDEST + ) + + # The destroyed child must still answer testMethod() in this same + # transaction - the point of the test. + destroy_call_ok = ( + Op.JUMPDEST + Op.POP * 2 - + Op.AND(Op.DUP2, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.PUSH4[0xB9C3D0A5] + + Op.AND(Op.DUP2, address_mask) + + Op.PUSH4[test_method_selector] + Op.PUSH1[0x20] + Op.PUSH1[0x0] + Op.MSTORE( - offset=Op.DUP2, - value=0xB9C3D0A500000000000000000000000000000000000000000000000000000000, # noqa: E501 + offset=Op.DUP2, value=test_method_selector * selector_shift ) + Op.PUSH1[0x4] + Op.PUSH1[0x0] * 2 + Op.DUP7 + Op.SUB(Op.GAS, 0x32) - + Op.JUMPI(pc=0x137, condition=Op.CALL) + + Op.JUMPI( + pc=Op.PUSH2(data_placeholder="test_call_ok"), condition=Op.CALL + ) + Op.STOP - + Op.JUMPDEST + ) + + # Compare the returned word against 225 and yield true / false. + test_call_ok = ( + Op.JUMPDEST + Op.POP * 2 - + Op.JUMPI(pc=0x148, condition=Op.EQ(0xE1, Op.MLOAD(offset=0x0))) - + Op.JUMP(pc=0x151) - + Op.JUMPDEST + + Op.JUMPI( + pc=Op.PUSH2(data_placeholder="result_true"), + condition=Op.EQ(test_method_result, Op.MLOAD(offset=0x0)), + ) + + Op.JUMP(pc=Op.PUSH2(data_placeholder="result_false")) + ) + result_true = ( + Op.JUMPDEST + Op.PUSH1[0x1] + Op.SWAP2 + Op.POP - + Op.JUMP(pc=0x156) - + Op.JUMPDEST - + Op.PUSH1[0x0] - + Op.SWAP2 - + Op.POP - + Op.JUMPDEST - + Op.POP - + Op.SWAP1 - + Op.JUMP - + Op.JUMPDEST + + Op.JUMP(pc=Op.PUSH2(data_placeholder="suicide_join")) + ) + result_false = Op.JUMPDEST + Op.PUSH1[0x0] + Op.SWAP2 + Op.POP + suicide_join = Op.JUMPDEST + Op.POP + Op.SWAP1 + Op.JUMP + + # run(): call testContractSuicide internally, then pack the bool it + # returns into byte 0 of slot 0 (`returnValue`). + run_body = ( + Op.JUMPDEST + Op.PUSH1[0x0] - + Op.PUSH2[0x164] - + Op.JUMP(pc=Op.PUSH2[0x5D]) - + Op.JUMPDEST + + Op.PUSH2(data_placeholder="run_store") + + Op.JUMP(pc=Op.PUSH2(data_placeholder="suicide_body_from_run")) + ) + run_store = ( + Op.JUMPDEST + Op.PUSH1[0x0] + Op.EXP(0x100, 0x0) + Op.AND(Op.NOT(Op.MUL(0xFF, Op.DUP2)), Op.SLOAD(key=Op.DUP2)) @@ -145,52 +301,78 @@ def test_test_contract_suicide( + Op.SWAP1 + Op.JUMP + Op.STOP - + Op.PUSH1[0x75] - + Op.CODECOPY(dest_offset=0x0, offset=0xC, size=Op.DUP1) - + Op.PUSH1[0x0] - + Op.RETURN - + Op.STOP - + Op.DIV( - Op.CALLDATALOAD(offset=0x0), - 0x100000000000000000000000000000000000000000000000000000000, + ) + parent_runtime = ( + parent_dispatcher + + suicide_stub + + suicide_return + + run_stub + + run_return + + suicide_body + + destroy_call_ok + + test_call_ok + + result_true + + result_false + + suicide_join + + run_body + + run_store + ) + + # Resolve each absolute position from the sections that precede it. + # `suicide_body` is reached from two sites, so it carries two + # placeholder names - a name may only appear once per concatenation. + parent_targets = offsets( + ( + ("suicide_stub", parent_dispatcher), + ("suicide_return", suicide_stub), + ("run_stub", suicide_return), + ("run_return", run_stub), + ("suicide_body", run_return), + ("destroy_call_ok", suicide_body), + ("test_call_ok", destroy_call_ok), + ("result_true", test_call_ok), + ("result_false", result_true), + ("suicide_join", result_false), + ("run_body", suicide_join), + ("run_store", run_body), ) - + Op.JUMPI(pc=0x36, condition=Op.EQ(Op.DUP2, 0xF55D9D)) - + Op.JUMPI(pc=0x45, condition=Op.EQ(0xB9C3D0A5, Op.DUP1)) - + Op.STOP - + Op.JUMPDEST - + Op.PUSH1[0x3F] - + Op.CALLDATALOAD(offset=0x4) - + Op.JUMP(pc=0x5A) - + Op.JUMPDEST - + Op.RETURN(offset=0x0, size=0x0) - + Op.JUMPDEST - + Op.PUSH1[0x4B] - + Op.JUMP(pc=0x55) - + Op.JUMPDEST - + Op.MSTORE(offset=0x0, value=Op.DUP1) - + Op.RETURN(offset=0x0, size=0x20) - + Op.JUMPDEST - + Op.PUSH1[0xE1] - + Op.SWAP1 - + Op.JUMP - + Op.JUMPDEST - + Op.SELFDESTRUCT( - address=Op.AND(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, Op.DUP1) + ) + suicide_body_offset = parent_targets.pop("suicide_body") + parent_runtime.substitute( + **parent_targets, + suicide_body_from_stub=suicide_body_offset, + suicide_body_from_run=suicide_body_offset, + child_code_offset=len(parent_runtime), + ) + factory_code = parent_runtime + child_code + + # Placeholders keep the offsets consistent with the section lengths + # but not with their contents, so check each target still lands on a + # JUMPDEST - that catches a section reshuffled at an unchanged size. + child_base = len(parent_runtime) + len(child_init) + code_bytes = bytes(factory_code) + for name, offset in ( + list(parent_targets.items()) + + [("suicide_body", suicide_body_offset)] + + [(f"child.{k}", child_base + v) for k, v in child_targets.items()] + ): + assert code_bytes[offset] == bytes(Op.JUMPDEST)[0], ( + f"{name} (0x{offset:x}) is no longer a JUMPDEST" ) - + Op.POP - + Op.JUMP, + + target = pre.deploy_contract( + code=factory_code, balance=0x186A0, - nonce=0, ) tx = Transaction( sender=sender, to=target, - data=Bytes("c0406226"), - gas_limit=350000, + data=run_selector.to_bytes(length=4), value=1, + protected=fork.supports_protected_txs(), ) - post = {target: Account(storage={0: 1}, nonce=1)} + post = {target: Account(storage={0: 1}, nonce=2)} - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stSystemOperationsTest/test_double_selfdestruct_touch_paris.py b/tests/ported_static/stSystemOperationsTest/test_double_selfdestruct_touch_paris.py index b20492dea03..3f07fcd0a94 100644 --- a/tests/ported_static/stSystemOperationsTest/test_double_selfdestruct_touch_paris.py +++ b/tests/ported_static/stSystemOperationsTest/test_double_selfdestruct_touch_paris.py @@ -1,31 +1,30 @@ """ -A single contract can execute SELFDESTRUCT multiple times using by... - -multiple times. The second and later SELFDESTRUCTs have little effect but can -touch some new beneficiary addresses. +Verify a contract executing SELFDESTRUCT twice in one transaction: the +second has little effect but touches a new beneficiary address. Ported from: state_tests/stSystemOperationsTest/doubleSelfdestructTouch_ParisFiller.yml + +@manually-enhanced: Do not overwrite. The post is a closed form of the +transaction value rather than three pinned result sets, and the caller +records both call results, which is what proves the second SELFDESTRUCT +ran. Extended with a `created_in_tx` arm covering EIP-6780's other +branch, where the account really is deleted. """ import pytest from execution_testing import ( - EOA, Account, Address, Alloc, Bytes, - Environment, + Initcode, StateTestFiller, Transaction, + compute_create_address, ) -from execution_testing.forks import Fork from execution_testing.vm import Op -from tests.ported_static.post_state_resolution import ( - resolve_expect_post, -) - REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" @@ -36,156 +35,122 @@ ], ) @pytest.mark.valid_from("Cancun") +@pytest.mark.parametrize("tx_value", [0, 1, 2]) @pytest.mark.parametrize( - "d, g, v", + "created_in_tx", [ - pytest.param( - 0, - 0, - 0, - id="-v0", - ), - pytest.param( - 0, - 0, - 1, - id="-v1", - ), - pytest.param( - 0, - 0, - 2, - id="-v2", - ), + pytest.param(False, id="pre_existing"), + pytest.param(True, id="created_in_tx"), ], ) -@pytest.mark.pre_alloc_mutable def test_double_selfdestruct_touch_paris( state_test: StateTestFiller, pre: Alloc, - fork: Fork, - d: int, - g: int, - v: int, + tx_value: int, + created_in_tx: bool, ) -> None: - """A single contract can execute SELFDESTRUCT multiple times using by...""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - empty_account_1 = Address(0x68FA59E127B7526718EB0A4E113DF5793628CB91) - empty_account_2 = Address(0x76FAE819612A29489A1A43208613D8F8557B8898) - sender = EOA( - key=0xE92C121432830128CA66D3D8C4E6D8D96CC4BEFA7C612D28415082EB3C8339C5 - ) + """Two SELFDESTRUCTs in one transaction touch two beneficiaries.""" + sender = pre.fund_eoa() - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=999, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=30000000, - ) + # Both beneficiaries start non-empty, so a zero-value SELFDESTRUCT + # leaves one exactly as it was. + beneficiary_balance = 10 + beneficiary_1 = pre.fund_eoa(amount=beneficiary_balance) + beneficiary_2 = pre.fund_eoa(amount=beneficiary_balance) - pre[sender] = Account(balance=0x5F5E102) - pre[empty_account_1] = Account(balance=10) - pre[empty_account_2] = Account(balance=10) # Source: yul - # berlin # { # let index := add(sload(0), 1) # sstore(0, index) # selfdestruct(sload(index)) # } - addr = pre.deploy_contract( # noqa: F841 - code=Op.ADD(Op.SLOAD(key=0x0), 0x1) + # Each call bumps the index, so the first SELFDESTRUCT picks the + # beneficiary in slot 1 and the second the one in slot 2. + selfdestructor_code = ( + Op.ADD(Op.SLOAD(key=0x0), 0x1) + Op.SSTORE(key=0x0, value=Op.DUP1) - + Op.SELFDESTRUCT(address=Op.SLOAD), - storage={0: 0, 1: empty_account_1, 2: empty_account_2}, - nonce=0, - address=Address(0x29E4504A3D2A0E0AE0EBBBEFEDD4570639B3EBEE), # noqa: E501 + + Op.SELFDESTRUCT(address=Op.SLOAD) ) - # Source: yul - # berlin - # { - # let v0 := callvalue() - # let v1 := shr(1, v0) - # let r1 := call(70000, , v1, 0, 0, 0, 0) # noqa: E501 - # let v2 := sub(v0, v1) - # let r2 := call(70000, , v2, 0, 0, 0, 0) # noqa: E501 - # } - target = pre.deploy_contract( # noqa: F841 - code=Op.PUSH1[0x0] - + Op.DUP1 * 3 - + Op.CALLVALUE - + Op.SHR(0x1, Op.DUP1) - + Op.SWAP1 - + Op.POP( - Op.CALL( - gas=0x11170, - address=0x29E4504A3D2A0E0AE0EBBBEFEDD4570639B3EBEE, - value=Op.DUP6, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=Op.DUP3, - ) + + address_slot = 0x100 # memory word holding the callee's address + first_result_slot = 1 + second_result_slot = 2 + callee_slot = 3 + + tx_data: Bytes | Initcode + if created_in_tx: + # Deploy it from the transaction's calldata, so EIP-6780 sees a + # contract created and destroyed within one transaction. + tx_data = Initcode( + deploy_code=selfdestructor_code, + initcode_prefix=Op.SSTORE(key=0x1, value=beneficiary_1) + + Op.SSTORE(key=0x2, value=beneficiary_2), + ) + setup = Op.CALLDATACOPY( + dest_offset=0x0, offset=0x0, size=Op.CALLDATASIZE + ) + Op.MSTORE( + address_slot, + Op.CREATE(value=0x0, offset=0x0, size=Op.CALLDATASIZE), + ) + else: + selfdestructor = pre.deploy_contract( + code=selfdestructor_code, + storage={0: 0, 1: beneficiary_1, 2: beneficiary_2}, + ) + tx_data = Bytes(b"") + setup = Op.MSTORE(address_slot, selfdestructor) + + # Split the received value in half and send each half in its own + # call, recording both results so neither call can go missing. + first_value = Op.SHR(0x1, Op.CALLVALUE) + second_value = Op.SUB(Op.CALLVALUE, Op.SHR(0x1, Op.CALLVALUE)) + caller = pre.deploy_contract( + code=setup + + Op.SSTORE(key=callee_slot, value=Op.MLOAD(address_slot)) + + Op.SSTORE( + key=first_result_slot, + value=Op.CALL(address=Op.MLOAD(address_slot), value=first_value), + ) + + Op.SSTORE( + key=second_result_slot, + value=Op.CALL(address=Op.MLOAD(address_slot), value=second_value), ) - + Op.SUB - + Op.PUSH20[0x29E4504A3D2A0E0AE0EBBBEFEDD4570639B3EBEE] - + Op.PUSH3[0x11170] - + Op.CALL + Op.STOP, - nonce=0, - address=Address(0x8EC7465877D3957084DC907C0F6D8F2911A17A52), # noqa: E501 ) - expect_entries_: list[dict] = [ - { - "indexes": {"data": -1, "gas": -1, "value": 0}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - target: Account(storage={}, balance=0, nonce=0), - empty_account_1: Account(balance=10), - empty_account_2: Account(balance=10), - }, - }, - { - "indexes": {"data": -1, "gas": -1, "value": 1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - target: Account(storage={}, balance=0, nonce=0), - empty_account_1: Account(balance=10), - empty_account_2: Account(balance=11, nonce=0), - }, - }, - { - "indexes": {"data": -1, "gas": -1, "value": 2}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - target: Account(storage={}, balance=0, nonce=0), - empty_account_1: Account(balance=11, nonce=0), - empty_account_2: Account(balance=11, nonce=0), - }, - }, - ] - - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) + tx = Transaction(sender=sender, to=caller, value=tx_value, data=tx_data) - tx_data = [ - Bytes(""), - ] - tx_gas = [10000000] - tx_value = [0, 1, 2] + # Each SELFDESTRUCT forwards its frame's whole balance onward, so + # every beneficiary gains exactly the half routed to it. + first_transfer = tx_value >> 1 + second_transfer = tx_value - first_transfer + post: dict[Address, Account | None] = { + sender: Account(nonce=1), + beneficiary_1: Account(balance=beneficiary_balance + first_transfer), + beneficiary_2: Account(balance=beneficiary_balance + second_transfer), + } + if created_in_tx: + # EIP-6780 deletes an account created in this same transaction. + callee = compute_create_address(address=caller, nonce=1) + post[callee] = Account.NONEXISTENT + else: + # EIP-6780 spares a pre-existing account, so the index reaching + # 2 survives to show both SELFDESTRUCTs executed. + callee = selfdestructor + post[selfdestructor] = Account( + storage={0: 2, 1: beneficiary_1, 2: beneficiary_2}, + balance=0, + ) - tx = Transaction( - sender=sender, - to=target, - data=tx_data[d], - gas_limit=tx_gas[g], - value=tx_value[v], - error=_exc, + # Recording the callee address proves the CREATE arm really got a + # contract, rather than a zero address that calls succeed against. + post[caller] = Account( + storage={ + first_result_slot: 1, + second_result_slot: 1, + callee_slot: callee, + }, + balance=0, ) - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stTransactionTest/test_opcodes_transaction_init.py b/tests/ported_static/stTransactionTest/test_opcodes_transaction_init.py index e14591f4675..b427924dca7 100644 --- a/tests/ported_static/stTransactionTest/test_opcodes_transaction_init.py +++ b/tests/ported_static/stTransactionTest/test_opcodes_transaction_init.py @@ -1,1518 +1,584 @@ """ -Test_opcodes_transaction_init. +Verify each opcode family executes inside a creation transaction's init +code, and that its result reaches the deployed contract. Ported from: state_tests/stTransactionTest/Opcodes_TransactionInitFiller.json + +@manually-enhanced: Do not overwrite. The ported test only checked that +each init code ran to completion, so an opcode returning the wrong +result was invisible. Every arm that produces a value now MSTOREs it and +RETURNs it as the deployed code, making the outcome observable. Cases +are keyed by opcode and parametrized from `fork.valid_opcodes()`, so a +newly-enabled opcode fails here until a case is added. The filler's +`returner` target returned four zero bytes, indistinguishable from an +empty return; it now returns a marker word so the RETURNDATA* arms can +be checked. Sub-calls are sized from the callee's own +`gas_cost(fork)` rather than forwarding everything, which is what lets +the test reach back to Frontier, and the created account's nonce is +derived from EIP-161 rather than pinned at one. """ +from dataclasses import dataclass, field +from typing import Callable, Generator + import pytest +from _pytest.mark.structures import ParameterSet from execution_testing import ( - EOA, Account, Address, Alloc, - Bytes, Environment, StateTestFiller, Transaction, + compute_create2_address, compute_create_address, + keccak256, ) from execution_testing.forks import Fork -from execution_testing.vm import Op - -from tests.ported_static.post_state_resolution import ( - resolve_expect_post, -) +from execution_testing.vm import Bytecode, Op, Opcodes REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +TX_VALUE = 100_000 +WORD = 32 +ALL_ONES = 2**256 - 1 +RETURN_MARKER = 0xBEEF +# Markers planted at the exact stack depth each EIP-8024 opcode +# reaches, so surfacing one proves the depth was right. +DUPN_MARKER = 0xA1 +SWAPN_MARKER = 0xB2 +EXCHANGE_MARKER = 0xC3 -@pytest.mark.ported_from( - ["state_tests/stTransactionTest/Opcodes_TransactionInitFiller.json"], +# Block context, pinned so the opcodes that read it can be asserted +# rather than merely executed. The block gas limit keeps its default: +# a transaction with no explicit limit is granted exactly that much, so +# lowering it here would make every arm exceed the block. +COINBASE = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) +BLOCK_NUMBER = 1 +BLOCK_TIMESTAMP = 1_000 +PREV_RANDAO = 0x20000 +BASE_FEE_PER_GAS = 10 +EXCESS_BLOB_GAS = 0 +SLOT_NUMBER = 7 + +# Markers written then read back, so a store or copy that silently did +# nothing is distinguishable from one that worked. +STORE_MARKER = 0xD1CE +MSTORE8_BYTE = 0xAB +RETURNER_MARKER = 0xF00D +SHA3_INPUT = 0x5EED +STORER_BALANCE = 0x1234 +CALL_SUCCEEDED = 1 +# Two distinct words, so the one left after a POP identifies how many +# items it removed. +POP_TOP = 0xE1 +POP_UNDER = 0xE2 +JUMP_MARKER = 0x7A + +STORER_CODE = Op.SSTORE(key=0x0, value=0x1) + Op.STOP +"""Pre-deployed target whose code EXTCODE* arms read.""" +# The ported filler's returner returned four zero bytes, which no +# assertion can distinguish from an empty return; it now returns a +# marker word so RETURNDATASIZE and RETURNDATACOPY are checkable. +RETURNER_CODE = Op.MSTORE( + offset=0x0, value=RETURNER_MARKER, new_memory_size=WORD +) + Op.RETURN(offset=0x0, size=WORD) +"""Pre-deployed target that returns a known word.""" + + +def _code_word(code: Bytecode) -> int: + """Return a code's first word, as a COPY into memory would read it.""" + return int.from_bytes(bytes(code)[:WORD].ljust(WORD, b"\x00"), "big") + + +def _base_nonce(fork: Fork) -> int: + """Return a newly created contract's starting nonce (EIP-161).""" + return int(fork.is_eip_enabled(161)) + + +def _hash_word(data: bytes) -> int: + """Return keccak256 of `data` as a word.""" + return int.from_bytes(bytes(keccak256(data)), "big") + + +def _jump_over_revert(conditional: bool) -> Bytecode: + """ + Jump past a REVERT to reach a marker push. + + The target is derived from the code it skips, and a jump landing + anywhere else either reverts or faults on a non-JUMPDEST, so the + marker surviving into the deployed code is the proof. Valid only + with an empty `prefix`, which puts this body at offset 0. + """ + revert = Op.REVERT(offset=0x0, size=0x0) + jump = ( + Op.JUMPI(pc=Op.PUSH1(data_placeholder="target"), condition=1) + if conditional + else Op.JUMP(pc=Op.PUSH1(data_placeholder="target")) + ) + code = jump + revert + Op.JUMPDEST + Op.PUSH1[JUMP_MARKER] + code.substitute(target=len(jump) + len(revert)) + return code + + +ENV = Environment( + fee_recipient=COINBASE, + number=BLOCK_NUMBER, + timestamp=BLOCK_TIMESTAMP, + prev_randao=PREV_RANDAO, + base_fee_per_gas=BASE_FEE_PER_GAS, + excess_blob_gas=EXCESS_BLOB_GAS, + slot_number=SLOT_NUMBER, ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.parametrize( - "d, g, v", - [ - pytest.param( - 0, - 0, - 0, - id="d0", - ), - pytest.param( - 1, - 0, - 0, - id="d1", - ), - pytest.param( - 2, - 0, - 0, - id="d2", - ), - pytest.param( - 3, - 0, - 0, - id="d3", - ), - pytest.param( - 4, - 0, - 0, - id="d4", - ), - pytest.param( - 5, - 0, - 0, - id="d5", - ), - pytest.param( - 6, - 0, - 0, - id="d6", - ), - pytest.param( - 7, - 0, - 0, - id="d7", - ), - pytest.param( - 8, - 0, - 0, - id="d8", - ), - pytest.param( - 9, - 0, - 0, - id="d9", - ), - pytest.param( - 10, - 0, - 0, - id="d10", - ), - pytest.param( - 11, - 0, - 0, - id="d11", - ), - pytest.param( - 12, - 0, - 0, - id="d12", - ), - pytest.param( - 13, - 0, - 0, - id="d13", - ), - pytest.param( - 14, - 0, - 0, - id="d14", - ), - pytest.param( - 15, - 0, - 0, - id="d15", - ), - pytest.param( - 16, - 0, - 0, - id="d16", - ), - pytest.param( - 17, - 0, - 0, - id="d17", - ), - pytest.param( - 18, - 0, - 0, - id="d18", - ), - pytest.param( - 19, - 0, - 0, - id="d19", - ), - pytest.param( - 20, - 0, - 0, - id="d20", - ), - pytest.param( - 21, - 0, - 0, - id="d21", - ), - pytest.param( - 22, - 0, - 0, - id="d22", - ), - pytest.param( - 23, - 0, - 0, - id="d23", - ), - pytest.param( - 24, - 0, - 0, - id="d24", - ), - pytest.param( - 25, - 0, - 0, - id="d25", - ), - pytest.param( - 26, - 0, - 0, - id="d26", - ), - pytest.param( - 27, - 0, - 0, - id="d27", - ), - pytest.param( - 28, - 0, - 0, - id="d28", - ), - pytest.param( - 29, - 0, - 0, - id="d29", - ), - pytest.param( - 30, - 0, - 0, - id="d30", - ), - pytest.param( - 31, - 0, - 0, - id="d31", - ), - pytest.param( - 32, - 0, - 0, - id="d32", - ), - pytest.param( - 33, - 0, - 0, - id="d33", - ), - pytest.param( - 34, - 0, - 0, - id="d34", - ), - pytest.param( - 35, - 0, - 0, - id="d35", - ), - pytest.param( - 36, - 0, - 0, - id="d36", - ), - pytest.param( - 37, - 0, - 0, - id="d37", - ), - pytest.param( - 38, - 0, - 0, - id="d38", - ), - pytest.param( - 39, - 0, - 0, - id="d39", - ), - pytest.param( - 40, - 0, - 0, - id="d40", - ), - pytest.param( - 41, - 0, - 0, - id="d41", - ), - pytest.param( - 42, - 0, - 0, - id="d42", - ), - pytest.param( - 43, - 0, - 0, - id="d43", - ), - pytest.param( - 44, - 0, - 0, - id="d44", - ), - pytest.param( - 45, - 0, - 0, - id="d45", - ), - pytest.param( - 46, - 0, - 0, - id="d46", - ), - pytest.param( - 47, - 0, - 0, - id="d47", - ), - pytest.param( - 48, - 0, - 0, - id="d48", - ), - pytest.param( - 49, - 0, - 0, - id="d49", - ), - pytest.param( - 50, - 0, - 0, - id="d50", - ), - pytest.param( - 51, - 0, - 0, - id="d51", - ), - pytest.param( - 52, - 0, - 0, - id="d52", - ), - pytest.param( - 53, - 0, - 0, - id="d53", - ), - pytest.param( - 54, - 0, - 0, - id="d54", - ), - pytest.param( - 55, - 0, - 0, - id="d55", - ), - pytest.param( - 56, - 0, - 0, - id="d56", - ), - pytest.param( - 57, - 0, - 0, - id="d57", - ), - pytest.param( - 58, - 0, - 0, - id="d58", - ), - pytest.param( - 59, - 0, - 0, - id="d59", - ), - pytest.param( - 60, - 0, - 0, - id="d60", - ), - pytest.param( - 61, - 0, - 0, - id="d61", - ), - pytest.param( - 62, - 0, - 0, - id="d62", - ), - pytest.param( - 63, - 0, - 0, - id="d63", - ), - pytest.param( - 64, - 0, - 0, - id="d64", - ), - pytest.param( - 65, - 0, - 0, - id="d65", - ), - pytest.param( - 66, - 0, - 0, - id="d66", - ), - pytest.param( - 67, - 0, - 0, - id="d67", - ), - pytest.param( - 68, - 0, - 0, - id="d68", - ), - pytest.param( - 69, - 0, - 0, - id="d69", - ), - pytest.param( - 70, - 0, - 0, - id="d70", - ), - pytest.param( - 71, - 0, - 0, - id="d71", - ), - pytest.param( - 72, - 0, - 0, - id="d72", - ), - pytest.param( - 73, - 0, - 0, - id="d73", - ), - pytest.param( - 74, - 0, - 0, - id="d74", - ), - pytest.param( - 75, - 0, - 0, - id="d75", - ), - pytest.param( - 76, - 0, - 0, - id="d76", - ), - pytest.param( - 77, - 0, - 0, - id="d77", - ), - pytest.param( - 78, - 0, - 0, - id="d78", - ), - pytest.param( - 79, - 0, - 0, - id="d79", - ), - pytest.param( - 80, - 0, - 0, - id="d80", - ), - pytest.param( - 81, - 0, - 0, - id="d81", - ), - pytest.param( - 82, - 0, - 0, - id="d82", - ), - pytest.param( - 83, - 0, - 0, - id="d83", - ), - pytest.param( - 84, - 0, - 0, - id="d84", - ), - pytest.param( - 85, - 0, - 0, - id="d85", - ), - pytest.param( - 86, - 0, - 0, - id="d86", - ), - pytest.param( - 87, - 0, - 0, - id="d87", - ), - pytest.param( - 88, - 0, - 0, - id="d88", - ), - pytest.param( - 89, - 0, - 0, - id="d89", - ), - pytest.param( - 90, - 0, - 0, - id="d90", - ), - pytest.param( - 91, - 0, - 0, - id="d91", - ), - pytest.param( - 92, - 0, - 0, - id="d92", - ), - pytest.param( - 93, - 0, - 0, - id="d93", - ), - pytest.param( - 94, - 0, - 0, - id="d94", - ), - pytest.param( - 95, - 0, - 0, - id="d95", - ), - pytest.param( - 96, - 0, - 0, - id="d96", - ), - pytest.param( - 97, - 0, - 0, - id="d97", - ), - pytest.param( - 98, - 0, - 0, - id="d98", - ), - pytest.param( - 99, - 0, - 0, - id="d99", - ), - pytest.param( - 100, - 0, - 0, - id="d100", - ), - pytest.param( - 101, - 0, - 0, - id="d101", - ), - pytest.param( - 102, - 0, - 0, - id="d102", - ), - pytest.param( - 103, - 0, - 0, - id="d103", - ), - pytest.param( - 104, - 0, - 0, - id="d104", - ), - pytest.param( - 105, - 0, - 0, - id="d105", - ), - pytest.param( - 106, - 0, - 0, - id="d106", - ), - pytest.param( - 107, - 0, - 0, - id="d107", - ), - pytest.param( - 108, - 0, - 0, - id="d108", - ), - pytest.param( - 109, - 0, - 0, - id="d109", - ), - pytest.param( - 110, - 0, - 0, - id="d110", - ), - pytest.param( - 111, - 0, - 0, - id="d111", - ), - pytest.param( - 112, - 0, - 0, - id="d112", - ), - pytest.param( - 113, - 0, - 0, - id="d113", - ), - pytest.param( - 114, - 0, - 0, - id="d114", - ), - pytest.param( - 115, - 0, - 0, - id="d115", - ), - pytest.param( - 116, - 0, - 0, - id="d116", - ), - pytest.param( - 117, - 0, - 0, - id="d117", - ), - pytest.param( - 118, - 0, - 0, - id="d118", - ), - pytest.param( - 119, - 0, - 0, - id="d119", - ), - pytest.param( - 120, - 0, - 0, - id="d120", - ), - pytest.param( - 121, - 0, - 0, - id="d121", - ), - pytest.param( - 122, - 0, - 0, - id="d122", - ), - pytest.param( - 123, - 0, - 0, - id="d123", - ), - pytest.param( - 124, - 0, - 0, - id="d124", - ), - pytest.param( - 125, - 0, - 0, - id="d125", - ), - pytest.param( - 126, - 0, - 0, - id="d126", - ), - pytest.param( - 127, - 0, - 0, - id="d127", - ), - pytest.param( - 128, - 0, - 0, - id="invalid_first_byte_ef", - ), - pytest.param( - 129, - 0, - 0, - id="side_effects", - ), - pytest.param( - 130, - 0, - 0, - id="side_effects_invalid_opcode", - ), - pytest.param( - 131, - 0, - 0, - id="side_effects_return_ef", + + +@dataclass(frozen=True) +class Targets: + """What an init-code body may need beyond the opcode itself.""" + + storer: Address + """Contract whose code performs `sstore(0, 1)`.""" + returner: Address + """Contract that returns a known word.""" + fork: Fork + """Lets a body size a sub-call from its callee's own cost.""" + + +@dataclass(frozen=True) +class Context: + """Runtime facts an expected value may depend on.""" + + created: Address + sender: Address + init_code: Bytecode + fork: Fork + + +@dataclass(frozen=True) +class Case: + """ + One opcode exercised inside a creation transaction's init code. + + `expected` is the word the deployed contract must hold. Unless + `terminates` is set, the scaffold supplies the MSTORE/RETURN that + puts it there, so `body` need only leave it on the stack. A callable + body receives the pre-deployed `Targets`. + """ + + body: Bytecode | Callable[[Targets], Bytecode] + expected: int | Callable[[Context], int] | None = None + prefix: Bytecode = field(default_factory=Bytecode) + terminates: bool = False + """`body` ends the frame itself; the scaffold adds no RETURN.""" + discarded: bool = False + """The created account must not exist once the frame ends.""" + creations: int = 0 + """Contracts the init code creates, which raise its own nonce.""" + extra: Callable[[Context], dict] | None = None + """Further post-state entries, given the runtime context.""" + + +def _stack(depth: int) -> tuple[Bytecode, int]: + """ + Push `depth` distinct non-zero words, deepest first. + + Return the pushed code and the deepest value, which is what a + correct `DUP` or `SWAP` must surface. + """ + values = [0xA0 + i for i in range(depth)] + code = Bytecode() + for value in values: + code += Op.PUSH1[value] + return code, values[0] + + +def _dup_op(n: int) -> Opcodes: + """Return the `DUP` opcode.""" + return getattr(Op, f"DUP{n}") + + +def _swap_op(n: int) -> Opcodes: + """Return the `SWAP` opcode.""" + return getattr(Op, f"SWAP{n}") + + +def _push_op(n: int) -> Opcodes: + """Return the `PUSH` opcode.""" + return getattr(Op, f"PUSH{n}") + + +def _dup_case(n: int) -> Case: + """DUP must reach exactly `n` items down the stack.""" + prep, deepest = _stack(n) + return Case(_dup_op(n), deepest, prefix=prep) + + +def _swap_case(n: int) -> Case: + """SWAP must exchange the top with the item `n` below it.""" + prep, deepest = _stack(n + 1) + return Case(_swap_op(n), deepest, prefix=prep) + + +def _push_case(n: int) -> Case: + """PUSH must place its whole immediate on the stack.""" + value = int.from_bytes(bytes(range(1, n + 1)), "big") + return Case(_push_op(n)[value], value) + + +def _address_word(address: Address) -> int: + """Return an address as the word an opcode would push.""" + return int.from_bytes(bytes(address), "big") + + +# Every opcode valid on a fork must appear here. `valid_opcodes()` +# drives the parametrization, so a newly-enabled opcode fails this test +# until a case is added; there is no opt-out. +CASES: dict[Opcodes, Case] = { + # --- Arithmetic. Operands chosen so the answer is self-evident. + Op.ADD: Case(Op.ADD(2, 3), 5), + Op.MUL: Case(Op.MUL(3, 4), 12), + Op.SUB: Case(Op.SUB(5, 3), 2), + Op.DIV: Case(Op.DIV(12, 4), 3), + Op.SDIV: Case(Op.SDIV(12, 4), 3), + Op.MOD: Case(Op.MOD(7, 3), 1), + Op.SMOD: Case(Op.SMOD(7, 3), 1), + Op.ADDMOD: Case(Op.ADDMOD(5, 3, 4), 0), + Op.MULMOD: Case(Op.MULMOD(5, 3, 4), 3), + Op.EXP: Case(Op.EXP(2, 10), 1024), + Op.SIGNEXTEND: Case(Op.SIGNEXTEND(0, 0xFF), ALL_ONES), + # --- Comparison and bitwise. + Op.LT: Case(Op.LT(1, 2), 1), + Op.GT: Case(Op.GT(2, 1), 1), + Op.SLT: Case(Op.SLT(1, 2), 1), + Op.SGT: Case(Op.SGT(2, 1), 1), + Op.EQ: Case(Op.EQ(3, 3), 1), + Op.ISZERO: Case(Op.ISZERO(0), 1), + Op.AND: Case(Op.AND(0xF0, 0x3C), 0x30), + Op.OR: Case(Op.OR(0xF0, 0x3C), 0xFC), + Op.XOR: Case(Op.XOR(0xF0, 0x3C), 0xCC), + Op.NOT: Case(Op.NOT(0), ALL_ONES), + Op.BYTE: Case(Op.BYTE(31, 0xAB), 0xAB), + Op.SHL: Case(Op.SHL(1, 1), 2), + Op.SHR: Case(Op.SHR(1, 2), 1), + Op.SAR: Case(Op.SAR(1, 2), 1), + Op.CLZ: Case(Op.CLZ(1), 255), + # --- Frame identity. A creation frame has no calldata, and the + # account already holds the transaction's value while init runs. + Op.ADDRESS: Case(Op.ADDRESS, lambda c: _address_word(c.created)), + Op.ORIGIN: Case(Op.ORIGIN, lambda c: _address_word(c.sender)), + Op.CALLER: Case(Op.CALLER, lambda c: _address_word(c.sender)), + Op.CALLVALUE: Case(Op.CALLVALUE, TX_VALUE), + Op.CALLDATASIZE: Case(Op.CALLDATASIZE, 0), + Op.CODESIZE: Case(Op.CODESIZE, lambda c: len(c.init_code)), + Op.SELFBALANCE: Case(Op.SELFBALANCE, TX_VALUE), + # The current block is not yet on the chain, so it hashes to zero. + # Asking for an ancestor instead reaches into `block_hashes`, which + # a single-block state test does not populate. + Op.BLOCKHASH: Case(Op.BLOCKHASH(block_number=Op.NUMBER), 0), + # A legacy transaction carries no blobs. + Op.BLOBHASH: Case(Op.BLOBHASH(index=0), 0), + # --- Round-trips, so a read that wrongly yields zero is + # distinguishable from a correct one. + Op.SLOAD: Case(Op.SLOAD(0x0), 42, prefix=Op.SSTORE(key=0x0, value=42)), + Op.MLOAD: Case(Op.MLOAD(0x40), 7, prefix=Op.MSTORE(offset=0x40, value=7)), + Op.TLOAD: Case(Op.TLOAD(0x0), 99, prefix=Op.TSTORE(key=0x0, value=99)), + Op.MCOPY: Case( + Op.MLOAD(0x80), + 7, + prefix=Op.MSTORE(offset=0x40, value=7) + + Op.MCOPY(dest_offset=0x80, offset=0x40, size=WORD), + ), + Op.MSIZE: Case(Op.MSIZE, 0x60, prefix=Op.MSTORE(offset=0x40, value=0)), + Op.PUSH0: Case(Op.PUSH0, 0), + # --- Reading other accounts, against their known code and balance. + Op.EXTCODESIZE: Case( + lambda t: Op.EXTCODESIZE(address=t.storer), len(STORER_CODE) + ), + Op.EXTCODEHASH: Case( + lambda t: Op.EXTCODEHASH(address=t.storer), + _hash_word(bytes(STORER_CODE)), + ), + Op.BALANCE: Case(lambda t: Op.BALANCE(address=t.storer), STORER_BALANCE), + # --- A call reports success as its stack result. + Op.CALL: Case( + lambda t: Op.CALL( + address=t.returner, gas=RETURNER_CODE.gas_cost(t.fork) + ), + CALL_SUCCEEDED, + ), + Op.CALLCODE: Case( + lambda t: Op.CALLCODE( + address=t.returner, gas=RETURNER_CODE.gas_cost(t.fork) + ), + CALL_SUCCEEDED, + ), + Op.DELEGATECALL: Case( + lambda t: Op.DELEGATECALL( + address=t.returner, gas=RETURNER_CODE.gas_cost(t.fork) + ), + CALL_SUCCEEDED, + ), + Op.STATICCALL: Case( + lambda t: Op.STATICCALL( + address=t.returner, gas=RETURNER_CODE.gas_cost(t.fork) + ), + CALL_SUCCEEDED, + ), + # --- Hashing a word we planted. + Op.SHA3: Case( + Op.SHA3(offset=0x1C0, size=WORD), + _hash_word(SHA3_INPUT.to_bytes(WORD, "big")), + prefix=Op.MSTORE(offset=0x1C0, value=SHA3_INPUT), + ), + Op.CHAINID: Case(Op.CHAINID, 1), + # --- Jumps must clear the REVERT they skip over. + Op.JUMP: Case(_jump_over_revert(conditional=False), JUMP_MARKER), + Op.JUMPI: Case(_jump_over_revert(conditional=True), JUMP_MARKER), + # A creation frame has no calldata: the transaction's `data` is this + # init code, and it is code here, not input. An implementation that + # also exposed it as calldata would read a non-zero word. + Op.CALLDATALOAD: Case(Op.CALLDATALOAD(offset=0x0), 0), + # POP must remove exactly the top item, leaving the one beneath. + Op.POP: Case( + Op.POP, + POP_UNDER, + prefix=Op.PUSH1[POP_UNDER] + Op.PUSH1[POP_TOP], + ), + # --- Stores and copies, each read back out of the location it + # wrote, so an operation that did nothing fails. + Op.MSTORE: Case( + Op.MLOAD(0x100), + STORE_MARKER, + prefix=Op.MSTORE(offset=0x100, value=STORE_MARKER), + ), + # MSTORE8 writes one byte, which lands in the word's high end. + Op.MSTORE8: Case( + Op.MLOAD(0x120), + MSTORE8_BYTE << 248, + prefix=Op.MSTORE8(offset=0x120, value=MSTORE8_BYTE), + ), + Op.SSTORE: Case( + Op.SLOAD(0x2), + STORE_MARKER, + prefix=Op.SSTORE(key=0x2, value=STORE_MARKER), + ), + Op.TSTORE: Case( + Op.TLOAD(0x2), + STORE_MARKER, + prefix=Op.TSTORE(key=0x2, value=STORE_MARKER), + ), + # A creation frame has no calldata, so the copy must clear the + # marker already sitting at the destination. + Op.CALLDATACOPY: Case( + Op.MLOAD(0x140), + 0, + prefix=Op.MSTORE(offset=0x140, value=STORE_MARKER) + + Op.CALLDATACOPY(dest_offset=0x140, offset=0x0, size=WORD), + ), + Op.CODECOPY: Case( + Op.MLOAD(0x160), + lambda c: _code_word(c.init_code), + prefix=Op.CODECOPY(dest_offset=0x160, offset=0x0, size=WORD), + ), + Op.EXTCODECOPY: Case( + lambda t: Op.EXTCODECOPY( + address=t.storer, dest_offset=0x180, offset=0x0, size=WORD + ) + + Op.MLOAD(0x180), + _code_word(STORER_CODE), + ), + Op.RETURNDATASIZE: Case( + lambda t: Op.POP(Op.CALL(address=t.returner)) + Op.RETURNDATASIZE, + WORD, + ), + Op.RETURNDATACOPY: Case( + lambda t: Op.POP(Op.CALL(address=t.returner)) + + Op.RETURNDATACOPY(dest_offset=0x1A0, offset=0x0, size=WORD) + + Op.MLOAD(0x1A0), + RETURNER_MARKER, + ), + # --- Block context, each read back against the pinned environment. + Op.COINBASE: Case(Op.COINBASE, _address_word(COINBASE)), + Op.NUMBER: Case(Op.NUMBER, BLOCK_NUMBER), + Op.TIMESTAMP: Case(Op.TIMESTAMP, BLOCK_TIMESTAMP), + Op.PREVRANDAO: Case(Op.PREVRANDAO, PREV_RANDAO), + Op.BASEFEE: Case(Op.BASEFEE, BASE_FEE_PER_GAS), + Op.GASLIMIT: Case(Op.GASLIMIT, int(ENV.gas_limit)), + Op.SLOTNUM: Case(Op.SLOTNUM, SLOT_NUMBER), + Op.BLOBBASEFEE: Case( + Op.BLOBBASEFEE, + lambda c: c.fork.blob_gas_price_calculator()( + excess_blob_gas=EXCESS_BLOB_GAS + ), + ), + # --- EIP-8024 immediate-operand stack ops. DUPN[n] copies the + # n-th item up, SWAPN[n] swaps the top with the one n below it, and + # EXCHANGE[a, b] swaps two items beneath the top. + Op.DUPN: Case( + Op.DUPN[17], + DUPN_MARKER, + prefix=Op.PUSH1[DUPN_MARKER] + Op.PUSH0 * 16, + ), + Op.SWAPN: Case( + Op.SWAPN[17], + SWAPN_MARKER, + prefix=Op.PUSH1[SWAPN_MARKER] + Op.PUSH0 * 17, + ), + Op.EXCHANGE: Case( + Op.EXCHANGE[1, 2] + Op.POP, + EXCHANGE_MARKER, + prefix=Op.PUSH1[EXCHANGE_MARKER] + Op.PUSH0 * 2, + ), + # --- Frames that end themselves. + Op.RETURN: Case( + Op.MSTORE(offset=0x0, value=RETURN_MARKER) + + Op.RETURN(offset=0x0, size=WORD), + RETURN_MARKER, + terminates=True, + ), + Op.REVERT: Case( + Op.REVERT(offset=0x0, size=0x0), terminates=True, discarded=True + ), + Op.SELFDESTRUCT: Case( + Op.SELFDESTRUCT(address=Op.ORIGIN), + terminates=True, + discarded=True, + ), + # --- Creating from within init code bumps this account's own nonce + # and leaves the nested account behind. + Op.CREATE: Case( + Op.CREATE(value=0x0, offset=0x0, size=0x0), + lambda c: _address_word( + compute_create_address( + address=c.created, nonce=_base_nonce(c.fork) + ) ), - ], + creations=1, + extra=lambda c: { + compute_create_address( + address=c.created, nonce=_base_nonce(c.fork) + ): Account(nonce=_base_nonce(c.fork)) + }, + ), + Op.CREATE2: Case( + Op.CREATE2(value=0x0, offset=0x0, size=0x0, salt=0x0), + lambda c: _address_word( + compute_create2_address(address=c.created, salt=0x0, initcode=b"") + ), + creations=1, + extra=lambda c: { + compute_create2_address( + address=c.created, salt=0x0, initcode=b"" + ): Account(nonce=_base_nonce(c.fork)) + }, + ), + # --- Arms with no value of their own: they must simply run. + Op.STOP: Case(Op.STOP), + Op.JUMPDEST: Case(Op.JUMPDEST), + Op.PC: Case(Op.POP(Op.PC)), + Op.GAS: Case(Op.POP(Op.GAS)), + Op.GASPRICE: Case(Op.POP(Op.GASPRICE)), + Op.LOG0: Case(Op.LOG0(offset=0x0, size=0x0)), + Op.LOG1: Case(Op.LOG1(offset=0x0, size=0x0, topic_1=0x0)), + Op.LOG2: Case(Op.LOG2(offset=0x0, size=0x0, topic_1=0x0, topic_2=0x0)), + Op.LOG3: Case( + Op.LOG3(offset=0x0, size=0x0, topic_1=0x0, topic_2=0x0, topic_3=0x0) + ), + Op.LOG4: Case( + Op.LOG4( + offset=0x0, + size=0x0, + topic_1=0x0, + topic_2=0x0, + topic_3=0x0, + topic_4=0x0, + ) + ), + # Reaching other accounts. +} +CASES.update({_push_op(n): _push_case(n) for n in range(1, 33)}) +CASES.update({_dup_op(n): _dup_case(n) for n in range(1, 17)}) +CASES.update({_swap_op(n): _swap_case(n) for n in range(1, 17)}) + + +def opcodes_by_fork(fork: Fork) -> Generator[ParameterSet, None, None]: + """Yield every opcode this fork enables, identified by its name.""" + for opcode in fork.valid_opcodes(): + yield pytest.param(opcode, id=opcode._name_.lower()) + + +@pytest.mark.ported_from( + ["state_tests/stTransactionTest/Opcodes_TransactionInitFiller.json"], ) -@pytest.mark.pre_alloc_mutable +@pytest.mark.valid_from("Frontier") +@pytest.mark.parametrize_by_fork("opcode", opcodes_by_fork) def test_opcodes_transaction_init( state_test: StateTestFiller, pre: Alloc, fork: Fork, - d: int, - g: int, - v: int, + opcode: Opcodes, ) -> None: - """Test_opcodes_transaction_init.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) - contract_1 = Address(0x0F572E5295C57F15886F9B263E2F6D2D6C7B5EC6) - sender = EOA( - key=0x45A915E4D060149EB4365960E6A7A45F334393093061116B197E3240065FF2D8 - ) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=1000000, + """Run one opcode inside a creation transaction's init code.""" + case = CASES.get(opcode) + assert case is not None, ( + f"{opcode._name_} is valid on this fork but has no case; " + "add one to CASES" ) - pre[sender] = Account(balance=0xDE0B6B3A7640000, storage={0: 0}) - # Source: yul - # berlin { sstore(0, 1) } - contract_0 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) + Op.STOP, - nonce=0, - address=Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B), # noqa: E501 - ) - # Source: raw - # 0x61ffff5060046000f3 - contract_1 = pre.deploy_contract( # noqa: F841 - code=Op.POP(0xFFFF) + Op.RETURN(offset=0x0, size=0x4), - balance=0xDE0B6B3A7640000, - nonce=1, - address=Address(0x0F572E5295C57F15886F9B263E2F6D2D6C7B5EC6), # noqa: E501 + sender = pre.fund_eoa() + targets = Targets( + storer=pre.deploy_contract(code=STORER_CODE, balance=STORER_BALANCE), + returner=pre.deploy_contract(code=RETURNER_CODE), + fork=fork, ) + body = case.body if isinstance(case.body, Bytecode) else case.body(targets) + created = compute_create_address(address=sender, nonce=0) - expect_entries_: list[dict] = [ - { - "indexes": {"data": 33, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - compute_create_address(address=sender, nonce=0): Account( - storage={ - 0: 0x38600060013960015160005560006000F3000000000000000000000000000000, # noqa: E501 - }, - nonce=1, - ), - }, - }, - { - "indexes": {"data": 37, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - compute_create_address(address=sender, nonce=0): Account( - nonce=1 - ), - }, - }, - { - "indexes": {"data": 38, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - compute_create_address(address=sender, nonce=0): Account( - nonce=1 - ), - }, - }, - { - "indexes": {"data": 120, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - compute_create_address(address=sender, nonce=0): Account( - nonce=2 - ), - }, - }, - { - "indexes": {"data": 124, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - compute_create_address(address=sender, nonce=0): Account( - nonce=1 - ), - }, - }, - { - "indexes": {"data": 125, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - compute_create_address(address=sender, nonce=0): Account( - nonce=1 - ), - }, - }, - { - "indexes": {"data": 126, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - compute_create_address( - address=sender, nonce=0 - ): Account.NONEXISTENT, - }, - }, - { - "indexes": {"data": 127, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - compute_create_address( - address=sender, nonce=0 - ): Account.NONEXISTENT, - }, - }, - { - "indexes": { - "data": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23, - 24, - 25, - 26, - 27, - 28, - 29, - 30, - 31, - 32, - 34, - 35, - 36, - 39, - 40, - 41, - 42, - 43, - 44, - 45, - 46, - 47, - 48, - 49, - 50, - 51, - 52, - 53, - 54, - 55, - 56, - 57, - 58, - 59, - 60, - 61, - 62, - 63, - 64, - 65, - 66, - 67, - 68, - 69, - 70, - 71, - 72, - 73, - 74, - 75, - 76, - 77, - 78, - 79, - 80, - 81, - 82, - 83, - 84, - 85, - 86, - 87, - 88, - 89, - 90, - 91, - 92, - 93, - 94, - 95, - 96, - 97, - 98, - 99, - 100, - 101, - 102, - 103, - 104, - 105, - 106, - 107, - 108, - 109, - 110, - 111, - 112, - 113, - 114, - 115, - 116, - 117, - 118, - 119, - 121, - 122, - 123, - ], - "gas": -1, - "value": -1, - }, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - compute_create_address(address=sender, nonce=0): Account( - nonce=1 - ), - }, - }, - { - "indexes": {"data": [128], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - compute_create_address( - address=sender, nonce=0 - ): Account.NONEXISTENT, - }, - }, - { - "indexes": {"data": [129], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account(storage={0: 1, 1: 0}), - compute_create_address(address=sender, nonce=0): Account( - nonce=1 - ), - }, - }, - { - "indexes": {"data": [130], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account(storage={}), - compute_create_address( - address=sender, nonce=0 - ): Account.NONEXISTENT, - }, - }, - { - "indexes": {"data": [131], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - contract_0: Account(storage={}), - compute_create_address( - address=sender, nonce=0 - ): Account.NONEXISTENT, - }, - }, - ] - - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) - - tx_data = [ - Op.STOP + Op.RETURN(offset=0x0, size=0x1), - Op.POP(Op.ADD(0x1, 0x1)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.MUL(0x1, 0x1)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.SUB(0x1, 0x1)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.DIV(0x1, 0x1)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.SDIV(0x1, 0x1)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.MOD(0x1, 0x1)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.SMOD(0x1, 0x1)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.ADDMOD(0x1, 0x1, 0x1)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.MULMOD(0x1, 0x1, 0x1)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.EXP(0x1, 0x1)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.SIGNEXTEND(0x1, 0x1)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.LT(0x1, 0x1)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.GT(0x1, 0x1)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.SLT(0x1, 0x1)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.SGT(0x1, 0x1)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.EQ(0x1, 0x1)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.ISZERO(0x0)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.AND(0x0, 0x0)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.OR(0x0, 0x0)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.XOR(0x0, 0x0)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.NOT(0x0)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.BYTE(0x0, 0x8050201008040201)) - + Op.RETURN(offset=0x0, size=0x0), - Op.SHA3(offset=0x0, size=0x0) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.ADDRESS) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.BALANCE(address=0x0)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.ORIGIN) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.CALLER) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.CALLVALUE) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.CALLDATALOAD(offset=0x0)) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.CALLDATASIZE) + Op.RETURN(offset=0x0, size=0x0), - Op.CALLDATACOPY(dest_offset=0x0, offset=0x0, size=0x0) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.CODESIZE) + Op.RETURN(offset=0x0, size=0x0), - Op.CODECOPY(dest_offset=0x1, offset=0x0, size=Op.CODESIZE) - + Op.SSTORE(key=0x0, value=Op.MLOAD(offset=0x1)) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.GASPRICE) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.EXTCODESIZE(address=0x0)) + Op.RETURN(offset=0x0, size=0x0), - Op.EXTCODECOPY( - address=0x1000000000000000000000000000000000000010, - dest_offset=0x0, - offset=0x0, - size=0x14, - ) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.RETURNDATASIZE) + Op.RETURN(offset=0x0, size=0x0), - Op.RETURNDATACOPY(dest_offset=0x0, offset=0x0, size=0x0) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0x0) * 2 + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.MLOAD(offset=0x0)) + Op.RETURN(offset=0x0, size=0x0), - Op.MSTORE(offset=0x0, value=0x0) + Op.RETURN(offset=0x0, size=0x0), - Op.MSTORE8(offset=0x0, value=0xFF) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.SLOAD(key=0x0)) + Op.RETURN(offset=0x0, size=0x0), - Op.SSTORE(key=0x1, value=0x1) + Op.RETURN(offset=0x0, size=0x0), - Op.JUMP(pc=0x4) - + Op.STOP - + Op.JUMPDEST - + Op.RETURN(offset=0x0, size=0x0), - Op.JUMPI(pc=0x6, condition=0x1) - + Op.STOP - + Op.JUMPDEST - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.PC) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.MSIZE) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.GAS) + Op.RETURN(offset=0x0, size=0x0), - Op.JUMPDEST + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFF) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFF) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFF) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFF) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFF) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFF) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFF) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFF) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFF) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFF) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFF) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFF) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFF) + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP( - 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF - ) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP( - 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF - ) - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] - + Op.POP(Op.DUP1) - + Op.POP - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 2 - + Op.POP(Op.DUP2) - + Op.POP * 2 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 3 - + Op.POP(Op.DUP3) - + Op.POP * 3 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 4 - + Op.POP(Op.DUP4) - + Op.POP * 4 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 5 - + Op.POP(Op.DUP5) - + Op.POP * 5 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 6 - + Op.POP(Op.DUP6) - + Op.POP * 6 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 7 - + Op.POP(Op.DUP7) - + Op.POP * 7 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 8 - + Op.POP(Op.DUP8) - + Op.POP * 8 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 9 - + Op.POP(Op.DUP9) - + Op.POP * 9 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 10 - + Op.POP(Op.DUP10) - + Op.POP * 10 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 11 - + Op.POP(Op.DUP11) - + Op.POP * 11 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 12 - + Op.POP(Op.DUP12) - + Op.POP * 12 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 13 - + Op.POP(Op.DUP13) - + Op.POP * 13 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 14 - + Op.POP(Op.DUP14) - + Op.POP * 14 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 15 - + Op.POP(Op.DUP15) - + Op.POP * 15 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 16 - + Op.POP(Op.DUP16) - + Op.POP * 16 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 2 - + Op.SWAP1 - + Op.POP * 2 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 3 - + Op.SWAP2 - + Op.POP * 3 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 4 - + Op.SWAP3 - + Op.POP * 4 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 5 - + Op.SWAP4 - + Op.POP * 5 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 6 - + Op.SWAP5 - + Op.POP * 6 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 7 - + Op.SWAP6 - + Op.POP * 7 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0xFF] * 8 - + Op.SWAP7 - + Op.POP * 8 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0x0] - + Op.PUSH1[0xFF] * 8 - + Op.SWAP8 - + Op.POP * 9 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0x0] - + Op.PUSH1[0xFF] * 9 - + Op.SWAP9 - + Op.POP * 10 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0x0] - + Op.PUSH1[0xFF] * 10 - + Op.SWAP10 - + Op.POP * 11 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0x0] - + Op.PUSH1[0xFF] * 11 - + Op.SWAP11 - + Op.POP * 12 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0x0] - + Op.PUSH1[0xFF] * 12 - + Op.SWAP12 - + Op.POP * 13 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0x0] - + Op.PUSH1[0xFF] * 13 - + Op.SWAP13 - + Op.POP * 14 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0x0] - + Op.PUSH1[0xFF] * 14 - + Op.SWAP14 - + Op.POP * 15 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0x0] - + Op.PUSH1[0xFF] * 15 - + Op.SWAP15 - + Op.POP * 16 - + Op.RETURN(offset=0x0, size=0x0), - Op.PUSH1[0x0] - + Op.PUSH1[0xFF] * 16 - + Op.SWAP16 - + Op.POP * 17 - + Op.RETURN(offset=0x0, size=0x0), - Op.LOG0(offset=0x0, size=0x0) + Op.RETURN(offset=0x0, size=0x0), - Op.LOG1(offset=0x0, size=0x0, topic_1=0xFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.LOG2(offset=0x0, size=0x0, topic_1=0xFF, topic_2=0xFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.LOG3(offset=0x0, size=0x0, topic_1=0xFF, topic_2=0xFF, topic_3=0xFF) - + Op.RETURN(offset=0x0, size=0x0), - Op.LOG4( - offset=0x0, - size=0x0, - topic_1=0xFF, - topic_2=0xFF, - topic_3=0xFF, - topic_4=0xFF, - ) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP(Op.CREATE(value=0xFF, offset=0x0, size=0x0)) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP( - Op.CALL( - gas=0x64, - address=contract_1, - value=0x17, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - ) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP( - Op.CALLCODE( - gas=0x64, - address=contract_1, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) + if case.terminates: + init_code = case.prefix + body + elif case.expected is None: + init_code = case.prefix + body + Op.RETURN(offset=0x0, size=0x0) + else: + init_code = ( + case.prefix + + Op.MSTORE(offset=0x0, value=body) + + Op.RETURN(offset=0x0, size=WORD) ) - + Op.RETURN(offset=0x0, size=0x0), - Op.RETURN(offset=0x0, size=0x0), - Op.POP( - Op.DELEGATECALL( - gas=0x186A0, - address=contract_1, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - ) - + Op.RETURN(offset=0x0, size=0x0), - Op.POP( - Op.STATICCALL( - gas=0x2710, - address=contract_1, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - ) - + Op.RETURN(offset=0x0, size=0x0), - Op.REVERT(offset=0x0, size=0x0) + Op.RETURN(offset=0x0, size=0x0), - Op.SELFDESTRUCT(address=Op.ORIGIN), - Bytes("ef"), - Op.CALL( - gas=0xC350, - address=contract_0, - value=Op.DUP1, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) - + Op.STOP, - Op.POP( - Op.CALL( - gas=0xC350, - address=contract_0, - value=Op.DUP1, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) - ) - + Op.INVALID, - Op.POP( - Op.CALL( - gas=0xC350, - address=contract_0, - value=Op.DUP1, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) + + context = Context( + created=created, sender=sender, init_code=init_code, fork=fork + ) + deployed_code = b"" + if case.expected is not None: + value = ( + case.expected(context) + if callable(case.expected) + else case.expected ) - + Op.MSTORE8(offset=0x0, value=0xEF) - + Op.RETURN(offset=0x0, size=0x1), - ] - tx_gas = [400000] - tx_value = [100000] + deployed_code = value.to_bytes(WORD, "big") tx = Transaction( sender=sender, to=None, - data=tx_data[d], - gas_limit=tx_gas[g], - value=tx_value[v], - error=_exc, + data=init_code, + value=TX_VALUE, + protected=fork.supports_protected_txs(), + ) + + post: dict[Address, Account | None] = {sender: Account(nonce=1)} + post[created] = ( + Account.NONEXISTENT + if case.discarded + else Account( + code=deployed_code, + # EIP-161 starts a new contract's nonce at one; before it, + # at zero. + nonce=_base_nonce(fork) + case.creations, + ) ) + if case.extra is not None: + post.update(case.extra(context)) - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(env=ENV, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stTransactionTest/test_store_gas_on_create.py b/tests/ported_static/stTransactionTest/test_store_gas_on_create.py index 2d97f61d885..0cd492ac766 100644 --- a/tests/ported_static/stTransactionTest/test_store_gas_on_create.py +++ b/tests/ported_static/stTransactionTest/test_store_gas_on_create.py @@ -1,17 +1,24 @@ """ -Test_store_gas_on_create. +Verify the gas a CREATE's init code observes when the creating contract is +entered directly by the transaction: the child receives all but one 64th +of what remains in the creating frame. Ported from: state_tests/stTransactionTest/StoreGasOnCreateFiller.json + +@manually-enhanced: Do not overwrite. The ported bytecode is kept, but +the creating frame is entered through an outer call with a derived +budget, so the child's stored GAS observation depends on neither the +transaction gas limit nor the fork's intrinsic cost (the ported absolute +pin moved with every schedule change). The floor is TangerineWhistle +because the 63/64 withhold this test measures is EIP-150's. """ import pytest from execution_testing import ( Account, - Address, Alloc, - Bytes, - Environment, + Fork, StateTestFiller, Transaction, compute_create_address, @@ -21,51 +28,84 @@ REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +CHILD_GAS_SLOT = 0xFD +# The child must afford its store out of the 63/64 it is granted, so the +# creating frame carries a little more than that store costs. +CHILD_HEADROOM = 5_000 + @pytest.mark.ported_from( ["state_tests/stTransactionTest/StoreGasOnCreateFiller.json"], ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable +@pytest.mark.valid_from("TangerineWhistle") def test_store_gas_on_create( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: - """Test_store_gas_on_create.""" - coinbase = Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) - sender = pre.fund_eoa(amount=0x17D78400) + """A CREATE's init code observes 63/64 of the creating frame's gas.""" + # Child init code: stores the gas it observes, deposits no code. + child_code = Op.SSTORE( + key=CHILD_GAS_SLOT, + value=Op.GAS, + key_warm=False, + original_value=0, + new_value=1, + ) - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=1000000, + setup = Op.MSTORE( + offset=0x0, + value=int.from_bytes(child_code, "big"), + new_memory_size=0x20, + ) + create_code = Op.CREATE( + value=0x0, + offset=0x20 - len(child_code), + size=len(child_code), + new_memory_size=0x20, + old_memory_size=0x20, + init_code_size=len(child_code), + ) + creator = pre.deploy_contract( + code=setup + Op.POP(create_code) + Op.STOP, ) - # Source: lll - # { (MSTORE 0 0x5a60fd55) (CREATE 0 28 4)} - coinbase = pre.deploy_contract( # noqa: F841 - code=Op.MSTORE(offset=0x0, value=0x5A60FD55) - + Op.CREATE(value=0x0, offset=0x1C, size=0x4) - + Op.STOP, - nonce=0, - address=Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B), # noqa: E501 + # Enter the creator through an outer call with a fixed budget, so + # the child's observation depends on neither the transaction's gas + # limit nor the fork's intrinsic cost. The budget is chosen from + # what the child should see, then grown to cover the frame's own + # charges. + child_budget = child_code.gas_cost(fork) + CHILD_HEADROOM + creator_gas = ( + child_budget + setup.gas_cost(fork) + create_code.gas_cost(fork) + ) + entry = pre.deploy_contract( + code=Op.POP(Op.CALL(gas=creator_gas, address=creator)) + Op.STOP ) tx = Transaction( - sender=sender, - to=coinbase, - data=Bytes(""), - gas_limit=131882, - value=100, + sender=pre.fund_eoa(), + to=entry, + protected=fork.supports_protected_txs(), + # Charge state gas to the frames, so an EIP-8037 CREATE's own + # cost comes out of the budget above rather than a reservoir. + state_gas_reservoir=0, + ) + + # The child receives all but one 64th of what the creating frame + # still holds at the CREATE, and must afford its store out of that. + granted = child_budget - child_budget // 64 + assert granted > child_code.gas_cost(fork), ( + "CHILD_HEADROOM no longer covers the 63/64 withhold" ) + child_observed = granted - Op.GAS.gas_cost(fork) post = { - compute_create_address(address=coinbase, nonce=0): Account( - storage={253: 0x12F39} + compute_create_address(address=creator, nonce=1): Account( + nonce=int(fork.is_eip_enabled(161)), + code=b"", + storage={CHILD_GAS_SLOT: child_observed}, ), } - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stTransactionTest/test_suicides_and_internal_call_suicides_success.py b/tests/ported_static/stTransactionTest/test_suicides_and_internal_call_suicides_success.py index 03537745a43..0ccda83c1fa 100644 --- a/tests/ported_static/stTransactionTest/test_suicides_and_internal_call_suicides_success.py +++ b/tests/ported_static/stTransactionTest/test_suicides_and_internal_call_suicides_success.py @@ -1,146 +1,109 @@ """ -Test_suicides_and_internal_call_suicides_success. +Verify SELFDESTRUCT inside an internal call: the callee self-destructs to +a previously nonexistent beneficiary, which materializes only when the +forwarded gas covers the new-account charge. Ported from: state_tests/stTransactionTest/SuicidesAndInternalCallSuicidesSuccessFiller.json + +@manually-enhanced: Do not overwrite. The two forwarded-gas calldata words +derive from the fork's SELFDESTRUCT new-account cost (state-priced +under EIP-8037), and the two arms sit one gas either side of it, so the +boundary is exact on every fork rather than approximate. The floor is +Berlin: the cold-access metadata the budget derives from has no meaning +before EIP-2929. """ import pytest from execution_testing import ( - EOA, Account, Address, Alloc, - Environment, - Hash, StateTestFiller, Transaction, ) from execution_testing.forks import Fork from execution_testing.vm import Op -from tests.ported_static.post_state_resolution import ( - resolve_expect_post, -) - REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +CALL_VALUE = 1 +SD_VALUE = 999 + @pytest.mark.ported_from( [ "state_tests/stTransactionTest/SuicidesAndInternalCallSuicidesSuccessFiller.json" # noqa: E501 ], ) -@pytest.mark.valid_from("Cancun") +@pytest.mark.valid_from("Berlin") @pytest.mark.parametrize( - "d, g, v", + "sufficient_selfdestruct_gas", [ - pytest.param( - 0, - 0, - 0, - id="d0", - ), - pytest.param( - 1, - 0, - 0, - id="d1", - ), + pytest.param(False, id="insufficient_selfdestruct_gas"), + pytest.param(True, id="sufficient_selfdestruct_gas"), ], ) -@pytest.mark.pre_alloc_mutable def test_suicides_and_internal_call_suicides_success( state_test: StateTestFiller, pre: Alloc, fork: Fork, - d: int, - g: int, - v: int, + sufficient_selfdestruct_gas: bool, ) -> None: - """Test_suicides_and_internal_call_suicides_success.""" - coinbase = Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) - contract_0 = Address(0x0000000000000000000000000000000000000000) - contract_1 = Address(0xC94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) - sender = EOA( - key=0x45A915E4D060149EB4365960E6A7A45F334393093061116B197E3240065FF2D8 - ) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - ) - - pre[sender] = Account(balance=0xABA9500) + """A funded SELFDESTRUCT materializes its beneficiary.""" + self_destructing_contract_recipient = pre.nonexistent_account() # Source: lll # {(SELFDESTRUCT 0x0000000000000000000000000000000000000001)} - contract_0 = pre.deploy_contract( # noqa: F841 - code=Op.SELFDESTRUCT(address=0x1) + Op.STOP, - nonce=0, - address=Address(0x0000000000000000000000000000000000000000), # noqa: E501 + self_destruct_code = Op.SELFDESTRUCT( + address=self_destructing_contract_recipient, + # The beneficiary has never been touched, so the access is cold, + # and it does not exist, so it has to be created. + address_warm=False, + account_new=True, ) + self_destructing_contract = pre.deploy_contract(code=self_destruct_code) + + # What the callee needs: the beneficiary push plus the SELFDESTRUCT, + # including its EIP-8037 state charge. A value-bearing CALL hands it + # a stipend on top of the ask, so the ask that exactly suffices is + # that much smaller; the two arms sit one gas either side of it. + required_gas = self_destruct_code.gas_cost(fork) + exact_ask = required_gas - fork.gas_costs().CALL_STIPEND + assert exact_ask > 0, "the stipend alone would fund the SELFDESTRUCT" + call_gas = exact_ask if sufficient_selfdestruct_gas else exact_ask - 1 + # Source: lll # {(CALL (CALLDATALOAD 0) 0x0000000000000000000000000000000000000000 1 0 0 0 0) (SELFDESTRUCT 0)} # noqa: E501 - contract_1 = pre.deploy_contract( # noqa: F841 + caller_self_destructing_contract = pre.deploy_contract( code=Op.POP( Op.CALL( - gas=Op.CALLDATALOAD(offset=contract_0), - address=contract_0, - value=0x1, - args_offset=contract_0, - args_size=contract_0, - ret_offset=contract_0, - ret_size=contract_0, + gas=call_gas, + address=self_destructing_contract, + value=CALL_VALUE, ) ) - + Op.SELFDESTRUCT(address=contract_0) + + Op.SELFDESTRUCT(address=self_destructing_contract) + Op.STOP, - balance=1000, - nonce=0, - address=Address(0xC94F5374FCE5EDBC8E2A8697C15331677E6EBF0B), # noqa: E501 + balance=CALL_VALUE + SD_VALUE, ) - expect_entries_: list[dict] = [ - { - "indexes": {"data": 0, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - Address( - 0x0000000000000000000000000000000000000001 - ): Account.NONEXISTENT, - }, - }, - { - "indexes": {"data": 1, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - Address(0x0000000000000000000000000000000000000001): Account( - storage={}, balance=1 - ), - }, - }, - ] - - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) - - tx_data = [ - Hash(0x55F0), - Hash(0xAAF0), - ] - tx_gas = [150000] - tx_value = [10] + # The beneficiary is created only when the forwarded gas covers the + # new-account charge; otherwise the callee runs out and never pays. + post: dict[Address, Account | None] = { + self_destructing_contract_recipient: ( + Account(storage={}, balance=CALL_VALUE) + if sufficient_selfdestruct_gas + else Account.NONEXISTENT + ), + } tx = Transaction( - sender=sender, - to=contract_1, - data=tx_data[d], - gas_limit=tx_gas[g], - value=tx_value[v], - error=_exc, + sender=pre.fund_eoa(), + to=caller_self_destructing_contract, + # Charge state gas to the frames, so the callee's new-account + # charge is paid out of `call_gas` and the boundary is real. + state_gas_reservoir=0, ) - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) From d9bc249acc4b6edb21475357e9b8080243e1ec53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20Wahrst=C3=A4tter?= <51536394+nerolation@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:23:38 +0200 Subject: [PATCH 25/59] fix(tests): undecodable BAL is an invalid payload (#3463) --- .../test_block_access_lists_invalid.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py index 0199542e9ab..5e317c462b6 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py @@ -23,7 +23,6 @@ BlockException, Bytes, EIPChecklist, - EngineAPIError, Environment, Fork, Hash, @@ -1708,6 +1707,9 @@ def test_bal_invalid_engine_payload_encoding( list: the empty byte string `0x` (an empty BAL is `0xc0`), the RLP empty byte string `0x80` (valid RLP but not a list), or a truncated list header `0xc1`. + + The field is present but not a valid encoding, so the payload is + invalid rather than the request being malformed. """ sender = pre.fund_eoa() receiver = pre.nonexistent_account() @@ -1727,7 +1729,6 @@ def test_bal_invalid_engine_payload_encoding( invalid_bal_payload ), exception=BlockException.INVALID_BLOCK_ACCESS_LIST, - engine_api_error_code=EngineAPIError.InvalidParams, ) ], ) From c4deda5b3cfc5c1c8429dcd9159a6fb5636d8486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E4=BD=B3=E8=AA=A0=20Louis=20Tsai?= <72684086+LouisTsai-Csie@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:25:02 +0800 Subject: [PATCH 26/59] refactor(tests): enhance EIP-8037 test coverage part 2 (#3449) * refactor: state gas call scenario * refactor: state gas calldata floor scenario * refactor: state gas multi block scenario * refactor: state gas ordering scenario * refactor: state gas pricing scenario * refactor: state gas sstore scenario * tests: more fixes, added coverage --------- Co-authored-by: marioevz --- .../test_state_gas_call.py | 1173 +++++++++++------ .../test_state_gas_calldata_floor.py | 171 ++- .../test_state_gas_multi_block.py | 94 +- .../test_state_gas_ordering.py | 285 ---- .../test_state_gas_pricing.py | 408 ++++-- .../test_state_gas_sstore.py | 452 +++++-- 6 files changed, 1650 insertions(+), 933 deletions(-) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_call.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_call.py index 100afd09b41..cf565fdbff1 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_call.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_call.py @@ -24,6 +24,8 @@ Block, BlockchainTestFiller, Bytecode, + CodeGasMeasure, + Conditional, Fork, Header, Op, @@ -31,6 +33,7 @@ Storage, Transaction, TransactionReceipt, + WhileGas, compute_create_address, ) from execution_testing.checklists import EIPChecklist @@ -41,11 +44,16 @@ REFERENCE_SPEC_VERSION = ref_spec_8037.version +@pytest.mark.parametrize( + "sufficient_gas", + ["sufficient_gas", "insufficient_execute", "insufficient_state"], +) @pytest.mark.valid_from("EIP8037") def test_child_call_uses_reservoir( state_test: StateTestFiller, pre: Alloc, fork: Fork, + sufficient_gas: str, ) -> None: """ Test child call can use parent's state gas reservoir. @@ -54,26 +62,42 @@ def test_child_call_uses_reservoir( (zero-to-nonzero). The state gas for the SSTORE is drawn from the reservoir passed from the parent. """ - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - child_storage = Storage() + code = Op.SSTORE( + child_storage.store_next( + 1 if sufficient_gas == "sufficient_gas" else 0 + ), + 1, + # gas accounting + original_value=0, + new_value=1, + ) + + state_gas = code.state_cost(fork) + execution_gas = code.execution_cost(fork) + if sufficient_gas == "insufficient_execute": + execution_gas -= 1 + elif sufficient_gas == "insufficient_state": + state_gas -= 1 child = pre.deploy_contract( - code=Op.SSTORE(child_storage.store_next(1), 1), + code=code, ) parent_storage = Storage() parent = pre.deploy_contract( code=( Op.SSTORE( - parent_storage.store_next(1), - Op.CALL(gas=100_000, address=child), + parent_storage.store_next( + 1 if sufficient_gas == "sufficient_gas" else 0 + ), + Op.CALL(gas=execution_gas, address=child), ) - ), + ) ) tx = Transaction( to=parent, - state_gas_reservoir=sstore_state_gas, + state_gas_reservoir=state_gas, sender=pre.fund_eoa(), ) @@ -88,6 +112,7 @@ def test_child_call_uses_reservoir( def test_delegatecall_child_spill_not_double_charged( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """ Test DELEGATECALL child state gas paid from `gas_left` is not recharged. @@ -96,23 +121,48 @@ def test_delegatecall_child_spill_not_double_charged( reservoir (`state_gas_reservoir=0`), the top-level frame starts with no state gas reservoir and the child pays for SSTOREs by spilling from `gas_left`. The parent frame must not charge the same state growth again - at frame end. + at frame end: the header bills the storage sets once, in the state + dimension, so a second charge surfaces as inflated `gas_used`. """ - child_code = sum(Op.SSTORE(i, i + 1) for i in range(6)) + Op.STOP + num_sstores = 6 + child_code = ( + sum( + Op.SSTORE( + i, + 1, + # gas accounting + original_value=0, + new_value=1, + ) + for i in range(num_sstores) + ) + + Op.STOP + ) + child = pre.deploy_contract(code=child_code) - caller = pre.deploy_contract( - code=Op.POP( - Op.DELEGATECALL( - gas=Op.GAS, - address=child, - args_offset=0, - args_size=0, - ret_offset=0, - ret_size=0, - ) + caller_code = Op.POP( + Op.DELEGATECALL( + gas=Op.GAS, + address=child, + args_offset=0, + args_size=0, + ret_offset=0, + ret_size=0, ) ) + caller = pre.deploy_contract(code=caller_code) + + state_gas = child_code.state_cost(fork) + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + caller_code.execution_cost(fork) + + child_code.execution_cost(fork) + ) + expected_gas_used = max(execution_gas, state_gas) + assert expected_gas_used == state_gas, ( + "expected state gas to dominate execution gas" + ) tx = Transaction( to=caller, @@ -121,9 +171,14 @@ def test_delegatecall_child_spill_not_double_charged( ) post = { - caller: Account(storage={i: i + 1 for i in range(6)}), + caller: Account(storage=dict.fromkeys(range(num_sstores), 1)), } - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) @pytest.mark.valid_from("EIP8037") @@ -135,22 +190,37 @@ def test_reservoir_returned_on_revert( """ Test state gas reservoir is returned to parent on child revert. - The child contract reverts. The parent should recover the - reservoir and be able to use it for its own SSTORE. + The child draws the whole reservoir for an SSTORE then reverts, + restoring it. Repeating that leaves the parent with only one + SSTORE's execution cost, so its own SSTORE has nothing to spill + from and lands only if the restores went back to the reservoir. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - child = pre.deploy_contract(code=Op.REVERT(0, 0)) + child_code = Op.SSTORE(0, 1) + Op.REVERT(0, 0) + child = pre.deploy_contract(code=child_code) parent_storage = Storage() - parent = pre.deploy_contract( - code=( - # Call child that reverts (returns 0) - Op.POP(Op.CALL(gas=100_000, address=child)) - # Parent can still use reservoir for its own SSTORE - + Op.SSTORE(parent_storage.store_next(1), 1) - ), + final_sstore = Op.SSTORE(parent_storage.store_next(1), 1) + + child_gas = child_code.execution_cost(fork) + parent_code = ( + WhileGas( + body=Op.POP( + Op.CALL( + gas=child_gas, + address=child, + # gas accounting + address_warm=True, + inner_call_cost=child_gas, + ) + ), + fork=fork, + extra_gas=final_sstore.execution_cost(fork), + ) + + final_sstore ) + parent = pre.deploy_contract(code=parent_code) tx = Transaction( to=parent, @@ -171,23 +241,38 @@ def test_reservoir_returned_on_oog( """ Test state gas reservoir is returned to parent on child OOG. - The child runs out of execution gas. The parent recovers the - reservoir and can use it for its own state operations. + The child draws the whole reservoir for an SSTORE then halts on its + last gas, restoring it. Repeating that leaves the parent with only + one SSTORE's execution cost, so its own SSTORE has nothing to spill + from: it lands only if the reservoir came back. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - # Child that consumes all gas - child = pre.deploy_contract(code=Op.INVALID) + child_code = Op.SSTORE(0, 1) + Op.INVALID + child = pre.deploy_contract(code=child_code) parent_storage = Storage() - parent = pre.deploy_contract( - code=( - # Call child with minimal gas — it will OOG (returns 0) - Op.POP(Op.CALL(gas=100, address=child)) - # Parent can still use reservoir for SSTORE - + Op.SSTORE(parent_storage.store_next(1), 1) - ), + final_sstore = Op.SSTORE(parent_storage.store_next(1), 1) + # Without this metadata `WhileGas` sizes an iteration from the call + # opcode alone, misses the forwarded gas, and exits too low. + child_gas = child_code.execution_cost(fork) + parent_code = ( + WhileGas( + body=Op.POP( + Op.CALL( + gas=child_gas, + address=child, + # gas accounting + address_warm=True, + inner_call_cost=child_gas, + ) + ), + fork=fork, + extra_gas=final_sstore.execution_cost(fork), + ) + + final_sstore ) + parent = pre.deploy_contract(code=parent_code) tx = Transaction( to=parent, @@ -213,38 +298,62 @@ def test_reservoir_restored_after_child_spill_and_revert( spills into `gas_left`. The child then REVERTs. Because state changes are rolled back, the state gas is refilled LIFO: the spilled portion returns to `gas_left` and the reservoir-funded - portion restores the reservoir to its start value. The parent - then performs two SSTOREs, drawing one from the restored - reservoir and spilling the other from the recovered `gas_left`. + portion restores the reservoir to its start value. The parent then + calls a probe handed only its SSTORE's execution cost, so the probe + has no `gas_left` to spill from and succeeds only if the reservoir + itself was restored. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - # Child does two SSTOREs then reverts — the second SSTORE's - # state gas spills from the reservoir into `gas_left` - child = pre.deploy_contract( - code=(Op.SSTORE(0, 1) + Op.SSTORE(1, 1) + Op.REVERT(0, 0)), - ) + child_code = Op.SSTORE(0, 1) + Op.SSTORE(1, 1) + Op.REVERT(0, 0) + child = pre.deploy_contract(code=child_code) + # Exactly enough for both SSTOREs: the reservoir funds the first and + # the second spills. + child_gas = child_code.execution_cost(fork) + sstore_state_gas + + probe_code = Op.SSTORE(0, 1) + probe = pre.deploy_contract(code=probe_code) + probe_gas = probe_code.execution_cost(fork) parent_storage = Storage() - parent = pre.deploy_contract( - code=( - Op.POP(Op.CALL(gas=500_000, address=child)) - # State gas recovered LIFO: the spilled SSTORE returns to - # gas_left, the other restores the reservoir - + Op.SSTORE(parent_storage.store_next(1), 1) - + Op.SSTORE(parent_storage.store_next(1), 1) - ), + probe_slot = parent_storage.store_next(1, "probe_succeeds") + parent_code = Op.POP(Op.CALL(gas=child_gas, address=child)) + Op.SSTORE( + probe_slot, + Op.CALL(gas=probe_gas, address=probe), + original_value=1, + current_value=1, + new_value=1, + key_warm=False, ) + parent = pre.deploy_contract(code=parent_code, storage={probe_slot: 1}) + + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + parent_code.execution_cost(fork) + + child_code.execution_cost(fork) + + probe_gas + ) + expected_gas_used = max(execution_gas, sstore_state_gas) - # Reservoir = 1 SSTORE's worth of state gas — child will spill tx = Transaction( to=parent, state_gas_reservoir=sstore_state_gas, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=execution_gas + sstore_state_gas, + ), ) - post = {parent: Account(storage=parent_storage)} - state_test(pre=pre, post=post, tx=tx) + post = { + parent: Account(storage=parent_storage), + probe: Account(storage={0: 1}), + } + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) @pytest.mark.valid_from("EIP8037") @@ -267,70 +376,142 @@ def test_reservoir_restored_after_child_spill_and_halt( a credit of the burned spill back to the parent is caught. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() - child_budget = 500_000 - # Child does two SSTOREs then halts - child = pre.deploy_contract( - code=(Op.SSTORE(0, 1) + Op.SSTORE(1, 1) + Op.INVALID), - ) + child_code = Op.SSTORE(0, 1) + Op.SSTORE(1, 1) + Op.INVALID + child = pre.deploy_contract(code=child_code) + # Exactly enough for both SSTOREs: the reservoir funds the first and + # the second spills, leaving nothing for the INVALID to burn. + child_gas = child_code.execution_cost(fork) + sstore_state_gas + + probe_code = Op.SSTORE(0, 1) + probe_gas = probe_code.execution_cost(fork) + funded_probe = pre.deploy_contract(code=probe_code) + starved_probe = pre.deploy_contract(code=probe_code) parent_storage = Storage() + funded_slot = parent_storage.store_next(1) + starved_slot = parent_storage.store_next(0) parent_code = ( - Op.POP(Op.CALL(gas=child_budget, address=child)) - # First SSTORE drains the recovered reservoir; second - # SSTORE spills from parent's gas_left (gas_limit_cap is - # large enough to absorb it). - + Op.SSTORE(parent_storage.store_next(1), 1) - + Op.SSTORE(parent_storage.store_next(1), 1) + Op.POP(Op.CALL(gas=child_gas, address=child)) + + Op.SSTORE( + funded_slot, + Op.CALL(gas=probe_gas, address=funded_probe), + # gas accounting + original_value=1, + current_value=1, + new_value=1, + key_warm=False, + ) + + Op.SSTORE( + starved_slot, + Op.CALL(gas=probe_gas, address=starved_probe), + # gas accounting + original_value=1, + current_value=1, + new_value=0, + key_warm=False, + ) + ) + parent = pre.deploy_contract( + code=parent_code, storage={funded_slot: 1, starved_slot: 1} ) - parent = pre.deploy_contract(code=parent_code) - # The halted child burns its whole budget. The parent's own sets - # and their state gas are inside `gas_cost`. - expected_cumulative = ( - intrinsic_cost + parent_code.gas_cost(fork) + child_budget + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + parent_code.execution_cost(fork) + + child_gas + + 2 * probe_gas + ) + expected_gas_used = max(execution_gas, sstore_state_gas) + + # Recording the starved probe's failure clears its slot, which earns + # an execution refund the sender-facing receipt is net of. + gas_used_before_refund = execution_gas + sstore_state_gas + refund = min( + gas_used_before_refund // fork.max_refund_quotient(), + parent_code.refund(fork), ) - # Reservoir = 1 SSTORE's worth of state gas — child will spill tx = Transaction( to=parent, state_gas_reservoir=sstore_state_gas, sender=pre.fund_eoa(), expected_receipt=TransactionReceipt( - cumulative_gas_used=expected_cumulative, + cumulative_gas_used=gas_used_before_refund - refund, ), ) - post = {parent: Account(storage=parent_storage)} - state_test(pre=pre, post=post, tx=tx) + post = { + parent: Account(storage=parent_storage), + funded_probe: Account(storage={0: 1}), + starved_probe: Account(storage={0: 0}), + } + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) +@pytest.mark.parametrize( + "sufficent_gas", + [ + pytest.param(False, id="insufficient_gas"), + pytest.param(True, id="sufficient_gas"), + ], +) @pytest.mark.valid_from("EIP8037") def test_reservoir_restored_after_child_full_drain_and_revert( state_test: StateTestFiller, pre: Alloc, fork: Fork, + sufficent_gas: bool, ) -> None: """ Test reservoir restored when child exactly exhausts it then reverts. - The child performs exactly one SSTORE consuming the entire reservoir - (no spill into gas_left), then REVERTs. The full reservoir is - returned to the parent. + The child is granted only its execution cost, so its single SSTORE + must draw the whole state charge from the reservoir, then REVERTs. + The parent then calls a probe handed only its SSTORE's execution + cost, so the probe has no `gas_left` to spill from and succeeds + only if the full reservoir came back. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - child = pre.deploy_contract( - code=(Op.SSTORE(0, 1) + Op.REVERT(0, 0)), - ) + child_code = Op.SSTORE(0, 1) + Op.REVERT(0, 0) + child = pre.deploy_contract(code=child_code) + child_gas = child_code.execution_cost(fork) + + probe_code = Op.SSTORE(0, 1) + probe = pre.deploy_contract(code=probe_code) + probe_gas = probe_code.execution_cost(fork) + if not sufficent_gas: + probe_gas -= 1 parent_storage = Storage() - parent = pre.deploy_contract( - code=( - Op.POP(Op.CALL(gas=500_000, address=child)) - + Op.SSTORE(parent_storage.store_next(1), 1) - ), + probe_slot = parent_storage.store_next( + 2 if sufficent_gas else 1, "probe_succeeds" + ) + parent_code = Op.POP(Op.CALL(gas=child_gas, address=child)) + Op.SSTORE( + probe_slot, + Op.ADD(Op.CALL(gas=probe_gas, address=probe), 1), + # gas accounting + original_value=1, + current_value=1, + new_value=2 if sufficent_gas else 1, + key_warm=False, + ) + parent = pre.deploy_contract(code=parent_code, storage={probe_slot: 1}) + + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + parent_code.execution_cost(fork) + + child_gas + + probe_gas + ) + expected_gas_used = max( + execution_gas, sstore_state_gas if sufficent_gas else 0 ) tx = Transaction( @@ -339,41 +520,80 @@ def test_reservoir_restored_after_child_full_drain_and_revert( sender=pre.fund_eoa(), ) - post = {parent: Account(storage=parent_storage)} - state_test(pre=pre, post=post, tx=tx) + post = { + parent: Account(storage=parent_storage), + probe: Account(storage={0: 1 if sufficent_gas else 0}), + } + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) +@pytest.mark.parametrize( + "sufficent_gas", + [ + pytest.param(False, id="insufficient_gas"), + pytest.param(True, id="sufficient_gas"), + ], +) @pytest.mark.valid_from("EIP8037") def test_sequential_calls_reservoir_restored_between_reverts( state_test: StateTestFiller, pre: Alloc, fork: Fork, + sufficent_gas: bool, ) -> None: """ Test reservoir restored across sequential child reverts. - Parent calls child1, which uses the reservoir for an SSTORE and - reverts, restoring the reservoir. It then calls child2, which - reuses the restored reservoir and reverts, restoring it again. - The parent then performs its own SSTORE from the restored - reservoir. + Parent calls the child twice; each run uses the reservoir for an + SSTORE and reverts, restoring it. The parent then calls a probe + handed only its SSTORE's execution cost, so the probe has no + `gas_left` to spill from and succeeds only if both restores landed + in the reservoir rather than in `gas_left`. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - child = pre.deploy_contract( - code=(Op.SSTORE(0, 1) + Op.REVERT(0, 0)), - ) + child_code = Op.SSTORE(0, 1) + Op.REVERT(0, 0) + child = pre.deploy_contract(code=child_code) + child_gas = child_code.execution_cost(fork) + + probe_code = Op.SSTORE(0, 1) + probe = pre.deploy_contract(code=probe_code) + probe_gas = probe_code.execution_cost(fork) + if not sufficent_gas: + probe_gas -= 1 parent_storage = Storage() - parent = pre.deploy_contract( - code=( - # First child: uses reservoir, reverts — reservoir restored - Op.POP(Op.CALL(gas=500_000, address=child)) - # Second child: uses restored reservoir, reverts — restored again - + Op.POP(Op.CALL(gas=500_000, address=child)) - # Parent SSTORE succeeds with restored reservoir - + Op.SSTORE(parent_storage.store_next(1), 1) - ), + probe_slot = parent_storage.store_next( + 2 if sufficent_gas else 1, "probe_succeeds" + ) + parent_code = ( + Op.POP(Op.CALL(gas=child_gas, address=child)) + + Op.POP(Op.CALL(gas=child_gas, address=child, address_warm=True)) + + Op.SSTORE( + probe_slot, + Op.ADD(Op.CALL(gas=probe_gas, address=probe), 1), + # gas accounting + original_value=1, + current_value=1, + new_value=2 if sufficent_gas else 1, + key_warm=False, + ) + ) + parent = pre.deploy_contract(code=parent_code, storage={probe_slot: 1}) + + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + parent_code.execution_cost(fork) + + 2 * child_gas + + probe_gas + ) + expected_gas_used = max( + execution_gas, sstore_state_gas if sufficent_gas else 0 ) tx = Transaction( @@ -382,42 +602,77 @@ def test_sequential_calls_reservoir_restored_between_reverts( sender=pre.fund_eoa(), ) - post = {parent: Account(storage=parent_storage)} - state_test(pre=pre, post=post, tx=tx) + post = { + parent: Account(storage=parent_storage), + probe: Account(storage={0: 1 if sufficent_gas else 0}), + } + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) +@pytest.mark.parametrize( + "sufficient_gas", + [ + pytest.param(False, id="insufficient_gas"), + pytest.param(True, id="sufficient_gas"), + ], +) @pytest.mark.valid_from("EIP8037") def test_nested_calls_reservoir_passing( state_test: StateTestFiller, pre: Alloc, fork: Fork, + sufficient_gas: bool, ) -> None: """ Test reservoir passes through nested calls. The reservoir is passed from A to B to C. C performs an SSTORE using the reservoir gas. After all calls return, A verifies - success. + success. C is handed only its execution cost, so one gas less + halts it before the SSTORE and leaves the reservoir untouched. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) c_storage = Storage() - c = pre.deploy_contract( - code=Op.SSTORE(c_storage.store_next(1), 1), - ) + c_code = Op.SSTORE(c_storage.store_next(1 if sufficient_gas else 0), 1) + c = pre.deploy_contract(code=c_code) + c_gas = c_code.execution_cost(fork) + if not sufficient_gas: + c_gas -= 1 + + # Each hop forwards only execution gas; the reservoir rides along in + # full, so C's SSTORE lands only if it reached the bottom frame. B + # gets 64/63 of C's need because a frame may forward at most 63/64 + # of the gas it holds. + b_code = Op.CALL(gas=c_gas, address=c) + b = pre.deploy_contract(code=b_code) - b = pre.deploy_contract( - code=Op.CALL(gas=200_000, address=c), + a_storage = Storage() + call_slot = a_storage.store_next(1, "nested_call_succeeds") + a_code = Op.SSTORE( + call_slot, + Op.CALL(address=b), + # gas accounting + original_value=1, + current_value=1, + new_value=1, + key_warm=False, ) + a = pre.deploy_contract(code=a_code, storage={call_slot: 1}) - a_storage = Storage() - a = pre.deploy_contract( - code=( - Op.SSTORE( - a_storage.store_next(1), - Op.CALL(gas=300_000, address=b), - ) - ), + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + a_code.execution_cost(fork) + + b_code.execution_cost(fork) + + c_gas + ) + expected_gas_used = max( + execution_gas, sstore_state_gas if sufficient_gas else 0 ) tx = Transaction( @@ -430,7 +685,12 @@ def test_nested_calls_reservoir_passing( a: Account(storage=a_storage), c: Account(storage=c_storage), } - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) @pytest.mark.valid_from("EIP8037") @@ -449,43 +709,65 @@ def test_call_value_transfer_new_account( target = pre.nonexistent_account() parent_storage = Storage() - # Capture the CALL result in a pre-existing slot (2 -> 1) so the - # instrumentation SSTORE modifies rather than creates a key and - # adds no state gas; the reservoir then covers exactly the CALL's - # new-account charge. - slot = parent_storage.store_next(1) + # The slot already holds the value the CALL returns, so the + # recording SSTORE is a no-op write that adds no state gas; the + # reservoir then covers exactly the CALL's new-account charge. + call_slot = parent_storage.store_next(1) parent_code = Op.SSTORE( - slot, + call_slot, Op.CALL( - gas=100_000, + gas=0, address=target, value=1, value_transfer=True, account_new=True, ), - original_value=2, - current_value=2, + # gas accounting + original_value=1, + current_value=1, new_value=1, key_warm=False, ) parent = pre.deploy_contract( - code=parent_code, balance=1, storage={slot: 2} + code=parent_code, balance=1, storage={call_slot: 1} + ) + + state_gas = parent_code.state_cost(fork) + # The codeless target returns the forwarded value-call stipend + # unused, so it is charged but never consumed. + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + parent_code.execution_cost(fork) + - fork.call_value_stipend() + ) + expected_gas_used = max(execution_gas, state_gas) + assert expected_gas_used == state_gas, ( + "expected state gas to dominate execution gas" ) tx = Transaction( to=parent, - state_gas_reservoir=parent_code.state_cost(fork), + state_gas_reservoir=state_gas, sender=pre.fund_eoa(), ) - post = {parent: Account(storage=parent_storage)} - state_test(pre=pre, post=post, tx=tx) + post = { + parent: Account(storage=parent_storage), + target: Account(balance=1), + } + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) @pytest.mark.valid_from("EIP8037") def test_call_value_transfer_existing_account_no_state_gas( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """ Test CALL with value to existing account charges no state gas. @@ -497,14 +779,29 @@ def test_call_value_transfer_existing_account_no_state_gas( target = pre.fund_eoa(amount=1) parent_storage = Storage() + + call_slot = parent_storage.store_next(1) + parent_code = Op.SSTORE( + call_slot, + Op.CALL(gas=0, address=target, value=1, value_transfer=True), + original_value=1, + current_value=1, + new_value=1, + key_warm=False, + ) parent = pre.deploy_contract( - code=( - Op.SSTORE( - parent_storage.store_next(1), - Op.CALL(gas=100_000, address=target, value=1), - ) - ), - balance=1, + code=parent_code, balance=1, storage={call_slot: 1} + ) + + state_gas = parent_code.state_cost(fork) + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + parent_code.execution_cost(fork) + - fork.call_value_stipend() + ) + expected_gas_used = max(execution_gas, state_gas) + assert state_gas == 0 and expected_gas_used == execution_gas, ( + "expected no state gas and execution gas to dominate" ) tx = Transaction( @@ -517,7 +814,12 @@ def test_call_value_transfer_existing_account_no_state_gas( parent: Account(balance=0, storage=parent_storage), target: Account(balance=2), } - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) @pytest.mark.valid_from("EIP8037") @@ -529,32 +831,41 @@ def test_child_state_gas_tracked_in_parent( """ Test state gas used by child is accumulated in parent. - Both parent and child perform SSTOREs. The total state gas used - should reflect both operations. This is verified by the test - succeeding with enough total gas but would OOG if state gas - wasn't tracked across frames. + Both parent and child perform SSTOREs, and the reservoir is sized + for exactly those two. A probe handed only its SSTORE's execution + cost then finds the reservoir empty and fails; it would succeed if + the child's draw had gone unrecorded in the parent. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) child_storage = Storage() - child = pre.deploy_contract( - code=Op.SSTORE(child_storage.store_next(1), 1), - ) + child_code = Op.SSTORE(child_storage.store_next(1), 1) + child = pre.deploy_contract(code=child_code) + child_gas = child_code.execution_cost(fork) + + probe_code = Op.SSTORE(0, 1) + probe = pre.deploy_contract(code=probe_code) + probe_gas = probe_code.execution_cost(fork) parent_storage = Storage() - parent = pre.deploy_contract( - code=( - # Parent SSTORE - Op.SSTORE(parent_storage.store_next(1), 1) - # Child SSTORE - + Op.SSTORE( - parent_storage.store_next(1), - Op.CALL(gas=100_000, address=child), - ) - ), + probe_slot = parent_storage.store_next(0, "probe_fails") + parent_code = ( + Op.SSTORE(parent_storage.store_next(1, "parent"), 1) + + Op.POP(Op.CALL(gas=child_gas, address=child)) + + Op.SSTORE( + probe_slot, + Op.CALL(gas=probe_gas, address=probe), + # gas accounting + original_value=1, + current_value=1, + new_value=0, + key_warm=False, + ) ) + parent = pre.deploy_contract(code=parent_code, storage={probe_slot: 1}) - # Provide enough reservoir for both SSTOREs + # Sized for the parent's and the child's SSTORE and nothing more, so + # the probe finds the reservoir empty with no gas_left to spill from. tx = Transaction( to=parent, state_gas_reservoir=sstore_state_gas * 2, @@ -564,6 +875,7 @@ def test_child_state_gas_tracked_in_parent( post = { parent: Account(storage=parent_storage), child: Account(storage=child_storage), + probe: Account(storage={0: 0}), } state_test(pre=pre, post=post, tx=tx) @@ -578,21 +890,35 @@ def test_delegatecall_reservoir_passing( Test DELEGATECALL passes full reservoir to child. DELEGATECALL runs child code in the caller's storage context. - The child's SSTORE writes to the parent's storage using state - gas from the reservoir. + The child's SSTORE writes to the parent's storage using state gas + from the reservoir, emptying it. A probe handed only its SSTORE's + execution cost then fails, proving the draw was real. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) parent_storage = Storage() # Library code runs in parent's context — slot is reserved on # parent_storage so the post check uses the same source of truth. - library = pre.deploy_contract( - code=Op.SSTORE(parent_storage.store_next(1, "delegated"), 1), - ) - - parent = pre.deploy_contract( - code=(Op.DELEGATECALL(gas=100_000, address=library)), + library_code = Op.SSTORE(parent_storage.store_next(1), 1) + library = pre.deploy_contract(code=library_code) + + probe_code = Op.SSTORE(0, 1) + probe = pre.deploy_contract(code=probe_code) + probe_gas = probe_code.execution_cost(fork) + + probe_slot = parent_storage.store_next(0, "probe_fails") + parent_code = Op.POP( + Op.DELEGATECALL(gas=library_code.execution_cost(fork), address=library) + ) + Op.SSTORE( + probe_slot, + Op.CALL(gas=probe_gas, address=probe), + # gas accounting + original_value=1, + current_value=1, + new_value=0, + key_warm=False, ) + parent = pre.deploy_contract(code=parent_code, storage={probe_slot: 1}) tx = Transaction( to=parent, @@ -600,7 +926,10 @@ def test_delegatecall_reservoir_passing( sender=pre.fund_eoa(), ) - post = {parent: Account(storage=parent_storage)} + post = { + parent: Account(storage=parent_storage), + probe: Account(storage={0: 0}), + } state_test(pre=pre, post=post, tx=tx) @@ -613,24 +942,55 @@ def test_staticcall_passes_reservoir( """ Test STATICCALL passes reservoir but cannot use it for state ops. - STATICCALL forbids state-modifying operations. The reservoir is - passed to the child but cannot be consumed. After the STATICCALL - returns, the parent can still use the reservoir for its own SSTORE. + The static child is handed the full execution cost of an SSTORE, + so only the static-context restriction can stop it, and it halts. + The parent then calls a probe handed only its SSTORE's execution + cost, so the probe has no `gas_left` to spill from and succeeds + only if the rejected write left the reservoir untouched. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - # Child does a read-only operation - child = pre.deploy_contract( - code=Op.MSTORE(0, Op.ADDRESS), - ) + child_code = Op.SSTORE(0, 1) + child = pre.deploy_contract(code=child_code) + child_gas = child_code.execution_cost(fork) + + probe_code = Op.SSTORE(0, 1) + probe = pre.deploy_contract(code=probe_code) + probe_gas = probe_code.execution_cost(fork) parent_storage = Storage() + static_slot = parent_storage.store_next(1, "staticcall_fails") + probe_slot = parent_storage.store_next(2, "probe_succeeds") + parent_code = Op.SSTORE( + static_slot, + Op.ADD(Op.STATICCALL(gas=child_gas, address=child), 1), + # gas accounting + original_value=1, + current_value=1, + new_value=1, + key_warm=False, + ) + Op.SSTORE( + probe_slot, + Op.ADD(Op.CALL(gas=probe_gas, address=probe), 1), + # gas accounting + original_value=1, + current_value=1, + new_value=2, + key_warm=False, + ) parent = pre.deploy_contract( - code=( - Op.POP(Op.STATICCALL(gas=100_000, address=child)) - # Reservoir should still be available for parent's SSTORE - + Op.SSTORE(parent_storage.store_next(1), 1) - ), + code=parent_code, storage={static_slot: 1, probe_slot: 1} + ) + + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + parent_code.execution_cost(fork) + + child_gas + + probe_gas + ) + expected_gas_used = max(execution_gas, sstore_state_gas) + assert expected_gas_used == sstore_state_gas, ( + "expected state gas to dominate execution gas" ) tx = Transaction( @@ -639,8 +999,17 @@ def test_staticcall_passes_reservoir( sender=pre.fund_eoa(), ) - post = {parent: Account(storage=parent_storage)} - state_test(pre=pre, post=post, tx=tx) + post = { + parent: Account(storage=parent_storage), + child: Account(storage={0: 0}), + probe: Account(storage={0: 1}), + } + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) @pytest.mark.valid_from("EIP8037") @@ -652,35 +1021,40 @@ def test_gas_opcode_excludes_reservoir( """ Test GAS opcode returns gas_left only, excluding the reservoir. - The spec states the GAS opcode reports only gas_left. When the - reservoir is non-empty, the GAS return value should be less than - the total remaining gas (gas_left + reservoir). + Measuring GAS either side of a call whose child spends the + reservoir yields the execution gas alone. Had GAS reported the + reservoir too, the difference would be inflated by the child's + state charge. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - storage = Storage() + child_code = Op.SSTORE(0, 1) + child = pre.deploy_contract(code=child_code) + child_gas = child_code.execution_cost(fork) + + # GAS before and after a call whose child spends the reservoir. The + # difference is the execution gas alone; had GAS reported the + # reservoir too, it would be inflated by the child's state charge. + measured_code = Op.CALL(gas=child_gas, address=child) + measured_gas = measured_code.execution_cost(fork) + child_gas contract = pre.deploy_contract( - code=( - # Store GAS opcode result — should only reflect gas_left - Op.SSTORE(0, Op.GAS) - # Store 1 to prove execution reached this point - + Op.SSTORE(storage.store_next(1), 1) + code=CodeGasMeasure( + code=measured_code, + overhead_cost=0, + extra_stack_items=1, ), ) - # Provide large reservoir — GAS should NOT include it - reservoir_gas = sstore_state_gas * 100 tx = Transaction( to=contract, - state_gas_reservoir=reservoir_gas, + state_gas_reservoir=sstore_state_gas, sender=pre.fund_eoa(), ) - # Verify: slot 0 should hold a value <= TX_MAX_GAS_LIMIT - # (gas_left is capped by TX_MAX_GAS_LIMIT - intrinsic.execution) - # We can't check the exact value, but we verify the SSTORE - # succeeded and the contract executed correctly - post = {contract: Account(storage=storage)} + post = { + contract: Account(storage={0: measured_gas}), + child: Account(storage={0: 1}), + } state_test(pre=pre, post=post, tx=tx) @@ -695,36 +1069,48 @@ def test_call_insufficient_balance_returns_reservoir( A value-bearing CALL to an existing account fails the balance check before entering the child frame; gas_left and state_gas_left are - returned to the parent, which can still use the reservoir for a - later SSTORE. The new-account variant (where NEW_ACCOUNT is charged - then refilled on the same failure) is pinned by - test_call_insufficient_balance_refunds_new_account_state_gas. + returned to the parent. A probe handed only its SSTORE's execution + cost then succeeds, proving the reservoir came back rather than the + gas landing in `gas_left`. The new-account variant (where + NEW_ACCOUNT is charged then refilled on the same failure) is pinned + by test_call_insufficient_balance_refunds_new_account_state_gas. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) target = pre.deploy_contract(code=Op.STOP) - reservoir = sstore_state_gas + + probe_code = Op.SSTORE(0, 1) + probe = pre.deploy_contract(code=probe_code) + probe_gas = probe_code.execution_cost(fork) storage = Storage() - contract = pre.deploy_contract( - code=( - # CALL with 1 wei — fails (contract has 0 balance) - Op.SSTORE( - storage.store_next(0, "call_fails"), - Op.CALL(100_000, target, 1, 0, 0, 0, 0), - ) - # Reservoir should be returned — SSTORE still works - + Op.SSTORE(storage.store_next(1, "sstore_after"), 1) - ), + call_slot = storage.store_next(0, "call_fails") + probe_slot = storage.store_next(1, "probe_succeeds") + contract_code = Op.SSTORE( + call_slot, Op.CALL(gas=0, address=target, value=1) + ) + Op.SSTORE( + probe_slot, + Op.CALL(gas=probe_gas, address=probe), + # gas accounting + original_value=1, + current_value=1, + new_value=1, + key_warm=False, ) + contract = pre.deploy_contract(code=contract_code, storage={probe_slot: 1}) + # The probe is handed only its execution cost, so its SSTORE lands + # only if the failed call handed the reservoir back intact. tx = Transaction( to=contract, - state_gas_reservoir=reservoir, + state_gas_reservoir=sstore_state_gas, sender=pre.fund_eoa(), ) - post = {contract: Account(storage=storage)} + post = { + contract: Account(storage=storage), + probe: Account(storage={0: 1}), + } state_test(pre=pre, post=post, tx=tx) @@ -739,23 +1125,36 @@ def test_create_insufficient_balance_returns_reservoir( When CREATE is called but the sender doesn't have enough balance for the endowment, the operation fails and both gas and state gas - reservoir are returned to the parent frame. + reservoir are returned to the parent frame. A probe handed only its + SSTORE's execution cost then succeeds, proving the state gas came + back to the reservoir rather than to `gas_left`. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + init_code = Op.STOP + mstore_value, init_code_size = init_code_at_high_bytes(init_code) + + probe_code = Op.SSTORE(0, 1) + probe = pre.deploy_contract(code=probe_code) + probe_gas = probe_code.execution_cost(fork) + storage = Storage() - contract = pre.deploy_contract( - code=( - Op.MSTORE(0, int.from_bytes(bytes(Op.STOP), "big") << 248) - # CREATE with 1 wei endowment — fails (contract has 0 balance) - + Op.SSTORE( - storage.store_next(0, "create_fails"), - Op.CREATE(1, 0, 1), - ) - # Reservoir returned — SSTORE still works - + Op.SSTORE(storage.store_next(1, "sstore_after"), 1) - ), + create_slot = storage.store_next(0, "create_fails") + probe_slot = storage.store_next(1, "probe_succeeds") + contract_code = ( + Op.MSTORE(0, mstore_value) + + Op.SSTORE(create_slot, Op.CREATE(1, 0, init_code_size)) + + Op.SSTORE( + probe_slot, + Op.CALL(gas=probe_gas, address=probe), + # gas accounting + original_value=1, + current_value=1, + new_value=1, + key_warm=False, + ) ) + contract = pre.deploy_contract(code=contract_code, storage={probe_slot: 1}) tx = Transaction( to=contract, @@ -763,7 +1162,10 @@ def test_create_insufficient_balance_returns_reservoir( sender=pre.fund_eoa(), ) - post = {contract: Account(storage=storage)} + post = { + contract: Account(storage=storage), + probe: Account(storage={0: 1}), + } state_test(pre=pre, post=post, tx=tx) @@ -774,34 +1176,55 @@ def test_call_stack_depth_returns_reservoir( fork: Fork, ) -> None: """ - Test CALL at stack depth limit returns reservoir. + Test a deep self-recursing call chain returns the reservoir. - When a CALL exceeds the 1024 stack depth limit, the call fails - and gas and state gas reservoir are returned. The parent can still - use the reservoir for state operations. + Each frame recurses while it has the gas to, and the bottom one + instead draws the whole reservoir for an SSTORE and reverts, + restoring it. A probe in the top frame, handed only its SSTORE's + execution cost, then succeeds only if that restore travelled back + up the entire unwind. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - # Contract that recursively calls itself until depth exhausted, - # then does an SSTORE using the reservoir + # Recurse while there is comfortably more gas than the bottom frame + # needs. Gas shrinks by only 1/64 per level, so the frame that first + # falls below the threshold still holds well over `bottom_gas`. + bottom = Op.SSTORE(0, 1) + Op.REVERT(0, 0) + bottom_gas = bottom.execution_cost(fork) + driver_code = Conditional( + condition=Op.GT(Op.GAS, 2 * bottom_gas), + if_true=Op.POP(Op.CALL(Op.GAS, Op.ADDRESS, 0, 0, 0, 0, 0)), + if_false=bottom, + ) + driver = pre.deploy_contract(code=driver_code) + + probe_code = Op.SSTORE(0, 1) + probe = pre.deploy_contract(code=probe_code) + probe_gas = probe_code.execution_cost(fork) + storage = Storage() - recursive = pre.deploy_contract( - code=( - # Try recursive call (will eventually hit depth 1024) - Op.POP(Op.CALL(Op.GAS, Op.ADDRESS, 0, 0, 0, 0, 0)) - # After recursion unwinds, only the outermost frame - # reaches this SSTORE - + Op.SSTORE(storage.store_next(1, "after_recursion"), 1) - ), + probe_slot = storage.store_next(1, "probe_succeeds") + caller_code = Op.POP(Op.CALL(gas=Op.GAS, address=driver)) + Op.SSTORE( + probe_slot, + Op.CALL(gas=probe_gas, address=probe), + # gas accounting + original_value=1, + current_value=1, + new_value=1, + key_warm=False, ) + caller = pre.deploy_contract(code=caller_code, storage={probe_slot: 1}) tx = Transaction( - to=recursive, + to=caller, state_gas_reservoir=sstore_state_gas, sender=pre.fund_eoa(), ) - post = {recursive: Account(storage=storage)} + post = { + caller: Account(storage=storage), + probe: Account(storage={0: 1}), + } state_test(pre=pre, post=post, tx=tx) @@ -902,7 +1325,7 @@ def test_call_new_account_header_gas_used( contract_code = Op.SSTORE( slot, Op.CALL( - gas=100_000, + gas=0, address=target, value=1, value_transfer=True, @@ -917,16 +1340,29 @@ def test_call_new_account_header_gas_used( code=contract_code, balance=1, storage={slot: 2} ) + state_gas = contract_code.state_cost(fork) + # The codeless target returns the forwarded value-call stipend + # unused, so it is charged but never consumed. + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + contract_code.execution_cost(fork) + - fork.call_value_stipend() + ) + expected_gas_used = max(execution_gas, state_gas) + assert expected_gas_used == state_gas, ( + "expected state gas to dominate execution gas" + ) + tx = Transaction( to=contract, - state_gas_reservoir=contract_code.state_cost(fork), + state_gas_reservoir=state_gas, sender=pre.fund_eoa(), ) blockchain_test( pre=pre, blocks=[ - Block(txs=[tx]), + Block(txs=[tx], header_verify=Header(gas_used=expected_gas_used)) ], post={contract: Account(storage=storage)}, ) @@ -953,9 +1389,9 @@ def test_call_value_to_self_destructed_same_tx_account( Confirms the happy path runs to completion. The account still has its CREATE nonce when the CALL runs, so it is neither empty - nor nonexistent and the new account creation gate does not fire; - end of the transaction destruction removes the account regardless - and the value transferred is burned. Strict discrimination of + nor nonexistent and the new account creation gate does not fire. + End of the transaction destruction then clears its nonce, code and + storage but leaves the balance in place. Strict discrimination of the no charge behavior lives in `test_call_value_to_self_destructed_header_gas_used`. """ @@ -1041,80 +1477,18 @@ def test_call_value_to_self_destructed_header_gas_used( ) + Op.MSTORE(0x20, Op.DUP1) + Op.POP - + Op.POP(Op.CALL(gas=Op.GAS, address=Op.MLOAD(0x20), value=1)) - ) - orchestrator = pre.deploy_contract(code=orchestrator_code, balance=3) - - tx = Transaction( - to=orchestrator, - state_gas_reservoir=orchestrator_code.state_cost(fork), - sender=pre.fund_eoa(), - ) - - blockchain_test( - pre=pre, - blocks=[Block(txs=[tx])], - post={}, - ) - - -@pytest.mark.parametrize( - "call_value", - [ - pytest.param(1, id="one_wei"), - pytest.param(10**18, id="one_ether"), - ], -) -@pytest.mark.parametrize( - "create_opcode", - [ - pytest.param(Op.CREATE, id="create"), - pytest.param(Op.CREATE2, id="create2"), - ], -) -@pytest.mark.valid_from("EIP8037") -def test_call_value_to_self_destructed_burns_value( - blockchain_test: BlockchainTestFiller, - pre: Alloc, - fork: Fork, - create_opcode: Op, - call_value: int, -) -> None: - """ - Verify value transferred to a same transaction selfdestructed - account is burned when end of the transaction destruction runs. - - The orchestrator funds the inner contract via CREATE, the - initcode immediately selfdestructs, and then the orchestrator - transfers more value into the now queued for destruction - address. At the end of the transaction the account is removed - and the accumulated balance is lost. - """ - inner_code = Op.SELFDESTRUCT(Op.ADDRESS) - mstore_value, size = init_code_at_high_bytes(inner_code) - - initial_balance = 2 * call_value - orchestrator_code = ( - Op.MSTORE(0, mstore_value) - + ( - Op.CREATE2(call_value, 0, size, 0) - if create_opcode == Op.CREATE2 - else Op.CREATE(call_value, 0, size) - ) - + Op.MSTORE(0x20, Op.DUP1) - + Op.POP + Op.POP( Op.CALL( gas=Op.GAS, address=Op.MLOAD(0x20), - value=call_value, + value=1, + # gas accounting + value_transfer=True, ) ) ) - orchestrator = pre.deploy_contract( - code=orchestrator_code, balance=initial_balance - ) - created_address = compute_create_address( + orchestrator = pre.deploy_contract(code=orchestrator_code, balance=3) + created = compute_create_address( address=orchestrator, nonce=1, salt=0, @@ -1122,23 +1496,42 @@ def test_call_value_to_self_destructed_burns_value( opcode=create_opcode, ) + state_gas = orchestrator_code.state_cost(fork) + # The codeless target returns the forwarded value-call stipend + # unused, so it is charged but never consumed. + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + orchestrator_code.execution_cost(fork) + + inner_code.execution_cost(fork) + - fork.call_value_stipend() + ) + expected_gas_used = max(execution_gas, state_gas) + assert expected_gas_used == state_gas, ( + "expected state gas to dominate execution gas" + ) + tx = Transaction( to=orchestrator, - state_gas_reservoir=orchestrator_code.state_cost(fork), + state_gas_reservoir=state_gas, sender=pre.fund_eoa(), ) - created_address_account = Account.NONEXISTENT - if fork.is_eip_enabled(8246): - created_address_account = Account(balance=call_value * 2) + # End of transaction destruction clears the nonce, code and storage + # but leaves every wei the account received in place. + swept = 0 if selfdestruct_beneficiary == "self" else 1 + post: dict = { + orchestrator: Account(balance=1), + created: Account(balance=2 - swept, nonce=0, code=b"", storage={}), + } + if selfdestruct_beneficiary != "self": + post[alive_beneficiary] = Account(balance=1 + swept) blockchain_test( pre=pre, - blocks=[Block(txs=[tx])], - post={ - created_address: created_address_account, - orchestrator: Account(balance=0), - }, + blocks=[ + Block(txs=[tx], header_verify=Header(gas_used=expected_gas_used)) + ], + post=post, ) @@ -1182,15 +1575,31 @@ def test_call_zero_value_to_self_destructed_same_tx_account( ) orchestrator = pre.deploy_contract(code=orchestrator_code, balance=3) + # The reservoir already pins the gas limit, and is sized for the + # CREATE's single account creation. Pinning the header is what + # proves the zero-value CALL added no second charge. + state_gas = orchestrator_code.state_cost(fork) + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + orchestrator_code.execution_cost(fork) + + inner_code.execution_cost(fork) + ) + expected_gas_used = max(execution_gas, state_gas) + assert expected_gas_used == state_gas, ( + "expected state gas to dominate execution gas" + ) + tx = Transaction( to=orchestrator, - state_gas_reservoir=orchestrator_code.state_cost(fork), + state_gas_reservoir=state_gas, sender=pre.fund_eoa(), ) blockchain_test( pre=pre, - blocks=[Block(txs=[tx])], + blocks=[ + Block(txs=[tx], header_verify=Header(gas_used=expected_gas_used)) + ], post={}, ) @@ -1244,14 +1653,19 @@ def test_call_value_to_pre_existing_selfdestructed_account( balance=1, ) - probes = Bytecode() - for slot in range(num_probes): - probes += Op.SSTORE(slot, 1) + probe_storage = Storage() + probe_code = Bytecode() + for _ in range(num_probes): + probe_code += Op.SSTORE(probe_storage.store_next(1), 1) + + probe = pre.deploy_contract(code=probe_code) + probe_gas = probe_code.execution_cost(fork) + orchestrator = pre.deploy_contract( code=( Op.POP(Op.CALL(gas=Op.GAS, address=target)) + Op.POP(Op.CALL(gas=Op.GAS, address=target, value=1)) - + probes + + Op.POP(Op.CALL(gas=probe_gas, address=probe)) ), balance=3, ) @@ -1270,7 +1684,7 @@ def test_call_value_to_pre_existing_selfdestructed_account( header_verify=Header(gas_used=probe_state_gas), ), ], - post={}, + post={probe: Account(storage=probe_storage)}, ) @@ -1329,7 +1743,15 @@ def test_top_level_halt_burns_spilled_state_gas( child = pre.deploy_contract(code=child_code) parent = pre.deploy_contract( - code=(Op.POP(Op.CALL(gas=500_000, address=child)) + Op.INVALID), + code=( + Op.POP( + Op.CALL( + gas=child_code.execution_cost(fork) - reservoir_delta, + address=child, + ) + ) + + Op.INVALID + ), ) reservoir = sstore_state_gas + reservoir_delta @@ -1374,18 +1796,27 @@ def test_callcode_value_no_new_account_state_gas( target = pre.fund_eoa(amount=0) storage = Storage() - contract = pre.deploy_contract( - code=( - Op.POP( - Op.CALLCODE( - gas=Op.GAS, - address=target, - value=1, - ) - ) - + Op.SSTORE(storage.store_next(1, "reservoir_ok"), 1) - ), - balance=10**18, + contract_code = Op.POP( + Op.CALLCODE( + gas=Op.GAS, + address=target, + value=1, + value_transfer=True, + ) + ) + Op.SSTORE(storage.store_next(1, "reservoir_ok"), 1) + contract = pre.deploy_contract(code=contract_code, balance=10**18) + + state_gas = contract_code.state_cost(fork) + # The codeless callee returns the forwarded value-call stipend + # unused, so it is charged but never consumed. + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + contract_code.execution_cost(fork) + - fork.call_value_stipend() + ) + expected_gas_used = max(execution_gas, state_gas) + assert expected_gas_used == state_gas == sstore_state_gas, ( + "expected only the SSTORE's state gas, dominating execution gas" ) tx = Transaction( @@ -1398,7 +1829,12 @@ def test_callcode_value_no_new_account_state_gas( contract: Account(storage=storage), target: Account.NONEXISTENT, } - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) @pytest.mark.with_all_create_opcodes() @@ -1424,15 +1860,10 @@ def test_create_oog_during_state_gas_charge( else create_opcode(value=0, offset=31, size=1) ) - inner = pre.deploy_contract( - code=( - Op.MSTORE( - 0, - int.from_bytes(bytes(init_code), "big") << 248, - ) - + Op.POP(inner_create_call) - ), + inner_code = Op.MSTORE(0, init_code_at_high_bytes(init_code)[0]) + Op.POP( + inner_create_call ) + inner = pre.deploy_contract(code=inner_code) grandchild_storage = Storage() grandchild_code = Op.SSTORE(grandchild_storage.store_next(1, "ran"), 1) @@ -1442,7 +1873,7 @@ def test_create_oog_during_state_gas_charge( parent = pre.deploy_contract( code=( - Op.POP(Op.CALL(gas=20_000, address=inner)) + Op.POP(Op.CALL(gas=inner_code.execution_cost(fork), address=inner)) + Op.POP(Op.CALL(gas=grandchild_stipend, address=grandchild)) ), ) @@ -1486,12 +1917,14 @@ def test_call_new_account_no_execution_account_creation_cost( ) caller = pre.deploy_contract(code=caller_code, balance=1) - # Tight budget: slack is less than the old pre-Amsterdam execution - # account-creation cost, so any extra execution draw would OOG. - intrinsic = fork.transaction_intrinsic_cost_calculator()() + # Exactly the intrinsic cost plus both gas dimensions the code + # needs, so any extra execution draw OOGs. tx = Transaction( to=caller, - gas_limit=(intrinsic + caller_code.gas_cost(fork) + 20_000), + gas_limit=( + fork.transaction_intrinsic_cost_calculator()() + + caller_code.gas_cost(fork) + ), sender=pre.fund_eoa(), ) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_calldata_floor.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_calldata_floor.py index c79f3b05873..60abef02639 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_calldata_floor.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_calldata_floor.py @@ -34,41 +34,105 @@ REFERENCE_SPEC_VERSION = ref_spec_8037.version +def calldata_length_where_floor_overtakes(fork: Fork, state_gas: int) -> int: + """ + Return the shortest all-nonzero calldata whose floor outgrows + `state_gas`. + """ + floor_calculator = fork.transaction_data_floor_cost_calculator() + + def floor_at(length: int) -> int: + return floor_calculator(data=b"\x01" * length) + + low, high = 0, 1 + while floor_at(high) <= state_gas: + high *= 2 + while low < high: + middle = (low + high) // 2 + if floor_at(middle) > state_gas: + high = middle + else: + low = middle + 1 + return low + + @EIPChecklist.GasRefundsChanges.Test.CrossFunctional.CalldataCost() +@pytest.mark.parametrize( + "floor_dominates", + [ + pytest.param(False, id="state_gas_dominates"), + pytest.param(True, id="calldata_floor_dominates"), + ], +) @pytest.mark.valid_from("EIP8037") def test_calldata_floor_with_sstore( state_test: StateTestFiller, pre: Alloc, + fork: Fork, + floor_dominates: bool, ) -> None: """ Test calldata floor does not affect state gas charging. - A transaction with large calldata triggers the calldata floor for - execution gas, but state gas for SSTORE is charged independently. + The calldata is sized to the exact length at which the floor + overtakes the SSTORE's state gas, so the two variants sit one byte + either side of that boundary. The block bills the state charge + below it and the floor above it, while the sender's bill stays the + sum of both dimensions throughout: the floor never discounts the + state gas, and the state gas never discounts the floor. """ storage = Storage() - contract = pre.deploy_contract( - code=Op.SSTORE(storage.store_next(1), 1), + code = Op.SSTORE(storage.store_next(1), 1, new_value=1) + state_cost = code.state_cost(fork) + execution_cost = code.execution_cost(fork) + + flip_length = calldata_length_where_floor_overtakes(fork, state_cost) + calldata = b"\x01" * (flip_length if floor_dominates else flip_length - 1) + + floor = fork.transaction_data_floor_cost_calculator()(data=calldata) + intrinsic = fork.transaction_intrinsic_cost_calculator()( + calldata=calldata, + return_cost_deducted_prior_execution=True, + ) + tx_execution = intrinsic + execution_cost + assert tx_execution < state_cost, ( + "the code's own execution gas must stay under the state gas, so " + "the floor alone decides the execution dimension" ) + if floor_dominates: + assert floor > state_cost, "calldata floor must outgrow the state gas" + else: + assert floor < state_cost, ( + "calldata floor must stay under the state gas" + ) - # Large calldata to trigger the calldata floor - calldata = b"\x01" * 256 + contract = pre.deploy_contract(code=code) tx = Transaction( to=contract, data=calldata, state_gas_reservoir=0, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=tx_execution + state_cost + ), ) - post = {contract: Account(storage=storage)} - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post={contract: Account(storage=storage)}, + tx=tx, + blockchain_test_header_verify=Header( + gas_used=max(tx_execution, floor, state_cost) + ), + ) @pytest.mark.valid_from("EIP8037") def test_calldata_floor_independent_of_state_gas( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """ Test calldata floor applies only to execution gas dimension. @@ -80,17 +144,28 @@ def test_calldata_floor_independent_of_state_gas( """ contract = pre.deploy_contract(code=Op.STOP) - # Large calldata so the floor exceeds actual execution gas calldata = b"\xff" * 512 + floor = fork.transaction_data_floor_cost_calculator()(data=calldata) + tx_execution = fork.transaction_intrinsic_cost_calculator()( + calldata=calldata, + return_cost_deducted_prior_execution=True, + ) + assert tx_execution < floor, "calldata floor must bind" tx = Transaction( to=contract, data=calldata, state_gas_reservoir=0, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt(cumulative_gas_used=floor), ) - state_test(pre=pre, post={}, tx=tx) + state_test( + pre=pre, + post={}, + tx=tx, + blockchain_test_header_verify=Header(gas_used=floor), + ) @pytest.mark.valid_from("EIP8037") @@ -105,25 +180,41 @@ def test_calldata_floor_higher_than_execution_with_state_ops( Even when calldata floor > actual execution gas used, state gas for SSTORE is charged normally from the reservoir or gas_left. """ - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - storage = Storage() - contract = pre.deploy_contract( - code=Op.SSTORE(storage.store_next(1), 1), - ) + code = Op.SSTORE(storage.store_next(1), 1, new_value=1) + state_cost = code.state_cost(fork) + execution_cost = code.execution_cost(fork) - # Large calldata so floor dominates execution gas calldata = b"\x01" * 1024 + floor = fork.transaction_data_floor_cost_calculator()(data=calldata) + intrinsic = fork.transaction_intrinsic_cost_calculator()( + calldata=calldata, + return_cost_deducted_prior_execution=True, + ) + tx_execution = intrinsic + execution_cost + assert tx_execution < floor < state_cost, ( + "floor must bind the execution dimension without reaching the " + "state dimension" + ) + + contract = pre.deploy_contract(code=code) tx = Transaction( to=contract, data=calldata, - state_gas_reservoir=sstore_state_gas, + state_gas_reservoir=state_cost, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=tx_execution + state_cost + ), ) - post = {contract: Account(storage=storage)} - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post={contract: Account(storage=storage)}, + tx=tx, + blockchain_test_header_verify=Header(gas_used=state_cost), + ) @pytest.mark.inclusion_test @@ -161,6 +252,7 @@ def test_calldata_floor_exceeding_tx_gas_limit_cap( cap = fork.transaction_gas_limit_cap() assert cap is not None floor_cost = fork.transaction_data_floor_cost_calculator() + intrinsic_cost = fork.transaction_intrinsic_cost_calculator() # Binary-search the largest all-nonzero calldata whose floor cost fits # within the gas cap; `exceeds_cap` adds one more byte to tip the floor @@ -182,15 +274,17 @@ def floor_fits(num_bytes: int) -> bool: max_bytes = low + 1 if exceeds_cap else low calldata = b"\x01" * max_bytes - contract = pre.deploy_contract(Op.STOP) + storage = Storage() + code = Op.SSTORE(storage.store_next(1), 1, new_value=1) + contract = pre.deploy_contract(code=code) + floor = floor_cost(data=calldata) + execution = intrinsic_cost( + calldata=calldata, + return_cost_deducted_prior_execution=True, + ) if exceeds_cap: - intrinsic = fork.transaction_intrinsic_cost_calculator() - execution = intrinsic( - calldata=calldata, - return_cost_deducted_prior_execution=True, - ) assert floor > cap, "calldata floor must exceed the cap" assert execution < cap, "execution intrinsic must stay below the cap" # Fund the floor in full so the sufficiency check cannot reject the @@ -198,6 +292,9 @@ def floor_fits(num_bytes: int) -> bool: gas_limit = floor + 1_000_000 else: assert floor <= cap + assert execution + code.gas_cost(fork) <= cap, ( + "the cap must still fund the callee's execution and state gas" + ) gas_limit = cap tx = Transaction( @@ -208,20 +305,29 @@ def floor_fits(num_bytes: int) -> bool: error=TransactionException.INTRINSIC_GAS_TOO_LOW if exceeds_cap else None, + expected_receipt=None + if exceeds_cap + else TransactionReceipt(cumulative_gas_used=floor), ) - post = {contract: Account(code=Op.STOP)} if not exceeds_cap else {} - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post={contract: Account(storage={0: 0} if exceeds_cap else storage)}, + tx=tx, + blockchain_test_header_verify=None + if exceeds_cap + else Header(gas_used=floor), + ) @pytest.mark.valid_from("EIP8037") -def test_calldata_floor_applied_to_sender_refund( +def test_calldata_floor_charged_to_sender( blockchain_test: BlockchainTestFiller, pre: Alloc, fork: Fork, ) -> None: """ - Verify the calldata floor is applied to the sender gas refund. + Verify the calldata floor is what the sender pays for. With a STOP callee and large all-nonzero calldata, execution gas falls below the calldata floor. The sender must be charged @@ -231,9 +337,14 @@ def test_calldata_floor_applied_to_sender_refund( gas_limit_cap = fork.transaction_gas_limit_cap() assert gas_limit_cap is not None calldata = b"\xff" * 1024 - calldata_floor = fork.transaction_intrinsic_cost_calculator()( + calldata_floor = fork.transaction_data_floor_cost_calculator()( + data=calldata, + ) + execution = fork.transaction_intrinsic_cost_calculator()( calldata=calldata, + return_cost_deducted_prior_execution=True, ) + assert execution < calldata_floor, "calldata floor must bind" gas_price = 10**9 initial = gas_limit_cap * gas_price diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_multi_block.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_multi_block.py index b8d0bd9d140..23b4fdb9172 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_multi_block.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_multi_block.py @@ -22,6 +22,7 @@ Alloc, Block, BlockchainTestFiller, + Environment, Fork, Op, Storage, @@ -120,13 +121,19 @@ def test_multi_block_mixed_state_operations( on any path breaks the fill. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() - child_budget = 500_000 + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()() + priority_fee = 1 + child_gas = 500_000 reverting_child_code = Op.SSTORE(0, 1) + Op.SSTORE(1, 1) + Op.REVERT(0, 0) reverting_child = pre.deploy_contract(code=reverting_child_code) - halting_child_code = Op.SSTORE(0, 1) + Op.SSTORE(1, 1) + Op.INVALID - halting_child = pre.deploy_contract(code=halting_child_code) + + halting_child = pre.deploy_contract( + code=(Op.SSTORE(0, 1) + Op.SSTORE(1, 1) + Op.INVALID), + ) + # Every transaction spends its whole bill on gas, and the coinbase + # takes `priority_fee` per unit of it. + block_gas_used = [0, 0, 0] all_contracts = [] all_storages = [] @@ -135,15 +142,16 @@ def test_multi_block_mixed_state_operations( block1_txs = [] for i in range(2): storage = Storage() - code = Op.SSTORE(storage.store_next(1), 1) - contract = pre.deploy_contract(code=code) + contract_code = Op.SSTORE(storage.store_next(1), 1) + contract = pre.deploy_contract(code=contract_code) + tx_gas_used = intrinsic_gas + contract_code.gas_cost(fork) + block_gas_used[0] += tx_gas_used all_contracts.append(contract) all_storages.append(storage) - tx_gas_used = intrinsic_cost + code.gas_cost(fork) block1_txs.append( Transaction( to=contract, - state_gas_reservoir=sstore_state_gas, + state_gas_reservoir=contract_code.state_cost(fork), max_priority_fee_per_gas=1, max_fee_per_gas=8, sender=pre.fund_eoa(), @@ -158,22 +166,23 @@ def test_multi_block_mixed_state_operations( for i in range(2): storage = Storage() parent_code = Op.POP( - Op.CALL(gas=child_budget, address=reverting_child) + Op.CALL(gas=child_gas, address=reverting_child) ) + Op.SSTORE(storage.store_next(1), 1) parent = pre.deploy_contract(code=parent_code) - all_contracts.append(parent) - all_storages.append(storage) # The reverted child refunds its state gas and returns its # unspent budget, so only its execution gas is consumed. tx_gas_used = ( - intrinsic_cost + intrinsic_gas + parent_code.gas_cost(fork) + reverting_child_code.execution_cost(fork) ) + block_gas_used[1] += tx_gas_used + all_contracts.append(parent) + all_storages.append(storage) block2_txs.append( Transaction( to=parent, - state_gas_reservoir=sstore_state_gas, + state_gas_reservoir=parent_code.state_cost(fork), max_priority_fee_per_gas=1, max_fee_per_gas=8, sender=pre.fund_eoa(), @@ -188,15 +197,14 @@ def test_multi_block_mixed_state_operations( for i in range(2): storage = Storage() parent_code = Op.POP( - Op.CALL(gas=child_budget, address=halting_child) + Op.CALL(gas=child_gas, address=halting_child) ) + Op.SSTORE(storage.store_next(1), 1) parent = pre.deploy_contract(code=parent_code) + # The halted child burns its whole budget, spill included. + tx_gas_used = intrinsic_gas + parent_code.gas_cost(fork) + child_gas + block_gas_used[2] += tx_gas_used all_contracts.append(parent) all_storages.append(storage) - # The halted child burns its whole budget, spill included. - tx_gas_used = ( - intrinsic_cost + parent_code.gas_cost(fork) + child_budget - ) block3_txs.append( Transaction( to=parent, @@ -215,10 +223,12 @@ def test_multi_block_mixed_state_operations( Block(txs=block2_txs), Block(txs=block3_txs), ] - post = { + post: dict = { c: Account(storage=s) for c, s in zip(all_contracts, all_storages, strict=False) } + fee_recipient = Environment().fee_recipient + post[fee_recipient] = Account(balance=sum(block_gas_used) * priority_fee) blockchain_test(pre=pre, blocks=blocks, post=post) @@ -246,32 +256,43 @@ def test_multi_block_observed_coinbase_balance( Tx 4: Store `BALANCE(COINBASE)` in slot 0. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()() + priority_fee = 1 + child_gas = 500_000 - reporter1 = pre.deploy_contract( - code=(Op.SSTORE(0, Op.BALANCE(Op.COINBASE))), - ) - reporter2 = pre.deploy_contract( - code=(Op.SSTORE(0, Op.BALANCE(Op.COINBASE))), - ) + reporter_code = Op.SSTORE(0, Op.BALANCE(Op.COINBASE, address_warm=True)) + reporter1 = pre.deploy_contract(code=reporter_code) + reporter2 = pre.deploy_contract(code=reporter_code) + reporter_gas = intrinsic_gas + reporter_code.gas_cost(fork) # Block 1 tx 1: simple SSTORE sstore_storage = Storage() - sstore_contract = pre.deploy_contract( - code=(Op.SSTORE(sstore_storage.store_next(1), 1)), + sstore_code = Op.SSTORE(sstore_storage.store_next(1), 1) + sstore_contract = pre.deploy_contract(code=sstore_code) + sstore_tx_gas = ( + intrinsic_gas + sstore_code.execution_cost(fork) + sstore_state_gas ) # Block 2 tx 3: child spill + revert, parent SSTORE - reverting_child = pre.deploy_contract( - code=(Op.SSTORE(0, 1) + Op.SSTORE(1, 1) + Op.REVERT(0, 0)), - ) + reverting_child_code = Op.SSTORE(0, 1) + Op.SSTORE(1, 1) + Op.REVERT(0, 0) + reverting_child = pre.deploy_contract(code=reverting_child_code) spill_storage = Storage() - spill_parent = pre.deploy_contract( - code=( - Op.POP(Op.CALL(gas=500_000, address=reverting_child)) - + Op.SSTORE(spill_storage.store_next(1), 1) - ), + spill_code = Op.POP( + Op.CALL(gas=child_gas, address=reverting_child) + ) + Op.SSTORE(spill_storage.store_next(1), 1) + spill_parent = pre.deploy_contract(code=spill_code) + + spill_tx_gas = ( + intrinsic_gas + + spill_code.gas_cost(fork) + + reverting_child_code.execution_cost(fork) ) + reporter1_observes = sstore_tx_gas * priority_fee + reporter2_observes = ( + sstore_tx_gas + reporter_gas + spill_tx_gas + ) * priority_fee + blocks = [ Block( txs=[ @@ -310,9 +331,10 @@ def test_multi_block_observed_coinbase_balance( ] ), ] - post = { sstore_contract: Account(storage=sstore_storage), spill_parent: Account(storage=spill_storage), + reporter1: Account(storage={0: reporter1_observes}), + reporter2: Account(storage={0: reporter2_observes}), } blockchain_test(pre=pre, blocks=blocks, post=post) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_ordering.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_ordering.py index 03fc036f747..c519a2d353e 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_ordering.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_ordering.py @@ -15,16 +15,12 @@ import pytest from execution_testing import ( - Account, Alloc, Block, BlockchainTestFiller, Fork, Header, - Initcode, Op, - StateTestFiller, - Storage, Transaction, ) @@ -36,287 +32,6 @@ WORD_SIZE = 32 -def _single_sstore_probe_gas(fork: Fork) -> int: - """ - Return the gas for a single-SSTORE probe that OOGs by 1 when the - reservoir is 0 but succeeds when the reservoir holds any state gas. - - The probe bytecode is Op.SSTORE(0, 1): two pushes + SSTORE. - """ - return Op.SSTORE(0, 1).gas_cost(fork) - 1 - - -@pytest.mark.valid_from("EIP8037") -def test_sstore_oog_reservoir_inflation_detection( - state_test: StateTestFiller, - pre: Alloc, - fork: Fork, -) -> None: - """ - Detect SSTORE state gas ordering via reservoir inflation. - - A factory does CREATE + SSTORE where SSTORE OOGs (1 gas short). - After factory failure, the parent's reservoir should contain only - CREATE's state gas. A probe contract tests this by doing 4 SSTOREs - that need more total state gas than the correct reservoir but less - than the inflated one. - - With correct ordering (execution gas first): probe OOGs on 4th SSTORE. - With wrong ordering (state gas first): reservoir is inflated, - probe succeeds. - """ - initcode = Initcode(deploy_code=Op.STOP) - initcode_len = len(initcode) - - factory_code = Op.CALLDATACOPY( - 0, - 0, - Op.CALLDATASIZE, - data_size=initcode_len, - new_memory_size=initcode_len, - ) + Op.SSTORE( - 0, - Op.CREATE( - value=0, - offset=0, - size=Op.CALLDATASIZE, - init_code_size=initcode_len, - ), - ) - factory = pre.deploy_contract(factory_code) - - factory_gas = ( - factory_code.gas_cost(fork) - + initcode.evm_gas(fork) - + initcode.deployment_gas(fork) - ) - - # Probe: 4 SSTOREs to cold slots. Total state gas exceeds the - # correct reservoir (CREATE state gas only) but fits within the - # inflated reservoir (CREATE + SSTORE state gas). - probe = pre.deploy_contract( - Op.SSTORE(0, 1) + Op.SSTORE(1, 1) + Op.SSTORE(2, 1) + Op.SSTORE(3, 1) - ) - - # Compute probe gas: enough for 4 SSTOREs' execution gas + pushes, - # but after 4th execution charge, gas_left < the state gas spill. - sstore_state = Op.SSTORE(new_value=1).state_cost(fork) - sstore_execution = Op.SSTORE(0, 1).execution_cost(fork) - create_state_gas = fork.create_state_gas( - code_size=len(initcode.deploy_code) - ) - spill = 4 * sstore_state - create_state_gas - probe_gas = 4 * sstore_execution + spill // 2 - - caller_storage = Storage() - caller = pre.deploy_contract( - Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) - + Op.POP( - Op.CALL( - gas=factory_gas - 1, - address=factory, - value=0, - args_offset=0, - args_size=Op.CALLDATASIZE, - ret_offset=0, - ret_size=0, - ) - ) - + Op.SSTORE( - caller_storage.store_next(0, "probe_must_fail"), - Op.CALL(gas=probe_gas, address=probe), - ) - ) - - sender = pre.fund_eoa() - tx = Transaction( - sender=sender, - to=caller, - data=bytes(initcode), - state_gas_reservoir=0, - ) - - post = { - caller: Account(storage=caller_storage), - } - - state_test(pre=pre, tx=tx, post=post) - - -@pytest.mark.valid_from("EIP8037") -def test_call_oog_reservoir_inflation_detection( - state_test: StateTestFiller, - pre: Alloc, - fork: Fork, -) -> None: - """ - Detect CALL state gas ordering via reservoir inflation. - - A child does CALL(value=1) to a dead address with gas tuned so - the execution gas charge OOGs by 1. If state gas (new account) is - incorrectly charged first, the parent's reservoir is inflated. - - A single-SSTORE probe detects the inflation: with correct reservoir - (0) it OOGs; with inflated reservoir it succeeds. - """ - dead_address = 0xDEAD - child_code = Op.CALL( - gas=0, - address=dead_address, - value=1, - args_offset=0, - args_size=0, - ret_offset=0, - ret_size=0, - value_transfer=True, - account_new=True, - ) - # One gas short of the CALL's full cost (execution plus the NEW_ACCOUNT - # state charge), so it OOGs on the account-creation charge. - child_gas = child_code.gas_cost(fork) - 1 - child = pre.deploy_contract(child_code) - - probe = pre.deploy_contract(Op.SSTORE(0, 1)) - probe_gas = _single_sstore_probe_gas(fork) - - caller_storage = Storage() - caller = pre.deploy_contract( - Op.POP(Op.CALL(gas=child_gas, address=child)) - + Op.SSTORE( - caller_storage.store_next(0, "probe_must_fail"), - Op.CALL(gas=probe_gas, address=probe), - ) - ) - - sender = pre.fund_eoa() - tx = Transaction( - sender=sender, - to=caller, - state_gas_reservoir=0, - ) - - post = {caller: Account(storage=caller_storage)} - state_test(pre=pre, tx=tx, post=post) - - -@pytest.mark.valid_from("EIP8037") -def test_selfdestruct_oog_reservoir_inflation_detection( - state_test: StateTestFiller, - pre: Alloc, - fork: Fork, -) -> None: - """ - Detect SELFDESTRUCT state gas ordering via reservoir inflation. - - A child with non-zero balance does SELFDESTRUCT(dead_beneficiary) - with gas tuned so the execution gas charge OOGs by 1. If state gas - is incorrectly charged first, the parent's reservoir is inflated. - - Single-SSTORE probe detects the inflation. - """ - dead_beneficiary = 0xBEEF - child_code = Op.SELFDESTRUCT(dead_beneficiary, account_new=True) - # One gas short of the SELFDESTRUCT's full cost (execution plus the - # NEW_ACCOUNT state charge), so it OOGs on the account-creation charge. - child_gas = child_code.gas_cost(fork) - 1 - child = pre.deploy_contract(child_code, balance=1) - - probe = pre.deploy_contract(Op.SSTORE(0, 1)) - probe_gas = _single_sstore_probe_gas(fork) - - caller_storage = Storage() - caller = pre.deploy_contract( - Op.POP(Op.CALL(gas=child_gas, address=child)) - + Op.SSTORE( - caller_storage.store_next(0, "probe_must_fail"), - Op.CALL(gas=probe_gas, address=probe), - ) - ) - - sender = pre.fund_eoa() - tx = Transaction( - sender=sender, - to=caller, - state_gas_reservoir=0, - ) - - post = {caller: Account(storage=caller_storage)} - state_test(pre=pre, tx=tx, post=post) - - -@pytest.mark.parametrize( - "oog_step", - [ - pytest.param("create_base", id="oog_on_create_base"), - pytest.param("init_code_word_cost", id="oog_on_init_code_word_cost"), - ], -) -@pytest.mark.with_all_create_opcodes() -@pytest.mark.valid_from("EIP8037") -def test_create_oog_reservoir_inflation_detection( - state_test: StateTestFiller, - pre: Alloc, - fork: Fork, - create_opcode: Op, - oog_step: str, -) -> None: - """ - Detect CREATE/CREATE2 state-gas ordering via parent-reservoir - inflation. Two OOG boundaries are exercised: `oog_on_create_base` - (empty initcode) and `oog_on_init_code_word_cost` (32-byte - initcode). - """ - if oog_step == "create_base": - initcode_size = 0 - else: - initcode_size = WORD_SIZE - - if create_opcode == Op.CREATE: - create_op = create_opcode( - value=0, offset=0, size=initcode_size, init_code_size=initcode_size - ) - else: - create_op = create_opcode( - value=0, - offset=0, - size=initcode_size, - salt=0, - init_code_size=initcode_size, - ) - - if oog_step == "create_base": - child_code = create_op - else: - child_code = Op.MSTORE(0, 0, new_memory_size=WORD_SIZE) + create_op - - # One gas short of the CREATE's full cost (execution plus the NEW_ACCOUNT - # state charge), so it OOGs on the account-creation charge. - child_gas = child_code.gas_cost(fork) - 1 - child = pre.deploy_contract(child_code) - - probe = pre.deploy_contract(Op.SSTORE(0, 1)) - probe_gas = _single_sstore_probe_gas(fork) - - caller_storage = Storage() - caller = pre.deploy_contract( - Op.POP(Op.CALL(gas=child_gas, address=child)) - + Op.SSTORE( - caller_storage.store_next(0, "probe_must_fail"), - Op.CALL(gas=probe_gas, address=probe), - ) - ) - - sender = pre.fund_eoa() - tx = Transaction( - sender=sender, - to=caller, - state_gas_reservoir=0, - ) - - post = {caller: Account(storage=caller_storage)} - state_test(pre=pre, tx=tx, post=post) - - @pytest.mark.parametrize( "oog_step", [ diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_pricing.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_pricing.py index e05dc3d9c20..daca0c594d9 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_pricing.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_pricing.py @@ -21,6 +21,8 @@ Address, Alloc, AuthorizationTuple, + Bytecode, + CodeGasMeasure, Environment, Fork, Header, @@ -29,6 +31,7 @@ Storage, Transaction, TransactionException, + TransactionReceipt, ) from execution_testing.checklists import EIPChecklist @@ -67,15 +70,35 @@ def test_pricing_at_various_gas_limits( independent of block gas limit. At each block size, an SSTORE zero-to-nonzero should succeed when given sufficient total gas. """ - gas_limit_cap = fork.transaction_gas_limit_cap() - assert gas_limit_cap is not None + storage = Storage() + code = Op.SSTORE( + storage.store_next(1), + 1, + # gas accounting + original_value=0, + new_value=1, + ) env = Environment(gas_limit=block_gas_limit) - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - tx_gas = min(gas_limit_cap + sstore_state_gas, block_gas_limit) - storage = Storage() + gas_limit = ( + fork.transaction_intrinsic_cost_calculator()() + + fork.transaction_top_frame_gas_calculator()() + + code.gas_cost(fork) + ) + + tx_gas = min(gas_limit, block_gas_limit) + contract = pre.deploy_contract( - code=Op.SSTORE(storage.store_next(1), 1), + code=code, + ) + + # The state charge does not scale with the block gas limit, so the + # header reports the same `gas_used` at 1M and at 1G. + state_gas = code.state_cost(fork) + execution_gas = gas_limit - state_gas + expected_gas_used = max(execution_gas, state_gas) + assert expected_gas_used == state_gas, ( + "expected state gas to dominate execution gas" ) tx = Transaction( @@ -85,53 +108,82 @@ def test_pricing_at_various_gas_limits( ) post = {contract: Account(storage=storage)} - state_test(env=env, pre=pre, post=post, tx=tx) + state_test( + env=env, + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) +@pytest.mark.parametrize( + "gas_delta", + [pytest.param(0, id="exact_fit"), pytest.param(-1, id="one_short")], +) @pytest.mark.valid_from("EIP8037") def test_charge_draws_entirely_from_reservoir( state_test: StateTestFiller, pre: Alloc, fork: Fork, + gas_delta: int, ) -> None: """ Test state gas is drawn entirely from the reservoir. - When the reservoir has enough gas for the SSTORE state cost, - gas_left should not be reduced by the state charge. Verify by - performing an execution-gas-heavy computation after the SSTORE. + The inner frame is handed exactly its SSTORE's execution cost, so + it has no `gas_left` to spill from and the state charge must come + entirely from the reservoir. An exact reservoir succeeds; one gas + short halts the frame. """ - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + inner_code = Op.SSTORE(0, 1) + inner = pre.deploy_contract(code=inner_code) + inner_gas = inner_code.execution_cost(fork) + succeeds = gas_delta == 0 storage = Storage() + + slot = storage.store_next(int(succeeds), "inner_succeeded") contract = pre.deploy_contract( - code=( - # SSTORE draws state gas from reservoir - Op.SSTORE(storage.store_next(1), 1) - # Remaining gas_left is available for execution ops - + Op.SSTORE( - storage.store_next(1), - Op.ADD(1, 0), # Cheap execution-gas op - ) + code=Op.SSTORE( + slot, + Op.CALL(gas=inner_gas, address=inner), + # gas accounting + original_value=1, + current_value=1, + new_value=int(succeeds), + key_warm=False, ), + storage={slot: 1}, ) - # Provide exact state gas in the reservoir tx = Transaction( to=contract, - state_gas_reservoir=sstore_state_gas * 2, + state_gas_reservoir=inner_code.state_cost(fork) + gas_delta, sender=pre.fund_eoa(), ) - post = {contract: Account(storage=storage)} + post = { + contract: Account(storage=storage), + inner: Account(storage={0: int(succeeds)}), + } state_test(pre=pre, post=post, tx=tx) +@pytest.mark.parametrize( + "reservoir_fraction", + [ + pytest.param(0, id="all_spilled"), + pytest.param(2, id="half_spilled"), + pytest.param(1, id="none_spilled"), + ], +) @pytest.mark.valid_from("EIP8037") def test_charge_spills_to_gas_left( state_test: StateTestFiller, pre: Alloc, fork: Fork, + reservoir_fraction: int, ) -> None: """ Test state gas spills from reservoir to gas_left. @@ -140,22 +192,43 @@ def test_charge_spills_to_gas_left( state charge, the remainder is taken from gas_left. The SSTORE should still succeed. """ - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + measured_code = Op.SSTORE( + 1, + 1, + # gas accounting + original_value=0, + current_value=0, + new_value=1, + key_warm=False, + ) + state_gas = measured_code.state_cost(fork) + execution_gas = measured_code.execution_cost(fork) - storage = Storage() + reservoir = state_gas // reservoir_fraction if reservoir_fraction else 0 + spill = state_gas - reservoir + + # Slot 0 already holds a value, so writing the measurement into it + # is a nonzero write that adds no state gas of its own. + result_slot = 0 contract = pre.deploy_contract( - code=Op.SSTORE(storage.store_next(1), 1), + code=CodeGasMeasure(code=measured_code, sstore_key=result_slot), + storage={result_slot: 1}, ) - # Provide half the state gas in the reservoir, rest from gas_left - half_state_gas = sstore_state_gas // 2 tx = Transaction( to=contract, - state_gas_reservoir=half_state_gas, + state_gas_reservoir=reservoir, sender=pre.fund_eoa(), ) - post = {contract: Account(storage=storage)} + post = { + contract: Account( + storage={ + result_slot: execution_gas + spill, + 1: 1, + } + ) + } state_test(pre=pre, post=post, tx=tx) @@ -179,13 +252,19 @@ def test_charge_spill_boundary( the block bills it as state gas; one gas short, neither pool can cover the charge and the frame runs out of gas with the slot unset. """ - code = Op.SSTORE(0, 1) + code = Op.SSTORE( + 0, + 1, + # gas accounting + original_value=0, + new_value=1, + ) contract = pre.deploy_contract(code=code) intrinsic = fork.transaction_intrinsic_cost_calculator()() execution = code.execution_cost(fork) - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - gas_limit = intrinsic + execution + sstore_state_gas + gas_delta + state = code.state_cost(fork) + gas_limit = intrinsic + execution + state + gas_delta tx = Transaction( to=contract, @@ -194,7 +273,7 @@ def test_charge_spill_boundary( ) header = Header( - gas_used=max(intrinsic + execution, sstore_state_gas) + gas_used=max(intrinsic + execution, state) if gas_delta == 0 else gas_limit ) @@ -207,34 +286,92 @@ def test_charge_spill_boundary( @EIPChecklist.GasRefundsChanges.Test.RefundCalculation() +@pytest.mark.parametrize( + "fund_from_reservoir", + [ + pytest.param(False, id="spilled_from_gas_left"), + pytest.param(True, id="drawn_from_reservoir"), + ], +) @pytest.mark.valid_from("EIP8037") def test_refund_cap_includes_state_gas( state_test: StateTestFiller, pre: Alloc, + fork: Fork, + fund_from_reservoir: bool, ) -> None: """ - Test the 1/5 refund cap includes state gas used from gas_left. + Test the 1/5 refund cap counts state gas whichever pool funds it. - When state gas is drawn from gas_left (no reservoir), it counts - toward tx_gas_used_before_refund. The 1/5 refund cap applies to - the combined total of execution + state gas consumed. This test - performs an SSTORE zero-to-nonzero-to-zero sequence to generate - a refund and verifies the transaction succeeds. + The cap applies to the combined execution plus state gas consumed, + and the gas used before the refund is the same whether the state + charge spills from `gas_left` or draws from the reservoir. Both + variants therefore expect the identical refund: one the execution + dimension alone would have capped short. """ + cleared_slots = 3 + set_slots = 3 + + storage = Storage() + code = Bytecode() + for _ in range(cleared_slots): + code += Op.SSTORE( + storage.store_next(0, "cleared"), + 0, + # gas accounting + original_value=1, + current_value=1, + new_value=0, + key_warm=False, + ) + for _ in range(set_slots): + code += Op.SSTORE( + storage.store_next(1, "set"), + 1, + # gas accounting + original_value=0, + current_value=0, + new_value=1, + key_warm=False, + ) contract = pre.deploy_contract( - code=(Op.SSTORE(0, 1) + Op.SSTORE(0, 0)), + code=code, + storage=dict.fromkeys(range(cleared_slots), 1), + ) + + state_gas = code.state_cost(fork) + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + code.execution_cost(fork) ) + before_refund = execution_gas + state_gas + refund = min(before_refund // 5, code.refund(fork)) + # Counting the state charge lifts the cap above the refund, + # While execution gas alone would cut it short. + assert refund == code.refund(fork) + assert execution_gas // fork.max_refund_quotient() < code.refund(fork) + + # Block accounting ignores refunds, so the header still reports the + # dominant pre-refund dimension. + expected_gas_used = max(execution_gas, state_gas) + assert expected_gas_used == state_gas - # No reservoir — all gas from gas_left, refund cap applies tx = Transaction( to=contract, - state_gas_reservoir=0, + state_gas_reservoir=state_gas if fund_from_reservoir else 0, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=before_refund - refund + ), ) - # Slot 0 restored to zero - post = {contract: Account(storage={0: 0})} - state_test(pre=pre, post=post, tx=tx) + post = {contract: Account(storage=storage)} + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) @EIPChecklist.GasRefundsChanges.Test.RefundCalculation() @@ -253,20 +390,37 @@ def test_refund_with_reservoir_state_gas( both dimensions. An SSTORE zero-to-nonzero-to-zero sequence should refund correctly. """ - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + kept = Op.SSTORE(0, 1, original_value=0, current_value=0, new_value=1) + restored = Op.SSTORE( + 1, 1, original_value=0, current_value=0, new_value=1 + ) + Op.SSTORE( + 1, 0, key_warm=True, original_value=0, current_value=1, new_value=0 + ) + code = kept + restored + contract = pre.deploy_contract(code=code) - contract = pre.deploy_contract( - code=(Op.SSTORE(0, 1) + Op.SSTORE(0, 0)), + net_state_gas = code.state_cost(fork) - code.state_refund(fork) + refund_counter = code.refund(fork) - code.state_refund(fork) + gas_used_before_refund = ( + fork.transaction_intrinsic_cost_calculator()() + + fork.transaction_top_frame_gas_calculator()(contract_creation=False) + + code.execution_cost(fork) + + net_state_gas + ) + refund = min( + gas_used_before_refund // fork.max_refund_quotient(), refund_counter ) tx = Transaction( to=contract, - state_gas_reservoir=sstore_state_gas, + state_gas_reservoir=code.state_cost(fork), sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=gas_used_before_refund - refund + ), ) - # Slot 0 restored to zero - post = {contract: Account(storage={0: 0})} + post = {contract: Account(storage={0: 1, 1: 0})} state_test(pre=pre, post=post, tx=tx) @@ -524,13 +678,20 @@ def test_create_state_gas_scales_with_cpsb( create_state_gas = fork.create_state_gas(code_size=1) storage = Storage() - contract = pre.deploy_contract( - code=( - Op.SSTORE( - storage.store_next(1, "create_success"), - Op.GT(Op.CREATE(0, 0, 1), 0), - ) - ), + contract_code = Op.SSTORE( + storage.store_next(1, "create_success"), + Op.GT(Op.CREATE(0, 0, 1), 0), + ) + contract = pre.deploy_contract(code=contract_code) + + state_gas = contract_code.state_cost(fork) + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + contract_code.execution_cost(fork) + ) + expected_gas_used = max(execution_gas, state_gas) + assert expected_gas_used == state_gas, ( + "expected state gas to dominate execution gas" ) tx_gas = min(gas_limit_cap + create_state_gas, block_gas_limit) @@ -541,7 +702,13 @@ def test_create_state_gas_scales_with_cpsb( ) post = {contract: Account(storage=storage)} - state_test(env=env, pre=pre, post=post, tx=tx) + state_test( + env=env, + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) @pytest.mark.parametrize("block_gas_limit", BLOCK_GAS_LIMITS) @@ -570,9 +737,19 @@ def test_call_new_account_state_gas_scales_with_cpsb( account_new=True, ) storage = Storage() - contract = pre.deploy_contract( - code=Op.SSTORE(storage.store_next(1, "call_success"), call), - balance=1, + contract_code = Op.SSTORE(storage.store_next(1, "call_success"), call) + contract = pre.deploy_contract(code=contract_code, balance=1) + + state_gas = contract_code.state_cost(fork) + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + contract_code.execution_cost(fork) + # The empty target returns the value-call stipend unused. + - fork.call_value_stipend() + ) + expected_gas_used = max(execution_gas, state_gas) + assert expected_gas_used == state_gas, ( + "expected state gas to dominate execution gas" ) tx_gas = min(gas_limit_cap + call.state_cost(fork), block_gas_limit) @@ -583,7 +760,13 @@ def test_call_new_account_state_gas_scales_with_cpsb( ) post = {contract: Account(storage=storage)} - state_test(env=env, pre=pre, post=post, tx=tx) + state_test( + env=env, + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) @pytest.mark.parametrize("block_gas_limit", BLOCK_GAS_LIMITS) @@ -607,15 +790,19 @@ def test_selfdestruct_new_beneficiary_scales_with_cpsb( beneficiary = pre.fund_eoa(0) storage = Storage() - caller = pre.deploy_contract( - code=( - Op.SSTORE( - storage.store_next(1, "selfdestruct_ran"), - 1, - ) - + Op.SELFDESTRUCT(beneficiary) - ), - balance=1, + caller_code = Op.SSTORE( + storage.store_next(1, "selfdestruct_ran"), 1 + ) + Op.SELFDESTRUCT(beneficiary, account_new=True) + caller = pre.deploy_contract(code=caller_code, balance=1) + + state_gas = caller_code.state_cost(fork) + execution_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + caller_code.execution_cost(fork) + ) + expected_gas_used = max(execution_gas, state_gas) + assert expected_gas_used == state_gas, ( + "expected state gas to dominate execution gas" ) tx_gas = min(gas_limit_cap + new_account_state_gas, block_gas_limit) @@ -626,7 +813,13 @@ def test_selfdestruct_new_beneficiary_scales_with_cpsb( ) post = {caller: Account(storage=storage)} - state_test(env=env, pre=pre, post=post, tx=tx) + state_test( + env=env, + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) @pytest.mark.parametrize("block_gas_limit", BLOCK_GAS_LIMITS) @@ -648,8 +841,42 @@ def test_sstore_refund_scales_with_cpsb( env = Environment(gas_limit=block_gas_limit) sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - contract = pre.deploy_contract( - code=(Op.SSTORE(0, 1) + Op.SSTORE(0, 0)), + code = Op.SSTORE( + 0, + 1, + # gas accounting + original_value=0, + current_value=0, + new_value=1, + key_warm=False, + ) + Op.SSTORE( + 0, + 0, + # gas accounting + key_warm=True, + original_value=0, + current_value=1, + new_value=0, + ) + contract = pre.deploy_contract(code=code) + + # Restoring the slot hands the whole state charge straight back to + # its own dimension, so none of it is billed; only the execution + # refund passes through the one fifth cap. + net_state_gas = code.state_cost(fork) - code.state_refund(fork) + assert net_state_gas == 0 + + refund_counter = code.refund(fork) - code.state_refund(fork) + + gas_used_before_refund = ( + fork.transaction_intrinsic_cost_calculator()() + + fork.transaction_top_frame_gas_calculator()(contract_creation=False) + + code.execution_cost(fork) + + net_state_gas + ) + + refund = min( + gas_used_before_refund // fork.max_refund_quotient(), refund_counter ) tx_gas = min(gas_limit_cap + sstore_state_gas, block_gas_limit) @@ -657,6 +884,9 @@ def test_sstore_refund_scales_with_cpsb( to=contract, gas_limit=tx_gas, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=gas_used_before_refund - refund + ), ) post = {contract: Account(storage={0: 0})} @@ -708,11 +938,21 @@ def test_auth_state_gas_scales_with_cpsb( ) storage = Storage() - target = pre.deploy_contract( - code=Op.SSTORE( - storage.store_next(1, "delegated_call_success"), - Op.CALL(gas=100_000, address=signer), - ), + target_code = Op.SSTORE( + storage.store_next(1, "delegated_call_success"), + Op.CALL(gas=100_000, address=signer, delegated_address=True), + ) + + target = pre.deploy_contract(code=target_code) + + state_gas = auth_state_gas + target_code.state_cost(fork) + execution_gas = fork.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=authorization_list + ) + target_code.execution_cost(fork) + + expected_gas_used = max(execution_gas, state_gas) + assert expected_gas_used == state_gas, ( + "expected state gas to dominate execution gas" ) tx_gas = min(gas_limit_cap + auth_state_gas, block_gas_limit) @@ -725,4 +965,10 @@ def test_auth_state_gas_scales_with_cpsb( ) post = {target: Account(storage=storage)} - state_test(env=env, pre=pre, post=post, tx=tx) + state_test( + env=env, + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_sstore.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_sstore.py index 59f8422bce2..782292a8ec9 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_sstore.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_sstore.py @@ -20,6 +20,7 @@ Block, BlockchainTestFiller, Bytecode, + CodeGasMeasure, Conditional, Fork, Header, @@ -31,17 +32,28 @@ ) from execution_testing.checklists import EIPChecklist -from .spec import ref_spec_8037 +from .spec import init_code_at_high_bytes, ref_spec_8037 REFERENCE_SPEC_GIT_PATH = ref_spec_8037.git_path REFERENCE_SPEC_VERSION = ref_spec_8037.version +def sender_gas_used(fork: Fork, pre_refund_gas: int, code: Bytecode) -> int: + """ + Return the sender's bill for a transaction whose top frame ran `code`. + """ + execution_refund = code.refund(fork) - code.state_refund(fork) + return pre_refund_gas - min( + pre_refund_gas // fork.max_refund_quotient(), execution_refund + ) + + @EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() @pytest.mark.valid_from("EIP8037") def test_sstore_zero_to_nonzero( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """ Test SSTORE zero-to-nonzero charges state gas. @@ -51,51 +63,82 @@ def test_sstore_zero_to_nonzero( in addition to execution gas. """ storage = Storage() - contract = pre.deploy_contract( - code=Op.SSTORE(storage.store_next(1), 1), + code = Op.SSTORE(storage.store_next(1), 1) + state_gas = code.state_cost(fork) + tx_execution = ( + fork.transaction_intrinsic_cost_calculator()() + + code.execution_cost(fork) ) + assert state_gas > tx_execution, "state dimension must set the header" + + contract = pre.deploy_contract(code=code) tx = Transaction( to=contract, state_gas_reservoir=0, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=tx_execution + state_gas + ), ) - post = {contract: Account(storage=storage)} - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post={contract: Account(storage=storage)}, + tx=tx, + blockchain_test_header_verify=Header(gas_used=state_gas), + ) @pytest.mark.valid_from("EIP8037") def test_sstore_nonzero_to_nonzero( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """ Test SSTORE nonzero-to-nonzero charges no state gas. Updating a slot that already holds a nonzero value to a different - nonzero value does not create new state, so no state gas is charged. + nonzero value does not create new state, so no state gas is charged + and the header reports the execution dimension alone. """ storage = Storage() - contract = pre.deploy_contract( - code=Op.SSTORE(storage.store_next(2), 2), - storage={0: 1}, + code = Op.SSTORE( + storage.store_next(2), + 2, + original_value=1, + current_value=1, + new_value=2, + ) + assert code.state_cost(fork) == 0, "no state growth, no state gas" + tx_execution = ( + fork.transaction_intrinsic_cost_calculator()() + + code.execution_cost(fork) ) + contract = pre.deploy_contract(code=code, storage={0: 1}) + tx = Transaction( to=contract, state_gas_reservoir=0, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt(cumulative_gas_used=tx_execution), ) - post = {contract: Account(storage=storage)} - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post={contract: Account(storage=storage)}, + tx=tx, + blockchain_test_header_verify=Header(gas_used=tx_execution), + ) @pytest.mark.valid_from("EIP8037") def test_sstore_nonzero_to_zero( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """ Test SSTORE nonzero-to-zero charges no state gas. @@ -104,25 +147,44 @@ def test_sstore_nonzero_to_zero( earns an execution gas refund (GAS_STORAGE_CLEAR_REFUND). """ storage = Storage() - contract = pre.deploy_contract( - code=Op.SSTORE(storage.store_next(0), 0), - storage={0: 1}, + code = Op.SSTORE( + storage.store_next(0), + 0, + original_value=1, + current_value=1, + new_value=0, + ) + assert code.state_cost(fork) == 0, "clearing a slot grows no state" + assert code.refund(fork) > 0, "clearing a slot must earn a refund" + pre_refund_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + code.execution_cost(fork) ) + contract = pre.deploy_contract(code=code, storage={0: 1}) + tx = Transaction( to=contract, state_gas_reservoir=0, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=sender_gas_used(fork, pre_refund_gas, code) + ), ) - post = {contract: Account(storage=storage)} - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post={contract: Account(storage=storage)}, + tx=tx, + blockchain_test_header_verify=Header(gas_used=pre_refund_gas), + ) @pytest.mark.valid_from("EIP8037") def test_sstore_zero_to_zero( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """ Test SSTORE zero-to-zero charges no state gas. @@ -131,18 +193,28 @@ def test_sstore_zero_to_zero( the warm access execution gas cost is charged. """ storage = Storage() - contract = pre.deploy_contract( - code=Op.SSTORE(storage.store_next(0), 0), + code = Op.SSTORE(storage.store_next(0), 0, new_value=0) + assert code.state_cost(fork) == 0, "a no-op write grows no state" + tx_execution = ( + fork.transaction_intrinsic_cost_calculator()() + + code.execution_cost(fork) ) + contract = pre.deploy_contract(code=code) + tx = Transaction( to=contract, state_gas_reservoir=0, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt(cumulative_gas_used=tx_execution), ) - post = {contract: Account(storage=storage)} - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post={contract: Account(storage=storage)}, + tx=tx, + blockchain_test_header_verify=Header(gas_used=tx_execution), + ) @pytest.mark.parametrize( @@ -241,6 +313,7 @@ def test_sstore_restoration_refund_credits_local_reservoir( def test_sstore_restoration_refund( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """ Test SSTORE zero-to-nonzero-to-zero restoration refunds state gas. @@ -250,25 +323,47 @@ def test_sstore_restoration_refund( (STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte) is refunded via refund_counter along with the execution gas write cost. """ - contract = pre.deploy_contract( - code=(Op.SSTORE(0, 1) + Op.SSTORE(0, 0)), + code = Op.SSTORE(0, 1) + Op.SSTORE( + 0, + 0, + # gas accounting + key_warm=True, + original_value=0, + current_value=1, + new_value=0, + ) + assert code.state_refund(fork) == code.state_cost(fork), ( + "the restoration must refund the whole state charge" + ) + pre_refund_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + code.execution_cost(fork) ) + contract = pre.deploy_contract(code=code) + tx = Transaction( to=contract, state_gas_reservoir=0, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=sender_gas_used(fork, pre_refund_gas, code) + ), ) - # Slot 0 restored to zero — state gas refunded - post = {contract: Account(storage={0: 0})} - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post={contract: Account(storage={0: 0})}, + tx=tx, + blockchain_test_header_verify=Header(gas_used=pre_refund_gas), + ) @pytest.mark.valid_from("EIP8037") def test_sstore_restoration_nonzero_no_state_refund( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """ Test nonzero-to-nonzero-to-original restoration has no state gas refund. @@ -277,46 +372,101 @@ def test_sstore_restoration_nonzero_no_state_refund( restoring it never involves state gas (no state growth occurred), so only execution gas refunds apply. """ - contract = pre.deploy_contract( - code=(Op.SSTORE(0, 2) + Op.SSTORE(0, 1)), - storage={0: 1}, + code = Op.SSTORE( + 0, + 2, + # gas accounting + original_value=1, + current_value=1, + new_value=2, + ) + Op.SSTORE( + 0, + 1, + # gas accounting + key_warm=True, + original_value=1, + current_value=2, + new_value=1, + ) + assert code.state_cost(fork) == 0, "a nonzero slot grows no state" + assert code.state_refund(fork) == 0, "no state charge, no state refund" + assert code.refund(fork) > 0, "the execution write cost is refunded" + pre_refund_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + code.execution_cost(fork) ) + contract = pre.deploy_contract(code=code, storage={0: 1}) + tx = Transaction( to=contract, state_gas_reservoir=0, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=sender_gas_used(fork, pre_refund_gas, code) + ), ) - post = {contract: Account(storage={0: 1})} - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post={contract: Account(storage={0: 1})}, + tx=tx, + blockchain_test_header_verify=Header(gas_used=pre_refund_gas), + ) @pytest.mark.valid_from("EIP8037") def test_sstore_clear_refund_reversal( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """ Test clearing a nonzero slot then un-clearing reverses the refund. When a slot with a nonzero original value is cleared (set to zero), the clear refund is granted. If the slot is then set back to a - nonzero value, the clear refund is reversed via refund_counter. + nonzero value, the clear refund is reversed via refund_counter, so + the sender pays the full pre-refund gas. """ - contract = pre.deploy_contract( - code=(Op.SSTORE(0, 0) + Op.SSTORE(0, 2)), - storage={0: 1}, + code = Op.SSTORE( + 0, + 0, + # gas accounting + original_value=1, + current_value=1, + new_value=0, + ) + Op.SSTORE( + 0, + 2, + # gas accounting + key_warm=True, + original_value=1, + current_value=0, + new_value=2, + ) + assert code.refund(fork) == 0, "the clear refund must be fully reversed" + assert code.state_cost(fork) == 0, "a nonzero slot grows no state" + tx_execution = ( + fork.transaction_intrinsic_cost_calculator()() + + code.execution_cost(fork) ) + contract = pre.deploy_contract(code=code, storage={0: 1}) + tx = Transaction( to=contract, state_gas_reservoir=0, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt(cumulative_gas_used=tx_execution), ) - post = {contract: Account(storage={0: 2})} - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post={contract: Account(storage={0: 2})}, + tx=tx, + blockchain_test_header_verify=Header(gas_used=tx_execution), + ) @pytest.mark.parametrize( @@ -331,28 +481,48 @@ def test_sstore_clear_refund_reversal( def test_sstore_multiple_slots( state_test: StateTestFiller, pre: Alloc, + fork: Fork, num_slots: int, ) -> None: """ Test multiple zero-to-nonzero SSTOREs each charge state gas. Each slot written from zero to nonzero independently charges - STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte of state gas. + STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte of state gas, so + the state dimension scales with the slot count. """ storage = Storage() code = Bytecode() for _ in range(num_slots): code += Op.SSTORE(storage.store_next(1), 1) + + state_gas = code.state_cost(fork) + assert state_gas == num_slots * Op.SSTORE(new_value=1).state_cost(fork), ( + "every slot must be charged independently" + ) + tx_execution = ( + fork.transaction_intrinsic_cost_calculator()() + + code.execution_cost(fork) + ) + assert state_gas > tx_execution, "state dimension must set the header" + contract = pre.deploy_contract(code=code) tx = Transaction( to=contract, state_gas_reservoir=0, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=tx_execution + state_gas + ), ) - post = {contract: Account(storage=storage)} - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post={contract: Account(storage=storage)}, + tx=tx, + blockchain_test_header_verify=Header(gas_used=state_gas), + ) @pytest.mark.valid_from("EIP8037") @@ -368,21 +538,38 @@ def test_sstore_state_gas_drawn_from_reservoir( SSTORE state gas from the reservoir, leaving gas_left untouched by the state gas charge. """ - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - - storage = Storage() - contract = pre.deploy_contract( - code=Op.SSTORE(storage.store_next(1), 1), + measured = Op.SSTORE(1, 1) + measured_execution = measured.execution_cost(fork) + sstore_state_gas = measured.state_cost(fork) + + # The recording SSTORE is itself a zero-to-nonzero set; its state + # gas spills into gas_left because the measured set drained the + # reservoir first. + code = CodeGasMeasure(code=measured, sstore_key=0) + state_gas = code.state_cost(fork) + tx_execution = ( + fork.transaction_intrinsic_cost_calculator()() + + code.execution_cost(fork) ) + assert state_gas > tx_execution, "state dimension must set the header" + + contract = pre.deploy_contract(code=code) tx = Transaction( to=contract, state_gas_reservoir=sstore_state_gas, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=tx_execution + state_gas + ), ) - post = {contract: Account(storage=storage)} - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post={contract: Account(storage={0: measured_execution, 1: 1})}, + tx=tx, + blockchain_test_header_verify=Header(gas_used=state_gas), + ) @pytest.mark.with_all_typed_transactions @@ -511,14 +698,19 @@ def test_sstore_restoration_block_state_gas_zero( code = Bytecode() for i in range(num_cycles): - code += Op.SSTORE(i, 1) + Op.SSTORE.with_metadata( + code += Op.SSTORE(i, 1) + Op.SSTORE( + i, + 0, + # gas accounting key_warm=True, original_value=0, current_value=1, new_value=0, - )(i, 0) - tx_execution = ( - intrinsic_gas + code.gas_cost(fork) - num_cycles * sstore_state_gas + ) + tx_execution = intrinsic_gas + code.execution_cost(fork) + + assert code.state_refund(fork) == num_cycles * sstore_state_gas, ( + "every cycle must refund its state charge" ) contract = pre.deploy_contract(code=code) @@ -526,6 +718,9 @@ def test_sstore_restoration_block_state_gas_zero( to=contract, state_gas_reservoir=num_cycles * sstore_state_gas, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=sender_gas_used(fork, tx_execution, code) + ), ) blockchain_test( @@ -536,10 +731,10 @@ def test_sstore_restoration_block_state_gas_zero( @pytest.mark.parametrize( - "num_cycles", + "num_cycles,state_dominates", [ - pytest.param(1, id="one_cycle"), - pytest.param(10, id="ten_cycles"), + pytest.param(1, True, id="one_cycle"), + pytest.param(10, False, id="ten_cycles"), ], ) @pytest.mark.valid_from("EIP8037") @@ -548,6 +743,7 @@ def test_sstore_restoration_mixed_with_genuine_sstore( pre: Alloc, fork: Fork, num_cycles: int, + state_dominates: bool, ) -> None: """ Verify restoration cycles plus a genuine 0 to x SSTORE. @@ -570,9 +766,8 @@ def test_sstore_restoration_mixed_with_genuine_sstore( code += Op.SSTORE(99, 1) num_0_to_1 = num_cycles + 1 - tx_execution = ( - intrinsic_gas + code.gas_cost(fork) - num_0_to_1 * sstore_state_gas - ) + tx_execution = intrinsic_gas + code.execution_cost(fork) + assert (sstore_state_gas > tx_execution) == state_dominates expected = max(tx_execution, sstore_state_gas) contract = pre.deploy_contract(code=code) @@ -580,6 +775,11 @@ def test_sstore_restoration_mixed_with_genuine_sstore( to=contract, state_gas_reservoir=num_0_to_1 * sstore_state_gas, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=sender_gas_used( + fork, tx_execution + sstore_state_gas, code + ) + ), ) post_storage = dict.fromkeys(range(num_cycles), 0) @@ -623,7 +823,7 @@ def test_sstore_restoration_intermediate_values( new_value=0, )(0, 0) ) - tx_execution = intrinsic_gas + code.gas_cost(fork) - sstore_state_gas + tx_execution = intrinsic_gas + code.execution_cost(fork) contract = pre.deploy_contract(code=code) tx = Transaction( @@ -670,7 +870,10 @@ def test_sstore_restoration_then_reset( new_value=1, )(0, 1) ) - tx_execution = intrinsic_gas + code.gas_cost(fork) - 2 * sstore_state_gas + tx_execution = intrinsic_gas + code.execution_cost(fork) + assert sstore_state_gas > tx_execution, ( + "the surviving state charge must set the header" + ) expected = max(tx_execution, sstore_state_gas) contract = pre.deploy_contract(code=code) @@ -678,6 +881,11 @@ def test_sstore_restoration_then_reset( to=contract, state_gas_reservoir=sstore_state_gas, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=sender_gas_used( + fork, tx_execution + sstore_state_gas, code + ) + ), ) blockchain_test( @@ -713,7 +921,8 @@ def test_sstore_restoration_reservoir_replenished_inline( )(0, 0) + Op.SSTORE(1, 1) ) - tx_execution = intrinsic_gas + code.gas_cost(fork) - 2 * sstore_state_gas + tx_execution = intrinsic_gas + code.execution_cost(fork) + assert sstore_state_gas > tx_execution, "state gas must dominates" expected = max(tx_execution, sstore_state_gas) contract = pre.deploy_contract(code=code) @@ -763,13 +972,15 @@ def test_sstore_restoration_cross_frame( + Op.STOP ) # Callee's execution gas excludes the state gas (refunded at x to 0). - child_execution = child_code.gas_cost(fork) - sstore_state_gas + child_execution = child_code.execution_cost(fork) child = pre.deploy_contract(code=child_code) parent_code = Op.POP(call_opcode(gas=child_execution, address=child)) parent = pre.deploy_contract(code=parent_code) - tx_execution = intrinsic_gas + parent_code.gas_cost(fork) + child_execution + tx_execution = ( + intrinsic_gas + parent_code.execution_cost(fork) + child_execution + ) tx = Transaction( to=parent, @@ -815,28 +1026,30 @@ def test_sstore_restoration_charge_in_ancestor( refund must propagate up the chain to the ancestor that charged the 0 to x. A probe SSTORE sized to OOG by 1 detects any loss. """ - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - probe_gas = Op.SSTORE(0, 1).gas_cost(fork) - 1 + code = Op.SSTORE(0, 1, new_value=1) + sstore_state_gas = code.state_cost(fork) + probe_gas = code.gas_cost(fork) - 1 # Innermost frame does x to 0; each hop above delegates down. delegate_target = pre.deploy_contract( code=( - Op.SSTORE.with_metadata( + Op.SSTORE( + 0, + 0, + # gas accounting key_warm=True, original_value=0, current_value=1, new_value=0, - )(0, 0) - + Op.STOP + ) ) ) for _ in range(num_hops - 1): delegate_target = pre.deploy_contract( code=Op.POP(call_opcode(gas=Op.GAS, address=delegate_target)) - + Op.STOP, ) - probe = pre.deploy_contract(code=Op.SSTORE(0, 1)) + probe = pre.deploy_contract(code=code) parent_storage = Storage() parent_code = ( @@ -1286,22 +1499,17 @@ def test_sstore_restoration_create_init_revert( init_code = Op.SSTORE(0, 1) + Op.SSTORE(0, 0) + Op.REVERT(0, 0) probe = pre.deploy_contract(code=Op.SSTORE(0, 1)) + mstore_value, init_code_size = init_code_at_high_bytes(init_code) if create_opcode == Op.CREATE: - create_call = Op.CREATE(0, 0, len(init_code)) + create_call = Op.CREATE(0, 0, init_code_size) else: - create_call = Op.CREATE2(0, 0, len(init_code), 0) + create_call = Op.CREATE2(0, 0, init_code_size, 0) # Inner contract performs the CREATE then REVERTs. inner = pre.deploy_contract( - code=( - Op.MSTORE( - 0, - int.from_bytes(bytes(init_code), "big") - << (256 - 8 * len(init_code)), - ) - + Op.POP(create_call) - + Op.REVERT(0, 0) - ), + code=Op.MSTORE(0, mstore_value) + + Op.POP(create_call) + + Op.REVERT(0, 0), ) caller_storage = Storage() @@ -1356,70 +1564,52 @@ def test_sstore_restoration_create_init_success( + Op.RETURN(0, 0) ) + mstore_value, init_code_size = init_code_at_high_bytes(init_code) if create_opcode == Op.CREATE: - create_call = Op.CREATE(0, 0, len(init_code)) + create_call = Op.CREATE(0, 0, init_code_size) else: - create_call = Op.CREATE2(0, 0, len(init_code), 0) + create_call = Op.CREATE2(0, 0, init_code_size, 0) + + probe_code = Op.SSTORE(0, 1) + probe = pre.deploy_contract(code=probe_code) + probe_gas = probe_code.execution_cost(fork) caller_storage = Storage() + create_slot = caller_storage.store_next(True, "create_succeeded") + probe_slot = caller_storage.store_next(1, "probe_succeeds") caller = pre.deploy_contract( - code=( - Op.MSTORE( - 0, - int.from_bytes(bytes(init_code), "big") - << (256 - 8 * len(init_code)), - ) - + Op.SSTORE( - caller_storage.store_next(True, "create_succeeded"), - Op.GT(create_call, 0), - ) + code=Op.MSTORE(0, mstore_value) + + Op.SSTORE( + create_slot, + Op.GT(create_call, 0), + # gas accounting + original_value=1, + current_value=1, + new_value=1, + key_warm=False, + ) + + Op.SSTORE( + probe_slot, + Op.CALL(gas=probe_gas, address=probe), + # gas accounting + original_value=1, + current_value=1, + new_value=1, + key_warm=False, ), + storage={create_slot: 1, probe_slot: 1}, ) + # Sized for the CREATE's account creation plus the probe's SSTORE: + # the init frame's set and clear net to zero. tx = Transaction( to=caller, state_gas_reservoir=create_state_gas + sstore_state_gas, sender=pre.fund_eoa(), ) - post = {caller: Account(storage=caller_storage)} + post = { + caller: Account(storage=caller_storage), + probe: Account(storage={0: 1}), + } state_test(pre=pre, tx=tx, post=post) - - -@pytest.mark.valid_from("EIP8037") -def test_sstore_restoration_reservoir_spillover( - blockchain_test: BlockchainTestFiller, - pre: Alloc, - fork: Fork, -) -> None: - """ - Verify restoration refund when state gas spilled into gas_left. - - With tx.gas at the cap, reservoir is zero. SSTORE 0 to 1 state - gas comes from gas_left. At x to 0 the refund goes to - `state_gas_reservoir` (not back to gas_left), moving gas between - buckets. Block state gas is zero. - """ - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - intrinsic_gas = fork.transaction_intrinsic_cost_calculator()() - - code = Op.SSTORE(0, 1) + Op.SSTORE.with_metadata( - key_warm=True, - original_value=0, - current_value=1, - new_value=0, - )(0, 0) - tx_execution = intrinsic_gas + code.gas_cost(fork) - sstore_state_gas - - contract = pre.deploy_contract(code=code) - tx = Transaction( - to=contract, - state_gas_reservoir=0, - sender=pre.fund_eoa(), - ) - - blockchain_test( - pre=pre, - blocks=[Block(txs=[tx], header_verify=Header(gas_used=tx_execution))], - post={contract: Account(storage={0: 0})}, - ) From e8e994812dd06cc2ecb2098991ae952365cc12b5 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Mon, 31 Aug 2026 08:13:29 +0200 Subject: [PATCH 27/59] fix(consume): map geth empty system contract rejection to SYSTEM_CONTRACT_EMPTY (#3465) Geth rejects blocks whose request system call targets a codeless contract with "failed to process : empty system contract: no code at " (since ethereum/go-ethereum#35514), which GethExceptionMapper could not map. Claude-Session: https://claude.ai/code/session_015PPcN9JS3yMPYWCYqusrEm --- packages/testing/src/execution_testing/client_clis/clis/geth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/testing/src/execution_testing/client_clis/clis/geth.py b/packages/testing/src/execution_testing/client_clis/clis/geth.py index 3184354bcd4..514c19b52b7 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/geth.py +++ b/packages/testing/src/execution_testing/client_clis/clis/geth.py @@ -108,6 +108,7 @@ class GethExceptionMapper(ExceptionMapper): "invalid number of versionedHashes" ), BlockException.INVALID_REQUESTS: "invalid requests hash", + BlockException.SYSTEM_CONTRACT_EMPTY: "empty system contract", BlockException.SYSTEM_CONTRACT_CALL_FAILED: ( "system call failed to execute:" ), From aee3eb2b02e49adf3c52b77c415907006e3d8a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Mon, 31 Aug 2026 08:31:20 +0200 Subject: [PATCH 28/59] test(tests): ECRECOVER - cover R == -G with u1 < u2 and u1 == u2 (#3459) `R == -G` is the input class where the Shamir/interleaved-MSM precomputation `P + Q` (with `P = G`, `Q = R`) is the point at infinity. Implementations that special-case it reduce `u1*G + u2*R` to `(u1 - u2)*G`, which branches on the ordering of `u1` and `u2`. Only `u1 > u2` was covered. Add the two neighbouring cases: - `u1_lt_u2_R_eq_neg_G`: `u1 = 1`, `u2 = 2`, so the difference is negative and the recovered key is `-G`. - `u1_eq_u2_R_eq_neg_G`: `u1 == u2`, so the recovered point is the point at infinity and the precompile returns empty. Fixes #3458 --- tests/frontier/precompiles/test_ecrecover.py | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/frontier/precompiles/test_ecrecover.py b/tests/frontier/precompiles/test_ecrecover.py index b98b69f42f6..8dac040b66a 100644 --- a/tests/frontier/precompiles/test_ecrecover.py +++ b/tests/frontier/precompiles/test_ecrecover.py @@ -236,6 +236,42 @@ ), id="u1_eq_neg_u2_R_eq_neg_G", ), + # u1 < u2 && R == -G + pytest.param( + bytes.fromhex( + "8641998106234453aa5f9d6a3178f4f7b812e00b817a776265dfdd31b93e29a9" + ), + bytes.fromhex( + "000000000000000000000000000000000000000000000000000000000000001c" + ), + bytes.fromhex( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + ), + bytes.fromhex( + "f37cccfdf3b97758ab40c52b9d0e160e0537f9b65b9c51b2b3e502b62df02f30" + ), + bytes.fromhex( + "00000000000000000000000080c0dbf239224071c59dd8970ab9d542e3414ab2" + ), + id="u1_lt_u2_R_eq_neg_G", + ), + # u1 == u2 && R == -G, so the recovered point is the point at infinity + pytest.param( + bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ), + bytes.fromhex( + "000000000000000000000000000000000000000000000000000000000000001c" + ), + bytes.fromhex( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + ), + bytes.fromhex( + "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140" + ), + b"", + id="u1_eq_u2_R_eq_neg_G", + ), # 13u1 == u2 && R == -13G pytest.param( bytes.fromhex( From 2ae4255ac5c59957653e154cd6e7e12182cb9013 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Mon, 31 Aug 2026 10:03:16 +0200 Subject: [PATCH 29/59] chore(deps): bump ethereum-hive to v0.1.0 (#3472) The first stable release of ethereum-hive; it pools Hive API connections in a shared keep-alive session, which fixes sporadic `EADDRNOTAVAIL` failures and silently lost test results at high simulator throughput (ethereum/hive-python-api#18). --- packages/testing/pyproject.toml | 2 +- uv.lock | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/testing/pyproject.toml b/packages/testing/pyproject.toml index 5ad9ec0491d..83d993930dd 100644 --- a/packages/testing/pyproject.toml +++ b/packages/testing/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ ] dependencies = [ "click>=8.1.0,<9", - "ethereum-hive>=0.1.0a5,<1.0.0", + "ethereum-hive>=0.1.0,<1.0.0", "ethereum-execution", "gitpython>=3.1.31,<4", "PyJWT>=2.3.0,<3", diff --git a/uv.lock b/uv.lock index c00273eacbf..cab3b4ab404 100644 --- a/uv.lock +++ b/uv.lock @@ -1122,7 +1122,7 @@ requires-dist = [ { name = "eth-abi", specifier = ">=5.2.0" }, { name = "eth-remerkleable", specifier = "==0.1.31" }, { name = "ethereum-execution", editable = "." }, - { name = "ethereum-hive", specifier = ">=0.1.0a5,<1.0.0" }, + { name = "ethereum-hive", specifier = ">=0.1.0,<1.0.0" }, { name = "ethereum-rlp", specifier = ">=0.1.6,<0.2" }, { name = "ethereum-types", specifier = ">=0.4.1,<0.5" }, { name = "filelock", specifier = ">=3.15.1,<4" }, @@ -1168,14 +1168,15 @@ test = [{ name = "pytest-cov", specifier = ">=4.1.0,<5" }] [[package]] name = "ethereum-hive" -version = "0.1.0a5" +version = "0.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, + { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/a8/95676acd86095a5dcf5dacfa5a991175b3a547dcd5729735fea777b8feec/ethereum_hive-0.1.0a5.tar.gz", hash = "sha256:bf91d3144c263a1a6407c931b3864ab3edd40bc5c34b812e571e7c49cc70f9eb", size = 75064, upload-time = "2026-03-11T07:36:24.532Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/06/583cb86c2fef9e0da8bb8d4165c9dde076a8c9e1f940327dbbb64bf68dfc/ethereum_hive-0.1.0.tar.gz", hash = "sha256:7f047fc03f15fc43081432c4adca362e9dffd6bf2faad163f707c7221148d61b", size = 79244, upload-time = "2026-08-31T07:44:36.736Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/95/986727018ac0401562d91961e2f46462ed65a08e2d5162a255d9f871a7ad/ethereum_hive-0.1.0a5-py3-none-any.whl", hash = "sha256:3225747ed83a9124a697db0ab8d736c4820117462e64aa3395bbc1588c4d40d0", size = 37589, upload-time = "2026-03-11T07:36:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/93/2f/461e38bd697e90f6e7a2199193eedd3653e69ca331cc98431653b46ad156/ethereum_hive-0.1.0-py3-none-any.whl", hash = "sha256:5a511ef89bab8b4740ed501ce7c3d244085f687c5001d9fa1ecc2ab01ad14b51", size = 41184, upload-time = "2026-08-31T07:44:35.636Z" }, ] [[package]] From 41e599935247b5312b818e603703c98dce3534db Mon Sep 17 00:00:00 2001 From: danceratopz Date: Mon, 31 Aug 2026 14:44:36 +0200 Subject: [PATCH 30/59] fix(doc): remove broken GitPOAP badge (#3474) --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f38aa34efb9..a15a25668b4 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,13 @@ # Ethereum Execution Layer Specifications -[![latest version](https://img.shields.io/github/v/release/ethereum/execution-specs)](https://github.com/ethereum/execution-specs/releases/latest) -[![PyPI version](https://img.shields.io/pypi/v/ethereum-execution)](https://pypi.org/project/ethereum-execution/) -[![License](https://img.shields.io/github/license/ethereum/execution-specs)](https://github.com/ethereum/execution-specs/blob/main/LICENSE) +[![PyPI release](https://img.shields.io/pypi/v/ethereum-execution)](https://pypi.org/project/ethereum-execution/) +[![Tests release](https://img.shields.io/github/v/release/ethereum/execution-specs?filter=tests%40v%2A&label=tests)](https://github.com/ethereum/execution-specs/releases) +[![License](https://img.shields.io/github/license/ethereum/execution-specs)](LICENSE.md) [![Python Specification](https://github.com/ethereum/execution-specs/actions/workflows/test.yaml/badge.svg)](https://github.com/ethereum/execution-specs/actions/workflows/test.yaml) [![codecov](https://codecov.io/gh/ethereum/execution-specs/graph/badge.svg?token=0LQZO56RTM)](https://codecov.io/gh/ethereum/execution-specs) ![Python Versions](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue) [![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) -[![GitPOAP Badge](https://public-api.gitpoap.io/v1/repo/ethereum/execution-specs/badge)](https://www.gitpoap.io/gh/ethereum/execution-specs) The Ethereum Execution Layer Specifications (EELS) are an executable Python reference implementation of Ethereum's execution layer, along with the test cases that verify it. It provides a shared, runnable description of consensus-critical behaviour, and the accompanying tests generate fixtures that can be used to validate execution client implementations. From 3e59e29e41589bc3c2d6a0276fcc445531550a6b Mon Sep 17 00:00:00 2001 From: danceratopz Date: Mon, 31 Aug 2026 15:07:10 +0200 Subject: [PATCH 31/59] feat(test-cli): add JSON output and optimization analysis to `groupstats` (#3308) --- .../cli/show_pre_alloc_group_stats.py | 797 ++++++++++++++++-- .../tests/test_show_pre_alloc_group_stats.py | 299 +++++++ 2 files changed, 1005 insertions(+), 91 deletions(-) create mode 100644 packages/testing/src/execution_testing/cli/tests/test_show_pre_alloc_group_stats.py diff --git a/packages/testing/src/execution_testing/cli/show_pre_alloc_group_stats.py b/packages/testing/src/execution_testing/cli/show_pre_alloc_group_stats.py index c801d61f313..8c4d38beb06 100644 --- a/packages/testing/src/execution_testing/cli/show_pre_alloc_group_stats.py +++ b/packages/testing/src/execution_testing/cli/show_pre_alloc_group_stats.py @@ -1,17 +1,17 @@ """Script to display statistics about pre-allocation groups.""" +import hashlib +import json +import re from collections import defaultdict +from dataclasses import dataclass from pathlib import Path -from typing import Dict, List, Set, Tuple +from typing import Any, Dict, List, Tuple import click -from pydantic import Field from rich.console import Console from rich.table import Table -from execution_testing.base_types import CamelModel -from execution_testing.fixtures import PreAllocGroups - def extract_test_module(test_id: str) -> str: """Extract test module path from test ID.""" @@ -42,6 +42,223 @@ def extract_test_function(test_id: str) -> str: return test_id +def _stable_json_hash(value: Any) -> str: + """Return a stable short hash for a JSON-compatible value.""" + encoded = json.dumps(value, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:16] + + +def _limited(items: List[Dict[str, Any]], limit: int) -> List[Dict[str, Any]]: + """Return up to ``limit`` items, or all items if limit is zero.""" + if limit <= 0: + return items + return items[:limit] + + +def _parse_chain_id(value: Any) -> int: + """Parse a chain ID serialized as an int or a hex string.""" + if isinstance(value, str): + return int(value, 16) + return int(value) + + +def _environment_hash(data: Dict[str, Any]) -> str: + """ + Return a stable hash of a group's genesis-relevant execution context. + + Older pre-alloc group files store the grouping ``environment`` directly; + newer files store only the derived ``genesis`` header. For the latter, + the pre-state dependent fields (``stateRoot`` and the block ``hash``) + are removed before hashing so that groups which could share a genesis + after merging hash identically. + """ + if "environment" in data: + return _stable_json_hash(data["environment"]) + genesis = dict(data.get("genesis", {})) + genesis.pop("stateRoot", None) + genesis.pop("hash", None) + return _stable_json_hash(genesis) + + +@dataclass(frozen=True) +class GroupRecord: + """Lightweight representation of one pre-allocation group file.""" + + hash: str + path: str + tests: int + accounts: int + fork: str + chain_id: int + group_salt: str | None + environment_hash: str + test_ids: List[str] + + @property + def candidate_bucket_key(self) -> str: + """Return the coarse key used to find potentially packable groups.""" + salt = self.group_salt or "" + return f"{self.fork}|{self.chain_id}|{salt}|{self.environment_hash}" + + @property + def modules(self) -> List[str]: + """Return test modules used by this group.""" + return sorted( + {extract_test_module(test_id) for test_id in self.test_ids} + ) + + @property + def functions(self) -> List[str]: + """Return test functions used by this group.""" + return sorted( + {extract_test_function(test_id) for test_id in self.test_ids} + ) + + def as_dict(self, *, include_test_ids: bool) -> Dict[str, Any]: + """Return a JSON-friendly group record.""" + result: Dict[str, Any] = { + "hash": self.hash, + "short_hash": self.hash[:10], + "path": self.path, + "tests": self.tests, + "accounts": self.accounts, + "fork": self.fork, + "chain_id": self.chain_id, + "group_salt": self.group_salt, + "environment_hash": self.environment_hash, + "candidate_bucket_key": self.candidate_bucket_key, + "modules": self.modules, + "functions": self.functions, + } + if include_test_ids: + result["test_ids"] = self.test_ids + return result + + +def _read_group_record(file: Path) -> GroupRecord: + """Read one pre-allocation group file without validating full fixtures.""" + data = json.loads(file.read_text()) + test_ids = data.get("testIds", []) + pre = data.get("pre", {}) + fork = data.get("network", "unknown") + if not isinstance(fork, str): + fork = str(fork) + + return GroupRecord( + hash=file.stem, + path=str(file), + tests=len(test_ids) if test_ids else data.get("testCount", 0), + accounts=len(pre) if pre else data.get("preAccountCount", 0), + fork=fork, + chain_id=_parse_chain_id(data.get("chainId", 1)), + group_salt=data.get("groupSalt"), + environment_hash=_environment_hash(data), + test_ids=test_ids, + ) + + +def _filter_group_records( + records: List[GroupRecord], + *, + match_test_id_substrings: Tuple[str, ...], + match_test_id_regexes: Tuple[str, ...], + exclude_test_id_substrings: Tuple[str, ...], + exclude_test_id_regexes: Tuple[str, ...], +) -> Tuple[List[GroupRecord], Dict[str, Any]]: + """Match and remove test IDs from records, dropping empty groups.""" + match_regexes = [re.compile(pattern) for pattern in match_test_id_regexes] + exclude_regexes = [ + re.compile(pattern) for pattern in exclude_test_id_regexes + ] + has_match_filters = bool(match_test_id_substrings or match_regexes) + + def matches(test_id: str) -> bool: + if not has_match_filters: + return True + return any( + substring in test_id for substring in match_test_id_substrings + ) or any(regex.search(test_id) is not None for regex in match_regexes) + + def excluded(test_id: str) -> bool: + return any( + substring in test_id for substring in exclude_test_id_substrings + ) or any( + regex.search(test_id) is not None for regex in exclude_regexes + ) + + filtered_records = [] + matched_test_ids = [] + excluded_test_ids = [] + unmatched_test_ids = [] + dropped_group_hashes = [] + groups_with_matches = 0 + groups_with_excludes = 0 + + for record in records: + kept_test_ids = [] + record_matched = [] + record_excluded = [] + record_unmatched = [] + for test_id in record.test_ids: + if not matches(test_id): + record_unmatched.append(test_id) + elif excluded(test_id): + record_excluded.append(test_id) + else: + record_matched.append(test_id) + kept_test_ids.append(test_id) + + if record_matched: + groups_with_matches += 1 + matched_test_ids.extend(record_matched) + if record_excluded: + groups_with_excludes += 1 + excluded_test_ids.extend(record_excluded) + unmatched_test_ids.extend(record_unmatched) + + if ( + not record_excluded + and not record_unmatched + and len(kept_test_ids) == len(record.test_ids) + ): + filtered_records.append(record) + continue + + if not kept_test_ids: + dropped_group_hashes.append(record.hash) + continue + + filtered_records.append( + GroupRecord( + hash=record.hash, + path=record.path, + tests=len(kept_test_ids), + accounts=record.accounts, + fork=record.fork, + chain_id=record.chain_id, + group_salt=record.group_salt, + environment_hash=record.environment_hash, + test_ids=kept_test_ids, + ) + ) + + return filtered_records, { + "match_test_id_substrings": list(match_test_id_substrings), + "match_test_id_regexes": list(match_test_id_regexes), + "exclude_test_id_substrings": list(exclude_test_id_substrings), + "exclude_test_id_regexes": list(exclude_test_id_regexes), + "matched_tests": len(matched_test_ids), + "unmatched_tests": len(unmatched_test_ids), + "excluded_tests": len(excluded_test_ids), + "groups_with_matches": groups_with_matches, + "groups_with_exclusions": groups_with_excludes, + "dropped_groups": len(dropped_group_hashes), + "dropped_group_hashes": dropped_group_hashes, + "matched_test_ids": matched_test_ids, + "excluded_test_ids": excluded_test_ids, + } + + def calculate_size_distribution( test_counts: List[int], ) -> Tuple[List[Tuple[str, int]], List[Tuple[str, int, int, int]]]: @@ -87,7 +304,6 @@ def calculate_size_distribution( # Test count distribution with group count tests_in_bin = sum(groups_in_bin) - # Added group_count test_distribution.append((label, tests_in_bin, 0, group_count)) # Calculate cumulative values for the table sorted from largest to @@ -115,126 +331,344 @@ def calculate_size_distribution( return group_distribution, test_distribution -def analyze_pre_alloc_folder(folder: Path) -> Dict: +def _summary_by_key( + groups: List[GroupRecord], + *, + key_name: str, + key_getter: Any, + include_test_ids: bool, +) -> List[Dict[str, Any]]: + """Build a ranked summary for modules or test functions.""" + grouped: Dict[str, List[GroupRecord]] = defaultdict(list) + for group in groups: + for key in key_getter(group): + grouped[key].append(group) + + summaries = [] + for key, key_groups in grouped.items(): + test_ids = sorted( + {test_id for group in key_groups for test_id in group.test_ids} + ) + summary: Dict[str, Any] = { + key_name: key, + "groups": len(key_groups), + "tests": sum(group.tests for group in key_groups), + "singleton_groups": sum( + 1 for group in key_groups if group.tests == 1 + ), + "forks": sorted({group.fork for group in key_groups}), + "environment_count": len( + {group.environment_hash for group in key_groups} + ), + "group_hashes": sorted(group.hash for group in key_groups), + } + if include_test_ids: + summary["test_ids"] = test_ids + summaries.append(summary) + + return sorted( + summaries, + key=lambda item: ( + -item["singleton_groups"], + -item["groups"], + -item["tests"], + item[key_name], + ), + ) + + +def _candidate_bucket_summaries( + groups: List[GroupRecord], + *, + low_test_count: int, + include_test_ids: bool, +) -> List[Dict[str, Any]]: + """Return ranked buckets where low-count groups share a genesis key.""" + all_buckets: Dict[str, List[GroupRecord]] = defaultdict(list) + for group in groups: + all_buckets[group.candidate_bucket_key].append(group) + + candidates = [] + for bucket_key, bucket_groups in all_buckets.items(): + low_groups = [ + group for group in bucket_groups if group.tests <= low_test_count + ] + if not low_groups or len(bucket_groups) <= 1: + continue + + first_group = bucket_groups[0] + bucket_group_hashes = sorted(group.hash for group in bucket_groups) + low_group_hashes = sorted(group.hash for group in low_groups) + summary: Dict[str, Any] = { + "bucket_key": bucket_key, + "fork": first_group.fork, + "chain_id": first_group.chain_id, + "group_salt": first_group.group_salt, + "environment_hash": first_group.environment_hash, + "groups": len(bucket_groups), + "tests": sum(group.tests for group in bucket_groups), + "accounts": sum(group.accounts for group in bucket_groups), + "low_groups": len(low_groups), + "low_tests": sum(group.tests for group in low_groups), + "singleton_groups": sum( + 1 for group in low_groups if group.tests == 1 + ), + "larger_groups": sum( + 1 for group in bucket_groups if group.tests > low_test_count + ), + "max_group_tests": max(group.tests for group in bucket_groups), + "group_hashes": bucket_group_hashes, + "low_group_hashes": low_group_hashes, + "modules": sorted( + {module for group in low_groups for module in group.modules} + ), + "functions": sorted( + { + function + for group in low_groups + for function in group.functions + } + ), + } + if include_test_ids: + summary["test_ids"] = sorted( + {test_id for group in low_groups for test_id in group.test_ids} + ) + candidates.append(summary) + + return sorted( + candidates, + key=lambda item: ( + -item["singleton_groups"], + -item["low_groups"], + -item["low_tests"], + -item["groups"], + item["bucket_key"], + ), + ) + + +def analyze_pre_alloc_folder( + folder: Path, + *, + low_test_count: int = 5, + limit: int = 50, + include_test_ids: bool = False, + include_group_details: bool = True, + compact: bool = False, + match_test_id_substrings: Tuple[str, ...] = (), + match_test_id_regexes: Tuple[str, ...] = (), + exclude_test_id_substrings: Tuple[str, ...] = (), + exclude_test_id_regexes: Tuple[str, ...] = (), +) -> Dict[str, Any]: """Analyze pre-allocation folder and return statistics.""" - pre_alloc_groups = PreAllocGroups.from_folder(folder, lazy_load=False) + group_files = sorted(folder.glob("*.json")) + records = [_read_group_record(file) for file in group_files] + records, filter_stats = _filter_group_records( + records, + match_test_id_substrings=match_test_id_substrings, + match_test_id_regexes=match_test_id_regexes, + exclude_test_id_substrings=exclude_test_id_substrings, + exclude_test_id_regexes=exclude_test_id_regexes, + ) # Basic stats - total_groups = len(pre_alloc_groups) - total_tests = sum(group.test_count for group in pre_alloc_groups.values()) - total_accounts = sum( - group.pre_account_count for group in pre_alloc_groups.values() - ) + total_groups = len(records) + total_tests = sum(group.tests for group in records) + total_accounts = sum(group.accounts for group in records) # Group by fork - fork_stats: Dict[str, Dict] = defaultdict( - lambda: {"groups": 0, "tests": 0} + fork_stats: Dict[str, Dict[str, Any]] = defaultdict( + lambda: {"groups": 0, "tests": 0, "low_groups": 0} ) - for group in pre_alloc_groups.values(): - fork_stats[group.fork.name()]["groups"] += 1 - fork_stats[group.fork.name()]["tests"] += group.test_count + for group in records: + fork_stats[group.fork]["groups"] += 1 + fork_stats[group.fork]["tests"] += group.tests + if group.tests <= low_test_count: + fork_stats[group.fork]["low_groups"] += 1 # Group by test module - module_stats: Dict[str, Dict] = defaultdict( - lambda: {"groups": set(), "tests": 0} + module_stats: Dict[str, Dict[str, Any]] = defaultdict( + lambda: {"groups": set(), "tests": 0, "low_groups": 0} ) - for hash_key, group in pre_alloc_groups.items(): + for group in records: # Count tests per module in this group - module_test_count: defaultdict = defaultdict(int) + module_test_count: defaultdict[str, int] = defaultdict(int) for test_id in group.test_ids: module = extract_test_module(test_id) module_test_count[module] += 1 # Add to module stats for module, test_count in module_test_count.items(): - module_stats[module]["groups"].add(hash_key) + module_stats[module]["groups"].add(group.hash) module_stats[module]["tests"] += test_count + if group.tests <= low_test_count: + module_stats[module]["low_groups"] += 1 # Convert sets to counts for module in module_stats: module_stats[module]["groups"] = len(module_stats[module]["groups"]) # Per-group details - group_details = [] - for hash_key, group in pre_alloc_groups.items(): - group_details.append( - { - "hash": str(hash_key), - "tests": group.test_count, - "accounts": group.pre_account_count, - "fork": group.fork.name(), - } - ) + group_details = ( + [group.as_dict(include_test_ids=include_test_ids) for group in records] + if include_group_details + else [] + ) # Calculate frequency distribution of group sizes group_distribution, test_distribution = calculate_size_distribution( - [g["tests"] for g in group_details] # type: ignore + [group.tests for group in records] ) # Analyze test functions split across multiple size-1 groups - class SplitTestFunction(CamelModel): - groups: int = 0 - forks: Set[str] = Field(default_factory=set) - - split_test_functions: Dict[str, SplitTestFunction] = defaultdict( - lambda: SplitTestFunction() + split_test_functions: Dict[str, Dict[str, Any]] = defaultdict( + lambda: {"groups": 0, "forks": set()} ) - - # Process all size-1 groups directly from pre_state - for _hash_key, group_data in pre_alloc_groups.items(): - if group_data.test_count == 1: # Size-1 group - test_id = group_data.test_ids[0] + for group in records: + if group.tests == 1: + test_id = group.test_ids[0] if group.test_ids else group.hash test_function = extract_test_function(test_id) - fork = group_data.fork.name() + split_test_functions[test_function]["groups"] += 1 + split_test_functions[test_function]["forks"].add(group.fork) - split_test_functions[test_function].groups += 1 - split_test_functions[test_function].forks.add(fork) - - # Filter to only test functions with multiple size-1 groups and calculate - # ratios split_functions = {} for func, split_test_function in split_test_functions.items(): - if split_test_function.groups > 1: - fork_count = len(split_test_function.forks) + if split_test_function["groups"] > 1: + fork_count = len(split_test_function["forks"]) groups_per_fork = ( - split_test_function.groups / fork_count + split_test_function["groups"] / fork_count if fork_count > 0 - else split_test_function.groups + else split_test_function["groups"] ) split_functions[func] = { - "total_groups": split_test_function.groups, + "total_groups": split_test_function["groups"], "fork_count": fork_count, "groups_per_fork": groups_per_fork, + "forks": sorted(split_test_function["forks"]), } + low_groups = sorted( + [ + group.as_dict(include_test_ids=include_test_ids) + for group in records + if group.tests <= low_test_count + ], + key=lambda item: ( + item["tests"], + -item["accounts"], + item["fork"], + item["hash"], + ), + ) + low_group_records = [ + group for group in records if group.tests <= low_test_count + ] + candidate_buckets = _candidate_bucket_summaries( + records, + low_test_count=low_test_count, + include_test_ids=include_test_ids, + ) + test_function_candidates = _summary_by_key( + low_group_records, + key_name="test_function", + key_getter=lambda group: group.functions, + include_test_ids=include_test_ids, + ) + module_candidates = _summary_by_key( + low_group_records, + key_name="module", + key_getter=lambda group: group.modules, + include_test_ids=include_test_ids, + ) + + if compact: + module_stats_output: Dict[str, Dict[str, Any]] = {} + split_functions_output: Dict[str, Dict[str, Any]] = {} + group_details_output: List[Dict[str, Any]] = [] + else: + module_stats_output = dict(module_stats) + split_functions_output = split_functions + group_details_output = group_details + return { + "pre_alloc_folder": str(folder), + "parameters": { + "low_test_count": low_test_count, + "limit": limit, + "include_test_ids": include_test_ids, + "include_group_details": include_group_details, + "compact": compact, + "match_test_id_substrings": list(match_test_id_substrings), + "match_test_id_regexes": list(match_test_id_regexes), + "exclude_test_id_substrings": list(exclude_test_id_substrings), + "exclude_test_id_regexes": list(exclude_test_id_regexes), + }, "total_groups": total_groups, "total_tests": total_tests, "total_accounts": total_accounts, + "low_group_count": len(low_groups), + "singleton_group_count": sum( + 1 for group in records if group.tests == 1 + ), "fork_stats": dict(fork_stats), - "module_stats": dict(module_stats), - "group_details": group_details, + "module_stats": module_stats_output, + "group_details": group_details_output, "group_distribution": group_distribution, "test_distribution": test_distribution, - "split_functions": split_functions, + "split_functions": split_functions_output, + "filters": filter_stats, + "optimization": { + "low_test_count": low_test_count, + "low_groups_total": len(low_groups), + "candidate_buckets_total": len(candidate_buckets), + "test_function_candidates_total": len(test_function_candidates), + "module_candidates_total": len(module_candidates), + "low_groups": _limited(low_groups, limit), + "candidate_buckets": _limited(candidate_buckets, limit), + "test_function_candidates": _limited( + test_function_candidates, limit + ), + "module_candidates": _limited(module_candidates, limit), + }, } -def display_stats(stats: Dict, console: Console, verbose: int = 0) -> None: +def display_stats( + stats: Dict[str, Any], + console: Console, + verbose: int = 0, +) -> None: """Display statistics in a formatted way.""" # Overall summary console.print("\n[bold cyan]Pre-Allocation Statistics Summary[/bold cyan]") console.print(f"Total groups: [green]{stats['total_groups']}[/green]") console.print(f"Total tests: [green]{stats['total_tests']}[/green]") console.print(f"Total accounts: [green]{stats['total_accounts']}[/green]") - if stats.get("skipped_count", 0) > 0: + console.print( + f"Singleton groups: [yellow]{stats['singleton_group_count']}[/yellow]" + ) + console.print( + "Low-count groups " + f"(<= {stats['parameters']['low_test_count']} tests): " + f"[yellow]{stats['low_group_count']}[/yellow]" + ) + filters = stats.get("filters", {}) + if filters.get("match_test_id_substrings") or filters.get( + "match_test_id_regexes" + ): console.print( - f"Skipped groups: [yellow]{stats['skipped_count']}[/yellow] " - "(use --verbose to see details)" + f"Matched tests: [yellow]{filters['matched_tests']}[/yellow] " + f"in [yellow]{filters['groups_with_matches']}[/yellow] groups" + ) + if filters.get("excluded_tests", 0) > 0: + console.print( + f"Excluded tests: [yellow]{filters['excluded_tests']}[/yellow] " + f"from [yellow]{filters['groups_with_exclusions']}[/yellow] " + "groups" ) - # Per-group details table (only with -v or -vv) - if verbose >= 1: + if verbose >= 1 and stats["group_details"]: console.print( "\n[bold yellow]Tests and Accounts per Group[/bold yellow]" ) @@ -243,6 +677,7 @@ def display_stats(stats: Dict, console: Console, verbose: int = 0) -> None: group_table.add_column("Fork", style="cyan") group_table.add_column("Tests", justify="right") group_table.add_column("Accounts", justify="right") + group_table.add_column("Environment", style="dim") # Sort by test count (descending) sorted_groups = sorted( @@ -254,10 +689,11 @@ def display_stats(stats: Dict, console: Console, verbose: int = 0) -> None: for group in groups_to_show: group_table.add_row( - group["hash"], + group["short_hash"], group["fork"], str(group["tests"]), str(group["accounts"]), + group["environment_hash"], ) if verbose < 2 and len(stats["group_details"]) > 20: @@ -266,9 +702,15 @@ def display_stats(stats: Dict, console: Console, verbose: int = 0) -> None: "...", "...", "...", + "...", ) console.print(group_table) + elif verbose >= 1 and stats["total_groups"] > 0: + console.print( + "\n[dim]Per-group details omitted " + "(use --include-group-details to show them).[/dim]" + ) # Fork statistics table console.print("\n[bold yellow]Groups and Tests per Fork[/bold yellow]") @@ -276,6 +718,7 @@ def display_stats(stats: Dict, console: Console, verbose: int = 0) -> None: fork_table.add_column("Fork", style="cyan") fork_table.add_column("Groups", justify="right") fork_table.add_column("Tests", justify="right") + fork_table.add_column("Low Groups", justify="right") fork_table.add_column("Avg Tests/Group", justify="right") # Sort forks by name @@ -291,6 +734,7 @@ def display_stats(stats: Dict, console: Console, verbose: int = 0) -> None: fork, str(fork_data["groups"]), str(fork_data["tests"]), + str(fork_data["low_groups"]), f"{avg_tests:.1f}", ) @@ -398,8 +842,54 @@ def display_stats(stats: Dict, console: Console, verbose: int = 0) -> None: console.print(coverage_table) - # Module statistics table (only with -v or -vv) - if verbose >= 1: + # Candidate buckets table + optimization = stats.get("optimization", {}) + candidate_buckets = optimization.get("candidate_buckets", []) + if candidate_buckets: + console.print( + "\n[bold yellow]Low-Count Groups Sharing a Genesis " + "Key[/bold yellow]" + ) + console.print( + "[dim]Buckets rank low-count groups that already share fork, " + "chain id, group salt, and environment. These are useful starting " + "points for checking whether reserved-address or pre-allocation " + "constraints can be relaxed.[/dim]", + highlight=False, + ) + bucket_table = Table(show_header=True, header_style="bold magenta") + bucket_table.add_column("Fork", style="cyan") + bucket_table.add_column("Env", style="dim") + bucket_table.add_column("Groups", justify="right") + bucket_table.add_column("Low", justify="right") + bucket_table.add_column("Singleton", justify="right") + bucket_table.add_column("Tests", justify="right") + bucket_table.add_column("Top Module", style="dim") + + for bucket in candidate_buckets: + bucket_table.add_row( + bucket["fork"], + bucket["environment_hash"], + str(bucket["groups"]), + str(bucket["low_groups"]), + str(bucket["singleton_groups"]), + str(bucket["tests"]), + bucket["modules"][0] if bucket["modules"] else "", + ) + + console.print(bucket_table) + if optimization.get("candidate_buckets_total", 0) > len( + candidate_buckets + ): + console.print( + f"[dim]Showing {len(candidate_buckets)} of " + f"{optimization['candidate_buckets_total']} candidate " + "buckets; " + "use --limit 0 to include all in JSON output.[/dim]" + ) + + # Module statistics table (only with -v or -vv; empty with --compact) + if verbose >= 1 and stats["module_stats"]: console.print( "\n[bold yellow]Groups and Tests per Test Module[/bold yellow]" ) @@ -407,6 +897,7 @@ def display_stats(stats: Dict, console: Console, verbose: int = 0) -> None: module_table.add_column("Test Module", style="dim") module_table.add_column("Groups", justify="right") module_table.add_column("Tests", justify="right") + module_table.add_column("Low Groups", justify="right") module_table.add_column("Avg Tests/Group", justify="right") # Sort modules by group count (descending) - shows execution complexity @@ -437,6 +928,7 @@ def display_stats(stats: Dict, console: Console, verbose: int = 0) -> None: module_display, str(module_data["groups"]), str(module_data["tests"]), + str(module_data["low_groups"]), f"{avg_tests:.1f}", ) @@ -446,6 +938,7 @@ def display_stats(stats: Dict, console: Console, verbose: int = 0) -> None: "...", "...", "...", + "...", ) console.print(module_table) @@ -454,12 +947,11 @@ def display_stats(stats: Dict, console: Console, verbose: int = 0) -> None: if stats.get("split_functions"): console.print( "\n[bold yellow]Test Functions Split Across Multiple " - "Groups[/bold yellow]" + "Singleton Groups[/bold yellow]" ) console.print( - "[dim]These test functions create multiple size-1 groups (due to " - "different forks/parameters), preventing pre-allocation group " - "optimization:[/dim]", + "[dim]These test functions create multiple size-1 groups, often " + "due to different forks or parameters.[/dim]", highlight=False, ) @@ -481,7 +973,6 @@ def display_stats(stats: Dict, console: Console, verbose: int = 0) -> None: display_function = test_function if display_function.startswith("tests/"): display_function = display_function[6:] # Remove "tests/" - # prefix split_table.add_row( display_function, @@ -500,8 +991,8 @@ def display_stats(stats: Dict, console: Console, verbose: int = 0) -> None: console.print( f"\n[yellow]Optimization Potential:[/yellow] Excluding these " - f"{total_split_functions} split functions would save " - f"{total_split_groups} groups" + f"{total_split_functions} split functions would remove " + f"{total_split_groups} singleton groups from the pool" ) # Verbosity hint @@ -509,7 +1000,8 @@ def display_stats(stats: Dict, console: Console, verbose: int = 0) -> None: if verbose == 0: console.print( "[dim]Hint: Use -v to see detailed group and module statistics, " - "or -vv to see all groups and modules[/dim]" + "--output json for programmatic analysis, or --limit 0 for all " + "candidate rows[/dim]" ) elif verbose == 1: console.print( @@ -518,7 +1010,24 @@ def display_stats(stats: Dict, console: Console, verbose: int = 0) -> None: ) -@click.command() +@click.command( + epilog=( + "Agent/programmatic example: groupstats PRE_ALLOC_FOLDER --output " + "json --compact --exclude-group-details --low-test-count 1 " + "--limit 20\n\n" + 'For JSON output, inspect the "optimization" object first. ' + '"candidate_buckets" ranks low-count groups sharing fork, chain ID, ' + "group salt, and environment. Then use " + '"test_function_candidates", "module_candidates", and "low_groups" ' + "to identify specific tests or modules to optimize. Add " + "--include-test-ids when exact pytest node IDs are needed. Use " + "--match-test-id or --match-test-id-regex to focus the analysis on " + "one side of a comparison. Use --exclude-test-id or " + "--exclude-test-id-regex to remove known-noisy tests before " + "recomputing stats. Use --limit 0 only when the caller can handle " + "large JSON payloads." + ) +) @click.argument( "pre_alloc_folder", type=click.Path(exists=True, path_type=Path), @@ -528,19 +1037,105 @@ def display_stats(stats: Dict, console: Console, verbose: int = 0) -> None: "--verbose", "-v", count=True, - help="Show verbose output (-v for warnings, -vv for all groups)", + help="Show verbose output (-v for top groups/modules, -vv for all groups)", +) +@click.option( + "--output", + "output_mode", + type=click.Choice(["rich", "json"]), + default="rich", + show_default=True, + help="Output format.", +) +@click.option( + "--low-test-count", + type=click.IntRange(min=1), + default=5, + show_default=True, + help="Treat groups with this many tests or fewer as optimization targets.", +) +@click.option( + "--limit", + type=click.IntRange(min=0), + default=50, + show_default=True, + help="Limit optimization result lists; use 0 for all rows.", +) +@click.option( + "--include-test-ids/--exclude-test-ids", + default=False, + show_default=True, + help="Include full test IDs in group and optimization records.", +) +@click.option( + "--include-group-details/--exclude-group-details", + default=True, + show_default=True, + help="Include the per-group detail list in JSON and verbose rich output.", +) +@click.option( + "--compact", + is_flag=True, + help=( + "Omit verbose top-level maps from JSON output; keeps summaries and " + "bounded optimization lists." + ), ) -def main(pre_alloc_folder: Path, verbose: int) -> None: +@click.option( + "--match-test-id", + "match_test_id_substrings", + multiple=True, + help=( + "Keep only test IDs containing this substring before computing stats. " + "Can be used multiple times." + ), +) +@click.option( + "--match-test-id-regex", + "match_test_id_regexes", + multiple=True, + help=( + "Keep only test IDs matching this regular expression before " + "computing stats. Can be used multiple times." + ), +) +@click.option( + "--exclude-test-id", + "exclude_test_id_substrings", + multiple=True, + help=( + "Exclude test IDs containing this substring before computing stats. " + "Can be used multiple times." + ), +) +@click.option( + "--exclude-test-id-regex", + "exclude_test_id_regexes", + multiple=True, + help=( + "Exclude test IDs matching this regular expression before computing " + "stats. Can be used multiple times." + ), +) +def main( + pre_alloc_folder: Path, + verbose: int, + output_mode: str, + low_test_count: int, + limit: int, + include_test_ids: bool, + include_group_details: bool, + compact: bool, + match_test_id_substrings: Tuple[str, ...], + match_test_id_regexes: Tuple[str, ...], + exclude_test_id_substrings: Tuple[str, ...], + exclude_test_id_regexes: Tuple[str, ...], +) -> None: """ Display statistics about pre-allocation groups. This script analyzes a pre_alloc folder generated by the test framework's - pre-allocation group optimization feature and displays: - - - Total number of groups, tests, and accounts - - Number of tests and accounts per group (tabulated) - - Number of groups and tests per fork (tabulated) - - Number of groups and tests per test module (tabulated) + pre-allocation group optimization feature. The pre_alloc file is generated when running tests with the --generate-pre-alloc-groups and --use-pre-alloc-groups flags to optimize @@ -549,15 +1144,35 @@ def main(pre_alloc_folder: Path, verbose: int) -> None: console = Console() try: - stats = analyze_pre_alloc_folder(pre_alloc_folder) - display_stats(stats, console, verbose=verbose) - except FileNotFoundError: - console.print( - f"[red]Error: Folder not found: {pre_alloc_folder}[/red]" + stats = analyze_pre_alloc_folder( + pre_alloc_folder, + low_test_count=low_test_count, + limit=limit, + include_test_ids=include_test_ids, + include_group_details=include_group_details, + compact=compact, + match_test_id_substrings=match_test_id_substrings, + match_test_id_regexes=match_test_id_regexes, + exclude_test_id_substrings=exclude_test_id_substrings, + exclude_test_id_regexes=exclude_test_id_regexes, ) + if output_mode == "json": + click.echo(json.dumps(stats, sort_keys=True, indent=2)) + else: + display_stats(stats, console, verbose=verbose) + except FileNotFoundError: + message = f"Error: Folder not found: {pre_alloc_folder}" + if output_mode == "json": + click.echo(message, err=True) + else: + console.print(f"[red]{message}[/red]") raise click.Abort() from None except Exception as e: - console.print(f"[red]Error: {e}[/red]") + message = f"Error: {e}" + if output_mode == "json": + click.echo(message, err=True) + else: + console.print(f"[red]{message}[/red]") raise click.Abort() from None diff --git a/packages/testing/src/execution_testing/cli/tests/test_show_pre_alloc_group_stats.py b/packages/testing/src/execution_testing/cli/tests/test_show_pre_alloc_group_stats.py new file mode 100644 index 00000000000..611f9e5e4d5 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/tests/test_show_pre_alloc_group_stats.py @@ -0,0 +1,299 @@ +"""Tests for the pre-allocation group statistics CLI.""" + +import json +from pathlib import Path + +from click.testing import CliRunner + +from execution_testing.cli.show_pre_alloc_group_stats import ( + analyze_pre_alloc_folder, + main, +) + + +def _write_group( + folder: Path, + group_hash: str, + *, + test_ids: list[str], + network: str = "Prague", + chain_id: int = 1, + environment: dict[str, str] | None = None, + pre_accounts: int = 1, + group_salt: str | None = None, +) -> None: + """Write a minimal pre-alloc group fixture.""" + payload = { + "testIds": test_ids, + "environment": environment or {"currentNumber": "0x00"}, + "network": network, + "chainId": chain_id, + "pre": { + f"0x{account:040x}": { + "nonce": "0x00", + "balance": "0x00", + "code": "0x", + "storage": {}, + } + for account in range(pre_accounts) + }, + } + if group_salt is not None: + payload["groupSalt"] = group_salt + (folder / f"{group_hash}.json").write_text(json.dumps(payload)) + + +def _write_genesis_format_group( + folder: Path, + group_hash: str, + *, + test_ids: list[str], + network: str = "Prague", + timestamp: str = "0x00", + state_root: str = "0x00", + pre_accounts: int = 1, +) -> None: + """ + Write a group in the newer format: a derived ``genesis`` header (with + pre-state dependent ``stateRoot`` and ``hash``) instead of the grouping + ``environment``, and a zero-padded hex ``chainId``. + """ + payload = { + "testIds": test_ids, + "network": network, + "chainId": "0x01", + "genesis": { + "timestamp": timestamp, + "gasLimit": "0x016345785d8a0000", + "stateRoot": state_root, + "hash": f"0xbeef{state_root[2:]}", + }, + "pre": { + f"0x{account:040x}": { + "nonce": "0x00", + "balance": "0x00", + "code": "0x", + "storage": {}, + } + for account in range(pre_accounts) + }, + } + (folder / f"{group_hash}.json").write_text(json.dumps(payload)) + + +def test_analyze_pre_alloc_folder_reports_low_count_candidate_buckets( + tmp_path: Path, +) -> None: + """Low-count groups sharing a genesis key are ranked as candidates.""" + shared_environment = {"currentNumber": "0x01"} + _write_group( + tmp_path, + "0xaaa", + test_ids=["tests/prague/foo/test_bar.py::test_case[fork_Prague-a]"], + environment=shared_environment, + pre_accounts=3, + ) + _write_group( + tmp_path, + "0xbbb", + test_ids=["tests/prague/foo/test_bar.py::test_case[fork_Prague-b]"], + environment=shared_environment, + pre_accounts=2, + ) + _write_group( + tmp_path, + "0xccc", + test_ids=[ + "tests/prague/foo/test_other.py::test_other[fork_Prague-a]", + "tests/prague/foo/test_other.py::test_other[fork_Prague-b]", + "tests/prague/foo/test_other.py::test_other[fork_Prague-c]", + ], + environment={"currentNumber": "0x02"}, + pre_accounts=1, + ) + + stats = analyze_pre_alloc_folder( + tmp_path, + low_test_count=1, + limit=0, + include_test_ids=True, + ) + + assert stats["total_groups"] == 3 + assert stats["singleton_group_count"] == 2 + assert stats["optimization"]["candidate_buckets_total"] == 1 + candidate = stats["optimization"]["candidate_buckets"][0] + assert candidate["group_hashes"] == ["0xaaa", "0xbbb"] + assert candidate["low_group_hashes"] == ["0xaaa", "0xbbb"] + assert candidate["singleton_groups"] == 2 + assert candidate["test_ids"] == [ + "tests/prague/foo/test_bar.py::test_case[fork_Prague-a]", + "tests/prague/foo/test_bar.py::test_case[fork_Prague-b]", + ] + + +def test_analyze_pre_alloc_folder_buckets_genesis_format_groups( + tmp_path: Path, +) -> None: + """ + Groups in the newer genesis-header format bucket on the genesis header + minus its pre-state dependent fields, and hex chain IDs parse as ints. + """ + _write_genesis_format_group( + tmp_path, + "0xaaa", + test_ids=["tests/prague/foo/test_bar.py::test_case[fork_Prague-a]"], + state_root="0x01", + ) + _write_genesis_format_group( + tmp_path, + "0xbbb", + test_ids=["tests/prague/foo/test_bar.py::test_case[fork_Prague-b]"], + state_root="0x02", + ) + _write_genesis_format_group( + tmp_path, + "0xccc", + test_ids=["tests/prague/foo/test_other.py::test_other[fork_Prague]"], + timestamp="0x0c", + state_root="0x03", + ) + + stats = analyze_pre_alloc_folder(tmp_path, low_test_count=1, limit=0) + + assert stats["total_groups"] == 3 + assert stats["optimization"]["candidate_buckets_total"] == 1 + candidate = stats["optimization"]["candidate_buckets"][0] + assert candidate["chain_id"] == 1 + assert candidate["group_hashes"] == ["0xaaa", "0xbbb"] + + +def test_groupstats_json_output_is_machine_readable(tmp_path: Path) -> None: + """The CLI should emit JSON without rich console markup.""" + _write_group( + tmp_path, + "0xaaa", + test_ids=["tests/prague/foo/test_bar.py::test_case[fork_Prague-a]"], + ) + + result = CliRunner().invoke( + main, + [ + str(tmp_path), + "--output", + "json", + "--low-test-count", + "1", + "--include-test-ids", + "--compact", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["parameters"]["include_test_ids"] is True + assert payload["parameters"]["compact"] is True + assert payload["group_details"] == [] + assert payload["optimization"]["low_groups"][0]["test_ids"] == [ + "tests/prague/foo/test_bar.py::test_case[fork_Prague-a]" + ] + assert "[bold" not in result.output + + +def test_analyze_pre_alloc_folder_excludes_test_id_substrings( + tmp_path: Path, +) -> None: + """Substring filters should remove tests before stats are computed.""" + _write_group( + tmp_path, + "0xaaa", + test_ids=[ + "tests/prague/foo/test_bar.py::test_keep[fork_Prague]", + "tests/prague/foo/test_bar.py::test_drop[fork_Prague]", + ], + ) + + stats = analyze_pre_alloc_folder( + tmp_path, + low_test_count=1, + exclude_test_id_substrings=("test_drop",), + ) + + assert stats["total_tests"] == 1 + assert stats["low_group_count"] == 1 + assert stats["filters"]["excluded_tests"] == 1 + assert stats["filters"]["groups_with_exclusions"] == 1 + assert stats["filters"]["dropped_groups"] == 0 + + +def test_analyze_pre_alloc_folder_matches_test_id_substrings( + tmp_path: Path, +) -> None: + """Substring match filters should keep only matching tests.""" + _write_group( + tmp_path, + "0xaaa", + test_ids=[ + "tests/ported_static/foo/test_bar.py::test_keep[fork_Prague]", + "tests/prague/foo/test_bar.py::test_drop[fork_Prague]", + ], + ) + + stats = analyze_pre_alloc_folder( + tmp_path, + low_test_count=1, + match_test_id_substrings=("ported_static",), + ) + + assert stats["total_tests"] == 1 + assert stats["low_group_count"] == 1 + assert stats["filters"]["matched_tests"] == 1 + assert stats["filters"]["unmatched_tests"] == 1 + assert stats["filters"]["dropped_groups"] == 0 + assert stats["optimization"]["low_groups"][0]["modules"] == [ + "tests/ported_static/foo/test_bar.py" + ] + + +def test_groupstats_rich_output_reports_regex_exclusions( + tmp_path: Path, +) -> None: + """Regex match and exclude filters should apply to rich mode output.""" + _write_group( + tmp_path, + "0xaaa", + test_ids=[ + "tests/ported_static/foo/test_bar.py::test_drop[fork_Prague]" + ], + ) + _write_group( + tmp_path, + "0xbbb", + test_ids=[ + "tests/ported_static/foo/test_bar.py::test_keep[fork_Prague]" + ], + ) + _write_group( + tmp_path, + "0xccc", + test_ids=["tests/prague/foo/test_bar.py::test_other[fork_Prague]"], + ) + + result = CliRunner().invoke( + main, + [ + str(tmp_path), + "--low-test-count", + "1", + "--match-test-id", + "ported_static", + "--exclude-test-id-regex", + "test_drop", + ], + ) + + assert result.exit_code == 0, result.output + assert "Total groups: 1" in result.output + assert "Total tests: 1" in result.output + assert "Matched tests: 1" in result.output + assert "Excluded tests: 1" in result.output From 6a2a98b761d08cbe4107a6e00c07d21d965e473e Mon Sep 17 00:00:00 2001 From: danceratopz Date: Mon, 31 Aug 2026 15:46:12 +0200 Subject: [PATCH 32/59] fix(test-consume): better xdist detection for enginex (#2793) * fix(consume): force loadgroup for enginex xdist EngineX relies on each pre-alloc group being executed by a single xdist worker so that the per-worker client manager sees every test in the group and can stop the shared client as soon as the group completes. The previous parallelism detection only matched '-n' as a separate argument. Common spellings such as '-n=6', '-n6', and '--numprocesses=6' did not trigger the EngineX loadgroup override, so pytest-xdist could use its default distribution and split one pre-alloc group across workers. Detect all supported xdist parallelism spellings and ensure consume enginex always runs with '--dist=loadgroup', overriding incompatible distribution modes with a warning. Keep the behavior scoped to EngineX so consume engine and the other simulators are unchanged. * fix(consume): strip xdist `-d` load shorthand for enginex pytest-xdist's `-d` flag sets `--dist=load` unconditionally in its cmdline hook, clobbering any `--dist=loadgroup` injected by the argument processor. Remove the flag and warn instead of relying on the `--dist` override alone. * refactor(consume): always force loadgroup dist for enginex `--dist=loadgroup` is inert when xdist is not active, so the parallelism-flag gate on the enginex path is unnecessary; ensure the distribution mode unconditionally instead of detecting every `-n` spelling first. * fix: apply nits from review Co-authored-by: spencer --------- Co-authored-by: spencer --- .../consume/tests/test_enginex_args.py | 110 ++++++++++++++++++ .../cli/pytest_commands/processors.py | 77 +++++++++++- 2 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/tests/test_enginex_args.py diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/tests/test_enginex_args.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/tests/test_enginex_args.py new file mode 100644 index 00000000000..9090e4ffaa3 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/tests/test_enginex_args.py @@ -0,0 +1,110 @@ +"""Tests for EngineX consume argument processing.""" + +import pytest + +from execution_testing.cli.pytest_commands.processors import ( + HiveEnvironmentProcessor, +) + + +@pytest.mark.parametrize( + "parallelism_args", + [ + ["-n", "6"], + ["-n=6"], + ["-n6"], + ["--numprocesses", "6"], + ["--numprocesses=6"], + ], +) +def test_enginex_parallelism_uses_loadgroup( + monkeypatch: pytest.MonkeyPatch, parallelism_args: list[str] +) -> None: + """EngineX must use xdist loadgroup for every supported -n spelling.""" + monkeypatch.delenv("HIVE_PARALLELISM", raising=False) + + args = HiveEnvironmentProcessor("enginex").process_args( + [*parallelism_args] + ) + + assert "--dist" in args + assert args[args.index("--dist") + 1] == "loadgroup" + + +@pytest.mark.parametrize( + "dist_args", + [ + ["--dist", "load"], + ["--dist=load"], + ], +) +def test_enginex_parallelism_overrides_non_loadgroup_dist( + monkeypatch: pytest.MonkeyPatch, dist_args: list[str] +) -> None: + """EngineX overrides incompatible xdist distribution modes.""" + monkeypatch.delenv("HIVE_PARALLELISM", raising=False) + + with pytest.warns(UserWarning, match="requires `--dist=loadgroup`"): + args = HiveEnvironmentProcessor("enginex").process_args( + ["-n=6", *dist_args] + ) + + assert "--dist=load" not in args + if "--dist" in args: + assert args[args.index("--dist") + 1] == "loadgroup" + else: + assert "--dist=loadgroup" in args + + +@pytest.mark.parametrize( + "dist_args", + [ + ["-d"], + ["-d", "--dist=loadgroup"], + ["--dist", "load", "-d"], + ], +) +def test_enginex_strips_xdist_load_shorthand( + monkeypatch: pytest.MonkeyPatch, dist_args: list[str] +) -> None: + """ + EngineX removes xdist's `-d` shorthand for `--dist=load`. + + `-d` overrides any `--dist` value within pytest-xdist's cmdline + hook, so overriding `--dist` alone is not enough. + """ + monkeypatch.delenv("HIVE_PARALLELISM", raising=False) + + with pytest.warns(UserWarning, match="requires `--dist=loadgroup`"): + args = HiveEnvironmentProcessor("enginex").process_args( + ["-n=6", *dist_args] + ) + + assert "-d" not in args + if "--dist" in args: + assert args[args.index("--dist") + 1] == "loadgroup" + else: + assert "--dist=loadgroup" in args + + +def test_enginex_without_parallelism_still_sets_loadgroup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Loadgroup is set even without xdist args (inert without `-n`).""" + monkeypatch.delenv("HIVE_PARALLELISM", raising=False) + + args = HiveEnvironmentProcessor("enginex").process_args([]) + + assert args[args.index("--dist") + 1] == "loadgroup" + + +def test_consume_engine_parallelism_does_not_force_loadgroup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The loadgroup override is scoped to consume enginex.""" + monkeypatch.delenv("HIVE_PARALLELISM", raising=False) + + args = HiveEnvironmentProcessor("engine").process_args(["-n=6"]) + + assert "--dist" not in args + assert "--dist=loadgroup" not in args diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/processors.py b/packages/testing/src/execution_testing/cli/pytest_commands/processors.py index d23174fc34e..cdf137c6998 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/processors.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/processors.py @@ -108,11 +108,8 @@ def process_args(self, args: List[str]) -> List[str]: # For enginex: ensure xdist uses loadgroup distribution so tests with # the same xdist_group marker (pre-alloc group) run on the same worker - if self.command_name == "enginex" and self._has_parallelism_flag( - modified_args - ): - if "--dist" not in modified_args: - modified_args.extend(["--dist", "loadgroup"]) + if self.command_name == "enginex": + modified_args = self._ensure_loadgroup_dist(modified_args) if os.getenv("HIVE_RANDOM_SEED") is not None: warnings.warn( @@ -146,7 +143,75 @@ def _has_regex_or_sim_limit(self, args: List[str]) -> bool: def _has_parallelism_flag(self, args: List[str]) -> bool: """Check if args already contain parallelism flag.""" - return "-n" in args + return any( + arg.startswith("-n") + or arg == "--numprocesses" + or arg.startswith("--numprocesses=") + for arg in args + ) + + def _ensure_loadgroup_dist(self, args: List[str]) -> List[str]: + """ + Ensure EngineX xdist runs keep pre-alloc groups on one worker. + + EngineX client cleanup depends on each worker seeing every test in a + group. Any xdist distribution mode other than loadgroup can split a + pre-alloc group across workers, causing each worker to start its own + group client and defer cleanup until session teardown. + + `--dist=loadgroup` is inert when xdist is not active (no `-n`), so + it is safe to ensure unconditionally. + """ + if any("no:xdist" in arg for arg in args): + # Without xdist, `--dist` is an unknown argument. + return args[:] + modified_args = args[:] + found_dist = False + changed_dist = False + index = 0 + + while index < len(modified_args): + arg = modified_args[index] + if arg == "--dist": + found_dist = True + if index + 1 < len(modified_args): + value = modified_args[index + 1] + if value.startswith("-"): + # Malformed `--dist `: leave it for + # argparse to reject with a clear error. + index += 1 + continue + if value != "loadgroup": + modified_args[index + 1] = "loadgroup" + changed_dist = True + index += 2 + continue + modified_args.append("loadgroup") + changed_dist = True + elif arg.startswith("--dist="): + found_dist = True + if arg != "--dist=loadgroup": + modified_args[index] = "--dist=loadgroup" + changed_dist = True + elif arg == "-d": + # xdist's shorthand for `--dist=load`; it clobbers any + # `--dist` value during pytest-xdist's cmdline hook, so + # it must be removed rather than overridden. + del modified_args[index] + changed_dist = True + continue + index += 1 + + if not found_dist: + modified_args.extend(["--dist", "loadgroup"]) + if changed_dist: + warnings.warn( + "`consume enginex` requires `--dist=loadgroup`; overriding " + "the provided xdist distribution mode.", + stacklevel=2, + ) + + return modified_args class WatchFlagsProcessor(ArgumentProcessor): From c7691a64b4ebdde033f1e27fe2932e71f6a22eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Mon, 31 Aug 2026 18:40:45 +0200 Subject: [PATCH 33/59] feat(tests): EIP-7928 - storage reads of a recreated destroyed account (#3475) * feat(tests): EIP-7928 - storage reads of a recreated destroyed account Add `test_bal_create2_selfdestruct_then_recreate_and_write`: two transactions each recreate, write and destroy the same pre-funded CREATE2 address, so both wiped slots must reach the account's block-level `storage_reads`. PR #3399 was meant to cover erigon#23407 but does not reproduce it. It demotes its writes with `REVERT` and never destroys the account, so it misses the self-destruct read path the bug lives in. Verified against erigon main with the fix reverted: this test fails and #3399's passes, and it is the only fixture in the EIP-7928 suite that discriminates. * chore(tests): correct test based on wording in test_cases.md; add beneficiary check --------- Co-authored-by: fselmo --- .../test_block_access_lists_opcodes.py | 72 +++++++++++++++++++ .../test_cases.md | 1 + 2 files changed, 73 insertions(+) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index da11a3d19cd..a1218c87733 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -40,6 +40,7 @@ Transaction, compute_create_address, ) +from execution_testing import Macros as Om from .spec import ref_spec_7928 from .test_block_access_lists_eip4788 import SYSTEM_ADDRESS @@ -3929,6 +3930,77 @@ def test_bal_create2_selfdestruct_then_recreate_same_block( ) +def test_bal_create2_selfdestruct_then_recreate_and_write( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure `storage_reads` unions the wiped `SSTORE`s of an address that two + transactions each recreate, write and destroy at the same CREATE2 + destination. + + Reported in https://github.com/erigontech/erigon/issues/23407. + """ + alice = pre.fund_eoa() + beneficiary = pre.fund_eoa(amount=0) + salt = 0 + target_balance = 100 + + # The balance names the slot, so the second transaction cannot pick its + # own until the first one has drained the account. + initcode = bytes( + Op.SSTORE(Op.SELFBALANCE, 0xCAFE) + Op.SELFDESTRUCT(beneficiary) + ) + factory = pre.deploy_contract( + code=Om.MSTORE(initcode, 0) + + Op.POP(Op.CREATE2(offset=0, size=len(initcode), salt=salt)) + ) + target = compute_create_address( + address=factory, + salt=salt, + initcode=initcode, + opcode=Op.CREATE2, + ) + pre.fund_address(target, target_balance) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[Transaction(sender=alice, to=factory) for _ in range(2)], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + target: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=0 + ), + ], + nonce_changes=[], + code_changes=[], + storage_changes=[], + storage_reads=[0, target_balance], + ), + beneficiary: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=target_balance, + ), + ], + ), + } + ), + ) + ], + post={ + target: Account.NONEXISTENT, + beneficiary: Account(balance=target_balance), + factory: Account(nonce=3), + }, + ) + + @pytest.mark.parametrize( "destruction_successful,oracle_suffix", [ diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 808f10a1207..5e7471419f5 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -184,6 +184,7 @@ | `test_bal_2935_selfdestruct_to_history_storage` | Ensure BAL captures `SELFDESTRUCT` to EIP-2935 history storage address | Single block: Transaction where Alice calls contract (pre-funded with 100 wei) that selfdestructs with `HISTORY_STORAGE_ADDRESS` as beneficiary. | BAL **MUST** include at `block_access_index=1`: Alice with `nonce_changes`, contract with `balance_changes` (100→0), `HISTORY_STORAGE_ADDRESS` with `balance_changes` (receives 100 wei). | ✅ Completed | | `test_bal_2935_invalid_calldata_size` | Ensure BAL correctly handles EIP-2935 queries with invalid calldata size (reverts before any storage access) | Parameterized test: Block 1 stores genesis hash via system call. Block 2: Oracle contract calls `HISTORY_STORAGE_ADDRESS` with invalid calldata sizes (0, 31, 33 bytes). EIP-2935 requires exactly 32 bytes calldata; any other size causes immediate revert before storage access. Optional value transfer (0 or 100 wei). | Block 2 BAL **MUST** include: `HISTORY_STORAGE_ADDRESS` with NO `storage_reads` (calldata size check fails before any SLOAD) and NO `balance_changes` (call reverts). Oracle with `storage_reads` [0] (implicit SLOAD from no-op SSTORE), NO `storage_changes`, and `balance_changes` if value > 0 (value stays in oracle on revert). Alice with `nonce_changes`. | ✅ Completed | | `test_bal_create2_selfdestruct_then_recreate_same_block` | Ensure BAL handles **(tx1) CREATE2+SSTORE+SELFDESTRUCT** then **(tx2) CREATE2 "resurrection"** of the *same address* in the same block. Parametrized over `pre_balance: [0, 100]`. | Two identical txs invoke the same factory with the same initcode (same hash → same CREATE2 address A). The factory branches on its own `storage[1]`: on the first tx, slot 1 is 0 so the factory CREATE2's then CALLs A (runtime SSTOREs to a target slot then SELFDESTRUCTs to beneficiary) and records the CALL's return code in slot 1; on the second tx, slot 1 is non-zero so only CREATE2 runs and A persists with the runtime code (its runtime is never executed). When `pre_balance > 0`, A is pre-funded so Tx1's SELFDESTRUCT transfers a real balance. | At `block_access_index=1` (destructed A): **MUST NOT** include `nonce_changes` or `code_changes` for A (EIP-7928 SELFDESTRUCT-in-tx semantics); the SSTORE is demoted to `storage_reads` for the target slot (write demoted because A is destroyed same-tx). `balance_changes` for A appears only when pre-funded. Beneficiary appears with `balance_changes` if pre-funded, otherwise `empty()` (SELFDESTRUCT touches the beneficiary even with 0 value). At `block_access_index=2` (resurrection): A has `nonce_changes` (post=1) and `code_changes` (post=runtime). Post-state: A has empty `storage={}` (the tx1 SSTORE was wiped; tx2 never executed the runtime). Factory's `storage[1] = 1` confirms the Tx1 CALL went through. | ✅ Completed | +| `test_bal_create2_selfdestruct_then_recreate_and_write` | Ensure an account's block-level `storage_reads` unions the wiped `SSTORE`s of an address that two transactions each recreate, write and destroy at the same CREATE2 destination. Regression for [erigon#23407](https://github.com/erigontech/erigon/issues/23407). | Two identical txs from Alice call a factory that `CREATE2`s the same initcode at pre-funded address A. The initcode `SSTORE`s `0xCAFE` to the slot named by `SELFBALANCE` then `SELFDESTRUCT`s to beneficiary, so A is created, written and destroyed within each tx and the write is wiped. Tx1 inherits A's pre-funded balance (slot `0x64`) and drains it, so tx2 finds the account empty (slot `0x00`). | BAL **MUST** include A with `storage_reads` for both `0x00` and `0x64`, empty `storage_changes`, no `nonce_changes` and no `code_changes` (EIP-7928 SELFDESTRUCT-in-tx semantics), and `balance_changes` (100→0) at `block_access_index=1`. Post-state: A does not exist and beneficiary holds 100. | ✅ Completed | | `test_bal_call_with_value_in_static_context` | CALL with nonzero value in static context: target NOT in BAL | Parametrized: `target_is_warm` (cold/warm via access list), `target_has_code` (EOA/contract). Static check must fire before account access. | `target` **MUST NOT** appear in BAL. Balances unchanged. | ✅ Completed | | `test_bal_create_in_static_context` | CREATE/CREATE2 in static context: created address NOT in BAL | Parametrized: `@pytest.mark.with_all_create_opcodes`, `value` (0/1). Static check must fire before balance check, address computation, or nonce increment. | Created address **MUST NOT** appear in BAL. Factory nonce unchanged. | ✅ Completed | | `test_bal_selfdestruct_in_static_context` | SELFDESTRUCT in static context: beneficiary NOT in BAL | Parametrized: `beneficiary_is_warm` (cold/warm via access list), `caller_balance` (0/100). Static check must fire before beneficiary access or balance transfer. | `beneficiary` **MUST NOT** appear in BAL. Balances unchanged. | ✅ Completed | From c563375f6892f0ea879e497ba6fe461d4eb54310 Mon Sep 17 00:00:00 2001 From: spencer Date: Mon, 31 Aug 2026 20:31:46 +0200 Subject: [PATCH 34/59] chore(tooling): forbid Claude session links in commits and PRs (#3481) --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index e29e0084072..809c5043f86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,7 @@ When done with changes, ask the user if they'd like to run `/lint` before commit - `mainnet` = stable specs for forks live on mainnet - PRs target the default branch - PRs strictly follow the template in `.github/PULL_REQUEST_TEMPLATE.md`. +- Never add Claude attribution links (`Claude-Session:` trailers or `claude.ai` URLs) to commit messages or PR descriptions. ## PR Reviews From 6eb68ed3e611a25c8d96292ec61e0aee37f894b4 Mon Sep 17 00:00:00 2001 From: felipe Date: Mon, 31 Aug 2026 16:29:04 -0600 Subject: [PATCH 35/59] feat(tests): EIP-7928 max nonce boundary check for BAL; update refspec (#3482) --- .../eip7928_block_level_access_lists/spec.py | 2 +- .../test_block_access_lists_opcodes.py | 125 +++++++++++++++++- .../test_cases.md | 1 + 3 files changed, 126 insertions(+), 2 deletions(-) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/spec.py b/tests/amsterdam/eip7928_block_level_access_lists/spec.py index f094e07f29c..b51e8d412ec 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/spec.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/spec.py @@ -13,7 +13,7 @@ class ReferenceSpec: ref_spec_7928 = ReferenceSpec( git_path="EIPS/eip-7928.md", - version="aca88aa0932580c29d0233f902cb4390e88b8c41", + version="d6f0b763bcb92d19ea342d3e09550853c741246e", ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index a1218c87733..74aa1a089a9 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -37,12 +37,13 @@ Fork, Initcode, Op, + StateTestFiller, Transaction, compute_create_address, ) from execution_testing import Macros as Om -from .spec import ref_spec_7928 +from .spec import Spec, ref_spec_7928 from .test_block_access_lists_eip4788 import SYSTEM_ADDRESS REFERENCE_SPEC_GIT_PATH = ref_spec_7928.git_path @@ -3571,6 +3572,128 @@ def test_bal_create_early_failure( ) +@pytest.mark.with_all_create_opcodes +@pytest.mark.parametrize( + "factory_nonce", + [ + pytest.param(Spec.MAX_NONCE, id="nonce_at_max"), + pytest.param(Spec.MAX_NONCE - 1, id="nonce_below_max"), + ], +) +def test_bal_create_nonce_overflow( + pre: Alloc, + state_test: StateTestFiller, + create_opcode: Op, + factory_nonce: int, +) -> None: + """ + Test BAL with the factory's nonce at the EIP-2681 boundary. + + At the maximum nonce the creation fails before the computed address + is accessed, so the address MUST NOT appear in the BAL; one below + the maximum the creation proceeds and the address appears with its + deployed nonce and code. + """ + alice = pre.fund_eoa() + + init_code = Initcode(deploy_code=Op.STOP) + init_code_bytes = bytes(init_code) + + factory_code = ( + Op.MSTORE(0, Op.PUSH32(init_code_bytes)) + + Op.SSTORE( + 0x00, + Op.GT( + create_opcode( + value=0, + offset=32 - len(init_code_bytes), + size=len(init_code_bytes), + ), + 0, + ), + ) + + Op.STOP + ) + + factory = pre.deploy_contract( + code=factory_code, + nonce=factory_nonce, + storage={0x00: 0xDEAD}, + ) + + target = compute_create_address( + address=factory, + nonce=factory_nonce, + salt=0, + initcode=init_code_bytes, + opcode=create_opcode, + ) + + tx = Transaction(sender=alice, to=factory) + + factory_nonce_changes: list[BalNonceChange] + target_expectation: BalAccountExpectation | None + target_post: Account | None + + if factory_nonce == Spec.MAX_NONCE: + create_result = 0 + factory_nonce_changes = [] + target_expectation = None + target_post = Account.NONEXISTENT + elif factory_nonce == Spec.MAX_NONCE - 1: + create_result = 1 + factory_nonce_changes = [ + BalNonceChange(block_access_index=1, post_nonce=Spec.MAX_NONCE) + ] + target_expectation = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + code_changes=[ + BalCodeChange(block_access_index=1, new_code=bytes(Op.STOP)) + ], + ) + target_post = Account(nonce=1, code=Op.STOP) + else: + raise ValueError(f"Invariant: unhandled factory_nonce {factory_nonce}") + + state_test( + pre=pre, + post={ + alice: Account(nonce=1), + # At the boundary the nonce is unchanged; one below, it is + # incremented into it. Both arms end at the maximum. + factory: Account( + nonce=Spec.MAX_NONCE, storage={0x00: create_result} + ), + target: target_post, + }, + tx=tx, + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + factory: BalAccountExpectation( + nonce_changes=factory_nonce_changes, + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange( + block_access_index=1, + post_value=create_result, + ) + ], + ) + ], + ), + target: target_expectation, + } + ), + ) + + @pytest.mark.with_all_create_opcodes @pytest.mark.parametrize("creation_outcome", ["pre_frame_failure", "success"]) def test_bal_create_existing_target( diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 5e7471419f5..22cfe27b2a3 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -96,6 +96,7 @@ | `test_bal_create_contract_init_revert` | Ensure BAL correctly handles CREATE when parent call reverts | Caller calls factory, factory executes CREATE (succeeds), then factory REVERTs rolling back the CREATE | BAL **MUST** include Alice with `nonce_changes`. Caller and factory with no changes (reverted). Created contract address appears in BAL but **MUST NOT** have `nonce_changes` or `code_changes` (CREATE was rolled back). Contract address **MUST NOT** exist in post-state. | ✅ Completed | | `test_bal_create_oog_code_deposit` | Ensure BAL correctly handles CREATE OOG during code deposit | Alice calls factory contract that executes CREATE with init code returning 10,000 bytes. Transaction has insufficient gas for code deposit. Factory nonce increments, CREATE returns 0 and stores in slot 1. | BAL **MUST** include Alice with `nonce_changes`. Factory with `nonce_changes` (incremented by CREATE) and `storage_changes` (slot 1 = 0). Contract address with empty changes (read during collision check). **MUST NOT** include nonce or code changes for contract address (rolled back on OOG). Contract address **MUST NOT** exist in post-state. | ✅ Completed | | `test_bal_create_early_failure` | Ensure BAL omits would-be address when CREATE/CREATE2 fails because of insufficient balance. | Factory with balance below the endowment attempts CREATE/CREATE2; the call fails before the address is accessed. | Alice: `nonce_changes`. Factory: `storage_changes` (CREATE returned 0), no `nonce_changes`. Would-be address **MUST NOT** appear in BAL. | ✅ Completed | +| `test_bal_create_nonce_overflow` | Ensure BAL omits the computed address when CREATE/CREATE2 fails at the EIP-2681 nonce boundary, and includes it one below the boundary. Parametrized: `@pytest.mark.with_all_create_opcodes`, `factory_nonce ∈ {2^64-1, 2^64-2}`. | Factory with nonce at (or one below) the EIP-2681 maximum attempts CREATE/CREATE2 with zero endowment. At the maximum, the preflight fails before the computed address is accessed. | `nonce_at_max`: Alice: `nonce_changes`. Factory: `storage_changes` (create returned 0), no `nonce_changes` (increment never runs). Computed address **MUST NOT** appear in BAL. `nonce_below_max`: Factory: `nonce_changes` (→ 2^64-1) and `storage_changes` (create returned 1); target appears with `nonce_changes` and `code_changes`. | ✅ Completed | | `test_bal_invalid_missing_nonce` | Verify clients reject blocks with BAL missing required nonce changes | Alice sends transaction to Bob; BAL modifier removes Alice's nonce change entry | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate that all sender accounts have nonce changes recorded. | ✅ Completed | | `test_bal_invalid_nonce_value` | Verify clients reject blocks with incorrect nonce values in BAL | Alice sends transaction to Bob; BAL modifier changes Alice's nonce to incorrect value | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate nonce values match actual state transitions. | ✅ Completed | | `test_bal_invalid_storage_value` | Verify clients reject blocks with incorrect storage values in BAL | Alice calls contract that writes to storage; BAL modifier changes storage value to incorrect value | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate storage change values match actual state transitions. | ✅ Completed | From e5b6e3b5ccd68eb47f2cba35da16cd2b240cb8b8 Mon Sep 17 00:00:00 2001 From: spencer Date: Tue, 1 Sep 2026 02:47:47 +0200 Subject: [PATCH 36/59] fix(test-forks): model the pre-Berlin gas and refund schedules (#3480) * fix(test-forks): price pre-Berlin account and storage access flat in the opcode gas model Claude-Session: https://claude.ai/code/session_01YN5aAKMhVpZwfFYXAKEuxp * fix(test-forks): model the pre-Berlin SSTORE, call value and refund schedules Claude-Session: https://claude.ai/code/session_01YN5aAKMhVpZwfFYXAKEuxp * fix(tests): use the metadata call cost in the byzantium precompile gas tests Claude-Session: https://claude.ai/code/session_01YN5aAKMhVpZwfFYXAKEuxp --- .../forks/forks/eips/berlin/eip_2929.py | 128 ++++++++++++ .../forks/eips/constantinople/eip_1052.py | 12 +- .../forks/forks/eips/istanbul/eip_1884.py | 12 ++ .../forks/forks/eips/istanbul/eip_2200.py | 76 +++++++ .../forks/forks/eips/london/eip_3529.py | 6 +- .../forks/eips/spurious_dragon/eip_161.py | 15 +- .../forks/eips/tangerine_whistle/__init__.py | 1 + .../forks/eips/tangerine_whistle/eip_150.py | 43 ++++ .../execution_testing/forks/forks/forks.py | 128 ++++++------ .../src/execution_testing/forks/gas_costs.py | 9 + .../forks/tests/test_opcode_gas_costs.py | 197 +++++++++++++++++- tests/byzantium/eip196_ec_add_mul/test_gas.py | 9 +- tests/byzantium/eip197_ec_pairing/test_gas.py | 9 +- 13 files changed, 551 insertions(+), 94 deletions(-) create mode 100644 packages/testing/src/execution_testing/forks/forks/eips/berlin/eip_2929.py create mode 100644 packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_2200.py create mode 100644 packages/testing/src/execution_testing/forks/forks/eips/tangerine_whistle/__init__.py create mode 100644 packages/testing/src/execution_testing/forks/forks/eips/tangerine_whistle/eip_150.py diff --git a/packages/testing/src/execution_testing/forks/forks/eips/berlin/eip_2929.py b/packages/testing/src/execution_testing/forks/forks/eips/berlin/eip_2929.py new file mode 100644 index 00000000000..600776655ca --- /dev/null +++ b/packages/testing/src/execution_testing/forks/forks/eips/berlin/eip_2929.py @@ -0,0 +1,128 @@ +""" +EIP-2929: Gas cost increases for state access opcodes. + +Replace the flat account and storage access costs with warm and cold +pricing driven by the `address_warm` and `key_warm` opcode metadata. + +https://eips.ethereum.org/EIPS/eip-2929 +""" + +from typing import Callable, Dict + +from execution_testing.vm import OpcodeBase, Opcodes + +from ....base_fork import BaseFork +from ....gas_costs import GasCosts + + +class EIP2929(BaseFork): + """EIP-2929 class.""" + + @classmethod + def _call_access_cost(cls, opcode: OpcodeBase, gas_costs: GasCosts) -> int: + """Price the CALL family target access by warmth.""" + if opcode.metadata["address_warm"]: + return gas_costs.WARM_ACCESS + return gas_costs.COLD_ACCOUNT_ACCESS + + @classmethod + def _calculate_sstore_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """Charge SSTORE by net gas metering with warm and cold keys.""" + metadata = opcode.metadata + + original_value = metadata["original_value"] + current_value = metadata["current_value"] + if current_value is None: + current_value = original_value + new_value = metadata["new_value"] + + gas_cost = 0 if metadata["key_warm"] else gas_costs.COLD_STORAGE_ACCESS + + if original_value == current_value and current_value != new_value: + if original_value == 0: + gas_cost += gas_costs.STORAGE_SET + else: + gas_cost += ( + gas_costs.COLD_STORAGE_WRITE + - gas_costs.COLD_STORAGE_ACCESS + ) + else: + gas_cost += gas_costs.WARM_SLOAD + + return gas_cost + + @classmethod + def _calculate_sstore_refund( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """Refund SSTORE by net gas metering with warm and cold keys.""" + metadata = opcode.metadata + + original_value = metadata["original_value"] + current_value = metadata["current_value"] + if current_value is None: + current_value = original_value + new_value = metadata["new_value"] + + refund = 0 + if current_value != new_value: + if original_value != 0 and current_value != 0 and new_value == 0: + # Storage is cleared for the first time in the transaction + refund += gas_costs.REFUND_STORAGE_CLEAR + + if original_value != 0 and current_value == 0: + # Gas refund issued earlier to be reversed + refund -= gas_costs.REFUND_STORAGE_CLEAR + + if original_value == new_value: + # Storage slot being restored to its original value + if original_value == 0: + # Slot was originally empty and was SET earlier + refund += gas_costs.STORAGE_SET - gas_costs.WARM_SLOAD + else: + # Slot was originally non-empty and was UPDATED earlier + refund += ( + gas_costs.COLD_STORAGE_WRITE + - gas_costs.COLD_STORAGE_ACCESS + - gas_costs.WARM_SLOAD + ) + + return refund + + @classmethod + def _selfdestruct_access_cost( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """Charge cold beneficiary access.""" + if opcode.metadata["address_warm"]: + return 0 + return gas_costs.COLD_ACCOUNT_ACCESS + + @classmethod + def opcode_gas_map( + cls, + ) -> Dict[OpcodeBase, int | Callable[[OpcodeBase], int]]: + """Price the account and storage access opcodes by warmth.""" + gas_costs = cls.gas_costs() + memory_expansion_calculator = cls.memory_expansion_gas_calculator() + base_map = super(EIP2929, cls).opcode_gas_map() + return { + **base_map, + Opcodes.BALANCE: cls._with_account_access(0, gas_costs), + Opcodes.EXTCODESIZE: cls._with_account_access(0, gas_costs), + Opcodes.EXTCODECOPY: cls._with_memory_expansion( + cls._with_data_copy( + cls._with_account_access(0, gas_costs), + gas_costs, + ), + memory_expansion_calculator, + ), + Opcodes.EXTCODEHASH: cls._with_account_access(0, gas_costs), + Opcodes.SLOAD: lambda op: ( + gas_costs.WARM_SLOAD + if op.metadata["key_warm"] + else gas_costs.COLD_STORAGE_ACCESS + ), + } diff --git a/packages/testing/src/execution_testing/forks/forks/eips/constantinople/eip_1052.py b/packages/testing/src/execution_testing/forks/forks/eips/constantinople/eip_1052.py index 5845bf12c99..0ccba91db22 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/constantinople/eip_1052.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/constantinople/eip_1052.py @@ -7,16 +7,26 @@ https://eips.ethereum.org/EIPS/eip-1052 """ +from dataclasses import replace from typing import Callable, Dict, List from execution_testing.vm import OpcodeBase, Opcodes from ....base_fork import BaseFork +from ....gas_costs import GasCosts class EIP1052(BaseFork): """EIP-1052 class.""" + @classmethod + def gas_costs(cls) -> GasCosts: + """Introduce the EXTCODEHASH gas cost.""" + return replace( + super(EIP1052, cls).gas_costs(), + OPCODE_EXTCODEHASH=400, + ) + @classmethod def opcode_gas_map( cls, @@ -26,7 +36,7 @@ def opcode_gas_map( base_map = super(EIP1052, cls).opcode_gas_map() return { **base_map, - Opcodes.EXTCODEHASH: cls._with_account_access(0, gas_costs), + Opcodes.EXTCODEHASH: gas_costs.OPCODE_EXTCODEHASH, } @classmethod diff --git a/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_1884.py b/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_1884.py index b79a3c1e4fa..78346dbaabd 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_1884.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_1884.py @@ -6,16 +6,28 @@ https://eips.ethereum.org/EIPS/eip-1884 """ +from dataclasses import replace from typing import Callable, Dict, List from execution_testing.vm import OpcodeBase, Opcodes from ....base_fork import BaseFork +from ....gas_costs import GasCosts class EIP1884(BaseFork): """EIP-1884 class.""" + @classmethod + def gas_costs(cls) -> GasCosts: + """Reprice the trie-size-dependent opcodes.""" + return replace( + super(EIP1884, cls).gas_costs(), + OPCODE_BALANCE=700, + OPCODE_SLOAD=800, + OPCODE_EXTCODEHASH=700, + ) + @classmethod def opcode_gas_map( cls, diff --git a/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_2200.py b/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_2200.py new file mode 100644 index 00000000000..adf4097657e --- /dev/null +++ b/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_2200.py @@ -0,0 +1,76 @@ +""" +EIP-2200: Structured definitions for net gas metering. + +Charge and refund SSTORE by the original, current and new value of the +slot. The dirty and no-op write cost is the SLOAD cost. EIP-1283 +introduced the same scheme at Constantinople and was reverted before +activation, so it is not modeled. + +https://eips.ethereum.org/EIPS/eip-2200 +""" + +from execution_testing.vm import OpcodeBase + +from ....base_fork import BaseFork +from ....gas_costs import GasCosts + + +class EIP2200(BaseFork): + """EIP-2200 class.""" + + @classmethod + def _calculate_sstore_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """Charge SSTORE by net gas metering.""" + metadata = opcode.metadata + + original_value = metadata["original_value"] + current_value = metadata["current_value"] + if current_value is None: + current_value = original_value + new_value = metadata["new_value"] + + if original_value == current_value and current_value != new_value: + if original_value == 0: + return gas_costs.STORAGE_SET + return gas_costs.COLD_STORAGE_WRITE + + # No-op and dirty writes charge the SLOAD cost. + return gas_costs.OPCODE_SLOAD + + @classmethod + def _calculate_sstore_refund( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """Refund SSTORE by net gas metering.""" + metadata = opcode.metadata + + original_value = metadata["original_value"] + current_value = metadata["current_value"] + if current_value is None: + current_value = original_value + new_value = metadata["new_value"] + + refund = 0 + if current_value != new_value: + if original_value != 0 and current_value != 0 and new_value == 0: + # Storage is cleared for the first time in the transaction + refund += gas_costs.REFUND_STORAGE_CLEAR + + if original_value != 0 and current_value == 0: + # Gas refund issued earlier to be reversed + refund -= gas_costs.REFUND_STORAGE_CLEAR + + if original_value == new_value: + # Storage slot being restored to its original value + if original_value == 0: + # Slot was originally empty and was SET earlier + refund += gas_costs.STORAGE_SET - gas_costs.OPCODE_SLOAD + else: + # Slot was originally non-empty and was UPDATED earlier + refund += ( + gas_costs.COLD_STORAGE_WRITE - gas_costs.OPCODE_SLOAD + ) + + return refund diff --git a/packages/testing/src/execution_testing/forks/forks/eips/london/eip_3529.py b/packages/testing/src/execution_testing/forks/forks/eips/london/eip_3529.py index 8baab195a29..a431bb1dc3a 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/london/eip_3529.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/london/eip_3529.py @@ -17,10 +17,14 @@ class EIP3529(BaseFork): @classmethod def gas_costs(cls) -> GasCosts: - """Storage clearing refund is reduced from 15000 to 4800.""" + """ + Reduce the storage clearing refund, remove the SELFDESTRUCT + refund. + """ return replace( super(EIP3529, cls).gas_costs(), REFUND_STORAGE_CLEAR=4_800, + REFUND_SELF_DESTRUCT=0, ) @classmethod diff --git a/packages/testing/src/execution_testing/forks/forks/eips/spurious_dragon/eip_161.py b/packages/testing/src/execution_testing/forks/forks/eips/spurious_dragon/eip_161.py index 2806276fb3e..318eaa4e5e2 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/spurious_dragon/eip_161.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/spurious_dragon/eip_161.py @@ -17,19 +17,12 @@ def _calculate_call_gas( cls, opcode: OpcodeBase, gas_costs: GasCosts ) -> int: """ - The call gas cost needs to take the value transfer - and account new into account. + Couple the new account charge to a value transfer, per the dead + account rules. The charge itself applies from Frontier. """ - base_cost = super(EIP161, cls)._calculate_call_gas(opcode, gas_costs) - - # Additional costs for value transfer, does not apply to STATICCALL metadata = opcode.metadata if "value_transfer" in metadata: - if metadata["value_transfer"]: - base_cost += gas_costs.CALL_VALUE - if metadata["account_new"]: - base_cost += gas_costs.NEW_ACCOUNT - elif metadata["account_new"]: + if metadata["account_new"] and not metadata["value_transfer"]: raise ValueError("Account new requires value transfer") - return base_cost + return super(EIP161, cls)._calculate_call_gas(opcode, gas_costs) diff --git a/packages/testing/src/execution_testing/forks/forks/eips/tangerine_whistle/__init__.py b/packages/testing/src/execution_testing/forks/forks/eips/tangerine_whistle/__init__.py new file mode 100644 index 00000000000..37271c994d8 --- /dev/null +++ b/packages/testing/src/execution_testing/forks/forks/eips/tangerine_whistle/__init__.py @@ -0,0 +1 @@ +"""Listings of all EIPs for Tangerine Whistle fork.""" diff --git a/packages/testing/src/execution_testing/forks/forks/eips/tangerine_whistle/eip_150.py b/packages/testing/src/execution_testing/forks/forks/eips/tangerine_whistle/eip_150.py new file mode 100644 index 00000000000..b7d07640e72 --- /dev/null +++ b/packages/testing/src/execution_testing/forks/forks/eips/tangerine_whistle/eip_150.py @@ -0,0 +1,43 @@ +""" +EIP-150: Gas cost changes for IO-heavy operations. + +Reprice the flat account and storage access costs. Only the costs +consumed by the opcode gas model are modeled here. + +https://eips.ethereum.org/EIPS/eip-150 +""" + +from dataclasses import replace + +from execution_testing.vm import OpcodeBase + +from ....base_fork import BaseFork +from ....gas_costs import GasCosts + + +class EIP150(BaseFork): + """EIP-150 class.""" + + @classmethod + def gas_costs(cls) -> GasCosts: + """Reprice the IO-heavy opcodes.""" + return replace( + super(EIP150, cls).gas_costs(), + OPCODE_BALANCE=400, + OPCODE_EXTERNAL_BASE=700, + OPCODE_CALL_BASE=700, + OPCODE_SLOAD=200, + OPCODE_SELFDESTRUCT_BASE=5_000, + ) + + @classmethod + def _calculate_selfdestruct_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """Charge for beneficiary creation, introduced by EIP-150.""" + base_cost = super(EIP150, cls)._calculate_selfdestruct_gas( + opcode, gas_costs + ) + if opcode.metadata["account_new"]: + base_cost += gas_costs.NEW_ACCOUNT + return base_cost diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 37562f59a01..72bb2b3c4b8 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -101,6 +101,10 @@ def gas_costs(cls) -> GasCosts: COLD_ACCOUNT_ACCESS=2_600, WARM_SLOAD=100, COLD_STORAGE_ACCESS=2_100, + OPCODE_BALANCE=20, + OPCODE_EXTERNAL_BASE=20, + OPCODE_CALL_BASE=40, + OPCODE_SLOAD=50, # Storage STORAGE_SET=20_000, COLD_STORAGE_WRITE=5_000, @@ -124,6 +128,7 @@ def gas_costs(cls) -> GasCosts: TX_CREATE=32_000, # Refunds REFUND_STORAGE_CLEAR=15_000, + REFUND_SELF_DESTRUCT=24_000, REFUND_AUTH_PER_EXISTING_ACCOUNT=0, # Precompiles PRECOMPILE_ECRECOVER=3_000, @@ -170,7 +175,7 @@ def gas_costs(cls) -> GasCosts: OPCODE_MLOAD_BASE=VERY_LOW, OPCODE_MSTORE_BASE=VERY_LOW, OPCODE_MSTORE8_BASE=VERY_LOW, - OPCODE_SELFDESTRUCT_BASE=5_000, + OPCODE_SELFDESTRUCT_BASE=0, OPCODE_COPY_PER_WORD=3, OPCODE_CREATE_BASE=32_000, OPCODE_EXP_BASE=10, @@ -377,7 +382,7 @@ def opcode_gas_map( memory_expansion_calculator, ), Opcodes.ADDRESS: gas_costs.BASE, - Opcodes.BALANCE: cls._with_account_access(0, gas_costs), + Opcodes.BALANCE: gas_costs.OPCODE_BALANCE, Opcodes.ORIGIN: gas_costs.BASE, Opcodes.CALLER: gas_costs.BASE, Opcodes.CALLVALUE: gas_costs.BASE, @@ -395,10 +400,10 @@ def opcode_gas_map( memory_expansion_calculator, ), Opcodes.GASPRICE: gas_costs.BASE, - Opcodes.EXTCODESIZE: cls._with_account_access(0, gas_costs), + Opcodes.EXTCODESIZE: gas_costs.OPCODE_EXTERNAL_BASE, Opcodes.EXTCODECOPY: cls._with_memory_expansion( cls._with_data_copy( - cls._with_account_access(0, gas_costs), + gas_costs.OPCODE_EXTERNAL_BASE, gas_costs, ), memory_expansion_calculator, @@ -422,11 +427,7 @@ def opcode_gas_map( gas_costs.OPCODE_MSTORE8_BASE, memory_expansion_calculator, ), - Opcodes.SLOAD: lambda op: ( - gas_costs.WARM_SLOAD - if op.metadata["key_warm"] - else gas_costs.COLD_STORAGE_ACCESS - ), + Opcodes.SLOAD: gas_costs.OPCODE_SLOAD, Opcodes.SSTORE: lambda op: cls._calculate_sstore_gas( op, gas_costs ), @@ -569,6 +570,11 @@ def opcode_refund_map( Opcodes.SSTORE: lambda op: cls._calculate_sstore_refund( op, gas_costs ), + Opcodes.SELFDESTRUCT: lambda op: ( + gas_costs.REFUND_SELF_DESTRUCT + if op.metadata["self_destructed_account"] + else 0 + ), } @classmethod @@ -618,37 +624,17 @@ def _calculate_sstore_refund( """Calculate SSTORE gas refund based on metadata.""" metadata = opcode.metadata - original_value = metadata["original_value"] current_value = metadata["current_value"] if current_value is None: - current_value = original_value + current_value = metadata["original_value"] new_value = metadata["new_value"] - # Refund is provided when setting from non-zero to zero - refund = 0 - if current_value != new_value: - if original_value != 0 and current_value != 0 and new_value == 0: - # Storage is cleared for the first time in the transaction - refund += gas_costs.REFUND_STORAGE_CLEAR - - if original_value != 0 and current_value == 0: - # Gas refund issued earlier to be reversed - refund -= gas_costs.REFUND_STORAGE_CLEAR - - if original_value == new_value: - # Storage slot being restored to its original value - if original_value == 0: - # Slot was originally empty and was SET earlier - refund += gas_costs.STORAGE_SET - gas_costs.WARM_SLOAD - else: - # Slot was originally non-empty and was UPDATED earlier - refund += ( - gas_costs.COLD_STORAGE_WRITE - - gas_costs.COLD_STORAGE_ACCESS - - gas_costs.WARM_SLOAD - ) - - return refund + # Every clearing write is refunded, no net metering before + # EIP-2200. + if current_value != 0 and new_value == 0: + return gas_costs.REFUND_STORAGE_CLEAR + + return 0 @classmethod def _calculate_sstore_gas( @@ -657,26 +643,39 @@ def _calculate_sstore_gas( """Calculate SSTORE gas cost based on metadata.""" metadata = opcode.metadata - original_value = metadata["original_value"] current_value = metadata["current_value"] if current_value is None: - current_value = original_value + current_value = metadata["original_value"] new_value = metadata["new_value"] - gas_cost = 0 if metadata["key_warm"] else gas_costs.COLD_STORAGE_ACCESS + # The charge depends on the current value only, no net metering + # before EIP-2200. + if current_value == 0 and new_value != 0: + return gas_costs.STORAGE_SET - if original_value == current_value and current_value != new_value: - if original_value == 0: - gas_cost += gas_costs.STORAGE_SET - else: - gas_cost += ( - gas_costs.COLD_STORAGE_WRITE - - gas_costs.COLD_STORAGE_ACCESS - ) - else: - gas_cost += gas_costs.WARM_SLOAD + return gas_costs.COLD_STORAGE_WRITE + + @classmethod + def _call_access_cost(cls, opcode: OpcodeBase, gas_costs: GasCosts) -> int: + """ + Return the CALL family account access cost. + + Flat before EIP-2929 introduces warm and cold pricing. + """ + del opcode + return gas_costs.OPCODE_CALL_BASE - return gas_cost + @classmethod + def _selfdestruct_access_cost( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """ + Return the SELFDESTRUCT beneficiary access cost. + + Zero before EIP-2929 introduces warm and cold pricing. + """ + del opcode, gas_costs + return 0 @classmethod def _calculate_call_gas( @@ -687,14 +686,18 @@ def _calculate_call_gas( """ metadata = opcode.metadata - # Base cost depends on address warmth - if metadata["address_warm"]: - base_cost = gas_costs.WARM_ACCESS - else: - base_cost = gas_costs.COLD_ACCOUNT_ACCESS + base_cost = cls._call_access_cost(opcode, gas_costs) if metadata["inner_call_cost"]: - return base_cost + metadata["inner_call_cost"] + base_cost += metadata["inner_call_cost"] + + # Value transfer and new account charges apply from Frontier. + # They are independent until EIP-161 couples the new account + # charge to a value transfer. + if "value_transfer" in metadata and metadata["value_transfer"]: + base_cost += gas_costs.CALL_VALUE + if "account_new" in metadata and metadata["account_new"]: + base_cost += gas_costs.NEW_ACCOUNT return base_cost @@ -733,17 +736,9 @@ def _calculate_selfdestruct_gas( cls, opcode: OpcodeBase, gas_costs: GasCosts ) -> int: """Calculate SELFDESTRUCT gas cost based on metadata.""" - metadata = opcode.metadata - base_cost = gas_costs.OPCODE_SELFDESTRUCT_BASE - # Check if the beneficiary is cold - if not metadata["address_warm"]: - base_cost += gas_costs.COLD_ACCOUNT_ACCESS - - # Check if creating a new account - if metadata["account_new"]: - base_cost += gas_costs.NEW_ACCOUNT + base_cost += cls._selfdestruct_access_cost(opcode, gas_costs) return base_cost @@ -1362,6 +1357,7 @@ class DAOFork( class TangerineWhistle( + eips.EIP150, DAOFork, ruleset_name="TANGERINE", ): @@ -1420,6 +1416,7 @@ class ConstantinopleFix( class Istanbul( + eips.EIP2200, eips.EIP2028, eips.EIP1884, eips.EIP1344, @@ -1444,6 +1441,7 @@ class MuirGlacier( class Berlin( eips.EIP2930, + eips.EIP2929, Istanbul, ): """Berlin fork.""" diff --git a/packages/testing/src/execution_testing/forks/gas_costs.py b/packages/testing/src/execution_testing/forks/gas_costs.py index 883cfd8953b..e06a6a48fc7 100644 --- a/packages/testing/src/execution_testing/forks/gas_costs.py +++ b/packages/testing/src/execution_testing/forks/gas_costs.py @@ -26,6 +26,14 @@ class GasCosts: COLD_ACCOUNT_ACCESS: int WARM_SLOAD: int COLD_STORAGE_ACCESS: int + # Flat access costs used before EIP-2929 introduces warm and cold + # pricing. Field names follow the EELS gas constants. + OPCODE_BALANCE: int + OPCODE_EXTERNAL_BASE: int + OPCODE_CALL_BASE: int + OPCODE_SLOAD: int + # Introduced by EIP-1052, zero before Constantinople. + OPCODE_EXTCODEHASH: int = 0 # Storage STORAGE_SET: int @@ -68,6 +76,7 @@ class GasCosts: # Refunds REFUND_STORAGE_CLEAR: int + REFUND_SELF_DESTRUCT: int REFUND_AUTH_PER_EXISTING_ACCOUNT: int # Precompiles diff --git a/packages/testing/src/execution_testing/forks/tests/test_opcode_gas_costs.py b/packages/testing/src/execution_testing/forks/tests/test_opcode_gas_costs.py index c8559088d5b..bff73004756 100644 --- a/packages/testing/src/execution_testing/forks/tests/test_opcode_gas_costs.py +++ b/packages/testing/src/execution_testing/forks/tests/test_opcode_gas_costs.py @@ -4,7 +4,14 @@ from execution_testing.vm import Bytecode, Op -from ..forks.forks import Homestead, Osaka +from ..forks.forks import ( + Berlin, + ConstantinopleFix, + Homestead, + Istanbul, + Osaka, + SpuriousDragon, +) from ..helpers import Fork @@ -322,7 +329,7 @@ pytest.param( Homestead, Op.CALL(address_warm=False, value_transfer=True, account_new=True), - Homestead.gas_costs().COLD_ACCOUNT_ACCESS, + 40 + 9_000 + 25_000, id="call_cold_account_new_homestead", ), pytest.param( @@ -419,6 +426,79 @@ Osaka.gas_costs().LOW, id="clz_osaka", ), + # Pre-Berlin flat access costs. Literal values are the point: + # they pin the historical schedule from the EELS vm/gas.py + # constants of each fork. + pytest.param(Homestead, Op.CALL, 40, id="call_homestead"), + pytest.param(SpuriousDragon, Op.CALL, 700, id="call_spurious_dragon"), + pytest.param( + SpuriousDragon, + Op.CALL(address_warm=True), + 700, + id="call_warmth_inert_spurious_dragon", + ), + pytest.param( + SpuriousDragon, + Op.CALL(address_warm=True, value_transfer=True, account_new=True), + 700 + 9_000 + 25_000, + id="call_value_new_account_spurious_dragon", + ), + pytest.param(Homestead, Op.BALANCE, 20, id="balance_homestead"), + pytest.param( + SpuriousDragon, Op.BALANCE, 400, id="balance_spurious_dragon" + ), + pytest.param(Istanbul, Op.BALANCE, 700, id="balance_istanbul"), + pytest.param(Homestead, Op.SLOAD, 50, id="sload_homestead"), + pytest.param( + SpuriousDragon, Op.SLOAD, 200, id="sload_spurious_dragon" + ), + pytest.param(Istanbul, Op.SLOAD, 800, id="sload_istanbul"), + pytest.param( + Homestead, Op.EXTCODESIZE, 20, id="extcodesize_homestead" + ), + pytest.param( + SpuriousDragon, + Op.EXTCODESIZE, + 700, + id="extcodesize_spurious_dragon", + ), + pytest.param( + ConstantinopleFix, + Op.EXTCODEHASH, + 400, + id="extcodehash_constantinople_fix", + ), + pytest.param(Istanbul, Op.EXTCODEHASH, 700, id="extcodehash_istanbul"), + pytest.param( + Homestead, Op.SELFDESTRUCT, 0, id="selfdestruct_homestead" + ), + pytest.param( + SpuriousDragon, + Op.SELFDESTRUCT, + 5_000, + id="selfdestruct_spurious_dragon", + ), + pytest.param( + SpuriousDragon, + Op.SELFDESTRUCT(account_new=True), + 5_000 + 25_000, + id="selfdestruct_new_account_spurious_dragon", + ), + # The new account charge is independent of a value transfer + # until EIP-161, and SELFDESTRUCT charges nothing before + # EIP-150. + pytest.param( + Homestead, + Op.CALL(account_new=True), + 40 + 25_000, + id="call_new_account_without_value_homestead", + ), + pytest.param( + Homestead, + Op.SELFDESTRUCT(account_new=True), + 0, + id="selfdestruct_new_account_homestead", + ), ], ) def test_opcode_gas_costs(fork: Fork, opcode: Op, expected_cost: int) -> None: # noqa: D103 @@ -488,6 +568,67 @@ def test_bytecode_gas_costs( # noqa: D103 0, id="mstore_no_refund", ), + # Legacy storage refunds, every clearing write refunds. + pytest.param( + Homestead, + Op.SSTORE(original_value=5, new_value=0), + 15_000, + id="sstore_clear_homestead", + ), + pytest.param( + Homestead, + Op.SSTORE(original_value=0, current_value=5, new_value=0), + 15_000, + id="sstore_re_clear_homestead", + ), + # EIP-2200 net metering refunds at Istanbul. + pytest.param( + Istanbul, + Op.SSTORE(original_value=5, current_value=6, new_value=5), + 5_000 - 800, + id="sstore_restore_reset_istanbul", + ), + pytest.param( + Istanbul, + Op.SSTORE(original_value=0, current_value=5, new_value=0), + 20_000 - 800, + id="sstore_restore_set_istanbul", + ), + # EIP-2929 warm and cold refund terms. + pytest.param( + Osaka, + Op.SSTORE(original_value=5, current_value=6, new_value=5), + Osaka.gas_costs().COLD_STORAGE_WRITE + - Osaka.gas_costs().COLD_STORAGE_ACCESS + - Osaka.gas_costs().WARM_SLOAD, + id="sstore_restore_reset_osaka", + ), + # EIP-3529 boundary for the storage clear refund. + pytest.param( + Berlin, + Op.SSTORE(original_value=5, new_value=0), + 15_000, + id="sstore_clear_berlin", + ), + # SELFDESTRUCT refund until EIP-3529 removes it. + pytest.param( + Homestead, + Op.SELFDESTRUCT(self_destructed_account=True), + 24_000, + id="selfdestruct_refund_homestead", + ), + pytest.param( + Berlin, + Op.SELFDESTRUCT(self_destructed_account=True), + 24_000, + id="selfdestruct_refund_berlin", + ), + pytest.param( + Osaka, + Op.SELFDESTRUCT(self_destructed_account=True), + 0, + id="selfdestruct_refund_osaka", + ), ], ) def test_opcode_refunds(fork: Fork, opcode: Op, expected_refund: int) -> None: # noqa: D103 @@ -604,6 +745,58 @@ def test_bytecode_refunds( # noqa: D103 + Osaka.gas_costs().STORAGE_RESET, id="sstore_clear_cold", # 5 → 0 ), + # Legacy SSTORE, charged by the current value only. Literal + # values pin the historical schedule from the EELS vm/gas.py + # constants of each fork. + pytest.param( + Homestead, + Op.SSTORE(new_value=5), + 20_000, + id="sstore_set_homestead", # 0 → 5 + ), + pytest.param( + Homestead, + Op.SSTORE(original_value=5, new_value=0), + 5_000, + id="sstore_clear_homestead", # 5 → 0 + ), + pytest.param( + Homestead, + Op.SSTORE(original_value=5, current_value=0, new_value=7), + 20_000, + id="sstore_dirty_set_homestead", # 5 → 0 → 7 + ), + pytest.param( + Homestead, + Op.SSTORE(key_warm=True, new_value=5), + 20_000, + id="sstore_warmth_inert_homestead", # 0 → 5 + ), + # EIP-2200 net metering at Istanbul. + pytest.param( + Istanbul, + Op.SSTORE(original_value=5, new_value=5), + 800, + id="sstore_noop_istanbul", # 5 → 5 + ), + pytest.param( + Istanbul, + Op.SSTORE(original_value=5, current_value=6, new_value=7), + 800, + id="sstore_dirty_istanbul", # 5 → 6 → 7 + ), + pytest.param( + Istanbul, + Op.SSTORE(new_value=5), + 20_000, + id="sstore_set_istanbul", # 0 → 5 + ), + pytest.param( + Istanbul, + Op.SSTORE(original_value=5, new_value=0), + 5_000, + id="sstore_clear_istanbul", # 5 → 0 + ), ], ) def test_sstore_gas_costs(fork: Fork, opcode: Op, expected_cost: int) -> None: diff --git a/tests/byzantium/eip196_ec_add_mul/test_gas.py b/tests/byzantium/eip196_ec_add_mul/test_gas.py index bcf5012bc25..3e2426033c7 100644 --- a/tests/byzantium/eip196_ec_add_mul/test_gas.py +++ b/tests/byzantium/eip196_ec_add_mul/test_gas.py @@ -14,7 +14,6 @@ from execution_testing import ( Macros as Om, ) -from execution_testing.forks.forks.forks import Berlin from execution_testing.vm import Opcodes as Op from .spec import PointG1, Scalar, Spec, ref_spec_196 @@ -116,12 +115,8 @@ def test_invalid_gas_consumption( address_warm=False ).gas_cost(fork) - # Pre-EIP-2929: fixed call = 700; Berlin+: warm access cost. - gas_costs = fork.gas_costs() - if fork >= Berlin: - staticcall_base = gas_costs.WARM_ACCESS - else: - staticcall_base = 700 + # Precompiles are warm from Berlin, flat call cost before. + staticcall_base = Op.STATICCALL(address_warm=True).gas_cost(fork) account = pre.deploy_contract( code=( diff --git a/tests/byzantium/eip197_ec_pairing/test_gas.py b/tests/byzantium/eip197_ec_pairing/test_gas.py index 1a1108c37f0..fa8222f3926 100644 --- a/tests/byzantium/eip197_ec_pairing/test_gas.py +++ b/tests/byzantium/eip197_ec_pairing/test_gas.py @@ -13,7 +13,6 @@ from execution_testing import ( Macros as Om, ) -from execution_testing.forks.forks.forks import Berlin from execution_testing.vm import Opcodes as Op from .spec import PointG1, Spec, ref_spec_197 @@ -106,12 +105,8 @@ def test_invalid_gas_consumption( address_warm=False ).gas_cost(fork) - # Pre-EIP-2929: fixed call = 700; Berlin+: warm access cost. - gas_costs = fork.gas_costs() - if fork >= Berlin: - staticcall_base = gas_costs.WARM_ACCESS - else: - staticcall_base = 700 + # Precompiles are warm from Berlin, flat call cost before. + staticcall_base = Op.STATICCALL(address_warm=True).gas_cost(fork) account = pre.deploy_contract( code=( From 459872749ad4a7449fa0fc7c366b771d9e8c2740 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Tue, 1 Sep 2026 17:16:52 +0200 Subject: [PATCH 37/59] fix(test-specs): calculate transaction fixture gas from context (#3491) --- .../specs/tests/test_transaction.py | 44 ++++++++++++++++++- .../execution_testing/specs/transaction.py | 7 +++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/execution_testing/specs/tests/test_transaction.py b/packages/testing/src/execution_testing/specs/tests/test_transaction.py index 95df5252d04..a58bc2c39f0 100644 --- a/packages/testing/src/execution_testing/specs/tests/test_transaction.py +++ b/packages/testing/src/execution_testing/specs/tests/test_transaction.py @@ -6,8 +6,9 @@ import pytest +from execution_testing import TestAddress from execution_testing.fixtures import TransactionFixture -from execution_testing.forks import Fork, Shanghai +from execution_testing.forks import Amsterdam, Fork, Shanghai from execution_testing.test_types import Transaction from ..transaction import TransactionTest @@ -51,3 +52,44 @@ def test_transaction_test_filling( remove_info_metadata(fixture) assert fixture == expected + + +@pytest.mark.parametrize( + "tx,expected_intrinsic_gas", + [ + pytest.param( + Transaction(gas_limit=100_000), + 15_000, + id="non_value_transfer", + ), + pytest.param( + Transaction(gas_limit=100_000, value=1), + 21_000, + id="value_transfer", + ), + pytest.param( + Transaction(gas_limit=100_000, to=TestAddress), + 12_000, + id="self_transfer", + ), + ], +) +def test_amsterdam_transaction_fixture_intrinsic_gas( + tx: Transaction, + expected_intrinsic_gas: int, +) -> None: + """Calculate Amsterdam intrinsic gas from transaction context.""" + fixture = ( + TransactionTest( + tx=tx.with_signature_and_sender(), + fork=Amsterdam, + ) + .generate( + t8n=None, # type: ignore + fixture_format=TransactionFixture, + ) + .fixture + ) + assert isinstance(fixture, TransactionFixture) + result = next(iter(fixture.result.values())) + assert result.intrinsic_gas == expected_intrinsic_gas diff --git a/packages/testing/src/execution_testing/specs/transaction.py b/packages/testing/src/execution_testing/specs/transaction.py index 0bc821e9ff3..142d2647fcc 100644 --- a/packages/testing/src/execution_testing/specs/transaction.py +++ b/packages/testing/src/execution_testing/specs/transaction.py @@ -15,6 +15,7 @@ TransactionFixture, ) from execution_testing.fixtures.transaction import FixtureResult +from execution_testing.recipient_type import RecipientType from execution_testing.test_types import Alloc, Transaction from .base import BaseTest, FillResult, OpMode @@ -62,6 +63,12 @@ def make_transaction_test_fixture( contract_creation=self.tx.to is None, access_list=self.tx.access_list, authorization_list_or_count=self.tx.authorization_list, + sends_value=self.tx.value > 0, + recipient_type=( + RecipientType.SELF + if self.tx.to == self.tx.sender + else RecipientType.CONTRACT + ), ) result = FixtureResult( exception=None, From 2909015e4275d047cebfc119653c0e123c8689cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Tue, 1 Sep 2026 17:20:35 +0200 Subject: [PATCH 38/59] feat(tests): add type-0 transaction RLP validity tests (#3156) * feat(tests): add type-0 transaction RLP validity tests Port the core malformation classes of the legacy TransactionTests suites (ttWrongRLP, ttNonce, ttValue, ttRSValue, ttVValue, ttAddress), which were never converted because the ported-static pipeline only handles state-test fillers and the raw malformed bytes cannot round-trip through a structured transaction model. A local RLP encoder builds each corruption deliberately, since a correct encoder cannot emit non-canonical forms: per-field leading zeros, 33-byte field overflows, 19 and 21 byte addresses, fields encoded as lists, structural corruptions of the outer list (truncation, trailing bytes, wrong element counts, header size mismatches, size with leading zeros), and well-encoded but invalid signature values. A valid re-encoded control case anchors the encoder to the framework's byte-exact output. The transaction_test fixture format records the declared exception without consulting the transition tool, so all 30 cases were verified externally by feeding the generated fixture bytes through EELS decode_transaction, recover_sender and validate_transaction at Frontier, London and Cancun: every invalid vector is rejected and the control is accepted with the matching sender. Notably the gas limit and gas price are unbounded scalars in the spec, so their oversized encodings are valid at the transaction level; the overflow cases cover the 256-bit bounded fields (nonce, value, r, s) only. * fix(tests): accept client-divergent transaction RLP exceptions Declare exception lists where clients legitimately report different errors for the same malformed transaction: - `header_declares_less`: the mutation leaves both a truncated final field and a trailing byte at the top level, so clients report it as either an EOF or a size error. - `v_29`: post EIP-155 clients may derive a chain id from any v other than 27 or 28 and reject the mismatch instead of the signature, as already documented in `test_bad_v_r_s`. * chore(tests): correct the transaction field overflow docstring The nonce is decoded as a 256-bit scalar by the spec; the 64-bit bound is an EIP-2681 validation rule, not a decoding one. Also note that the signature v is a bounded 256-bit field whose oversized encoding is uncovered only because no field-specific decoding exception exists. * feat(tests): add r and s field-as-list transaction RLP cases Extend `test_field_as_list` to the signature r and s fields, porting `TRANSCT_rvalue_GivenAsListCopier` and `TRANSCT_svalue_GivenAsListCopier` with the same `RLP_INVALID_SIGNATURE_R`/`_S` exceptions the legacy suite declares. The gas price and v fields remain uncovered for lack of a field-specific decoding exception. * feat(tests): add a non-canonical single-byte transaction RLP case Encode the single-byte nonce payload behind a one-byte string header (0x8101) instead of as the byte itself. This ports the `RLPIncorrectByteEncoding{00,01,127}Copier` legacy tests, which corrupt the nonce this way and declare `RLP_LEADING_ZEROS_NONCE_SIZE`. * feat(tests): add a data size leading zeros transaction RLP case Encode the size of the data field's long-form string header with a leading zero byte, porting `RLPArrayLengthWithFirstZerosCopier` with the `RLP_LEADING_ZEROS_DATA_SIZE` exception it declares. This covers the string-header variant of the list-header case already tested by the `list_size_leading_zeros` mutation. * feat(tests): add a zero v transaction signature case A zero v is well-encoded (empty payload) but is neither 27, 28 nor an EIP-155 value. Declare `INVALID_CHAINID` as an acceptable alternative for the same reason as the other invalid v cases: post EIP-155 clients may derive a chain id from any v other than 27 or 28. * chore(tests): cite more covered legacy transaction test fillers Add `ported_from` references for legacy fillers whose malformation class is already exercised by an existing case: - Leading zeros: the `tt{Nonce,GasPrice,GasLimit,Value}` zero-prefixed fillers and the `TRANSCT_*_Prefixed0000` copiers. - Overflow: the `TRANSCT_{r,s}value_TooLarge` copiers. - Address size: `AddressMoreThan20` and the `TRANSCT_to_*` copiers. - Field as list: the remaining `TRANSCT_*_GivenAsList` copiers. - Structure: `RLPTransactionGivenAsArray`, matching the `tx_as_byte_string` mutation. All referenced fillers were inspected at the pinned commit to confirm the corruption and declared exception match the covering case. * fix(tests): fund only senders that send in transaction RLP tests In execute mode, `pre.fund_eoa()` defers the funding amount until the EOA sends a transaction; an EOA that never sends one fails the run with "Sender balance must be set before sending". The senders of the corrupted transactions never send: only their raw serialization is submitted, expecting rejection. Fund them with `amount=0` so execute mode derives an address without scheduling a funding transaction. The signing keys are derived from the account content, so the corrupted vectors' bytes change; all vectors were re-verified against EELS decoding and validation at Frontier, London and Cancun. * chore(tests): mark transaction RLP tests as inclusion tests Each case asserts whether one transaction can be included in a block, which is what the `inclusion_test` marker denotes. * fix(tests): accept a type error for a transaction given as a byte string EIP-2718 reads a byte string in the transaction list as a typed transaction, so from Berlin on the corruption is reported as an unsupported transaction type rather than an RLP header error. Verified against EELS decoding at Frontier, Berlin and Cancun. --------- Co-authored-by: danceratopz --- .../validation/test_transaction_rlp.py | 538 ++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 tests/frontier/validation/test_transaction_rlp.py diff --git a/tests/frontier/validation/test_transaction_rlp.py b/tests/frontier/validation/test_transaction_rlp.py new file mode 100644 index 00000000000..e20dfa44ec2 --- /dev/null +++ b/tests/frontier/validation/test_transaction_rlp.py @@ -0,0 +1,538 @@ +"""Tests for RLP-level validity of type-0 transaction encodings.""" + +from typing import Dict, List, Mapping + +import pytest +from execution_testing import ( + Alloc, + Bytes, + Fork, + Transaction, + TransactionException, + TransactionTestFiller, +) + +pytestmark = [ + pytest.mark.valid_from("Frontier"), + pytest.mark.inclusion_test, +] + +LEGACY_TX_TESTS = ( + "https://github.com/ethereum/tests/blob/" + "c67e485ff8b5be9abc8ad15345ec21aa22e290d9/src/TransactionTestsFiller" +) + + +def encode_header(length: int, offset: int) -> bytes: + """Encode an RLP header for a payload of the given length.""" + if length < 56: + return bytes([offset + length]) + size = length.to_bytes((length.bit_length() + 7) // 8, "big") + return bytes([offset + 55 + len(size)]) + size + + +def rlp_bytes(payload: bytes) -> bytes: + """Encode a byte string.""" + if len(payload) == 1 and payload[0] < 0x80: + return payload + return encode_header(len(payload), 0x80) + payload + + +def rlp_list(items: List[bytes]) -> bytes: + """Encode a list of already-encoded items.""" + payload = b"".join(items) + return encode_header(len(payload), 0xC0) + payload + + +def int_payload(value: int) -> bytes: + """Return the canonical RLP payload of an integer.""" + if value == 0: + return b"" + return value.to_bytes((value.bit_length() + 7) // 8, "big") + + +def tx_fields(tx: Transaction) -> Dict[str, bytes]: + """ + Decompose a signed transaction into its canonical RLP payload per + field, using the framework's own field order and values. + """ + return { + name: bytes(el) if isinstance(el, bytes) else int_payload(int(el)) + for name, el in zip( + tx.get_rlp_fields(), + tx.to_list(signing=False), + strict=True, + ) + } + + +def signed_tx( + pre: Alloc, + fork: Fork, + nonce: int = 0, + data: bytes = b"", + funded: bool = True, +) -> Transaction: + """ + Build and sign the base type-0 transaction. + + Pass `funded=False` when the transaction is only used as an + encoding source and never sent, so that the execute mode does not + defer funding of a sender that never sends a transaction. + """ + return Transaction( + sender=pre.fund_eoa() if funded else pre.fund_eoa(amount=0), + to=pre.fund_eoa(amount=0), + nonce=nonce, + gas_price=10, + gas_limit=30_000, + value=1, + data=data, + protected=fork.supports_protected_txs(), + ).with_signature_and_sender() + + +def signed_tx_fields( + pre: Alloc, fork: Fork, nonce: int = 0, data: bytes = b"" +) -> Dict[str, bytes]: + """Build a signed type-0 transaction and decompose it.""" + return tx_fields( + signed_tx(pre, fork, nonce=nonce, data=data, funded=False) + ) + + +def encode_tx( + fields: Mapping[str, bytes], override: Mapping[str, bytes] | None = None +) -> bytes: + """ + Encode the transaction fields, allowing per-field pre-encoded + replacements. + """ + override = override or {} + return rlp_list( + [override.get(name, rlp_bytes(fields[name])) for name in fields] + ) + + +def invalid_tx( + pre: Alloc, + rlp: bytes, + error: TransactionException | list[TransactionException], +) -> Transaction: + """Return a transaction whose serialization is the given raw bytes.""" + tx = Transaction( + sender=pre.fund_eoa(amount=0), + to=0, + gas_price=10, + gas_limit=30_000, + error=error, + ) + tx.rlp_override = Bytes(rlp) + return tx + + +def test_valid_reencoded_transaction( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Verify the local field encoder reproduces the framework encoding + byte for byte, anchoring the malformation tests to a valid baseline. + """ + tx = signed_tx(pre, fork) + fields = tx_fields(tx) + assert encode_tx(fields) == bytes(tx.rlp()) + transaction_test(pre=pre, tx=tx) + + +@pytest.mark.ported_from( + [ + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPNonceWithFirstZerosCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPgasPriceWithFirstZerosCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPgasLimitWithFirstZerosCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPValueWithFirstZerosCopier.json", + f"{LEGACY_TX_TESTS}/ttRSValue/TransactionWithRvaluePrefixed00Filler.json", + f"{LEGACY_TX_TESTS}/ttRSValue/TransactionWithSvaluePrefixed00Filler.json", + f"{LEGACY_TX_TESTS}/ttRSValue/RightVRSTestVPrefixedBy0Filler.json", + f"{LEGACY_TX_TESTS}/ttNonce/TransactionWithLeadingZerosNonceFiller.json", + f"{LEGACY_TX_TESTS}/ttNonce/TransactionWithZerosBigIntFiller.json", + f"{LEGACY_TX_TESTS}/ttGasPrice/" + "TransactionWithLeadingZerosGasPriceFiller.json", + f"{LEGACY_TX_TESTS}/ttGasLimit/" + "TransactionWithLeadingZerosGasLimitFiller.json", + f"{LEGACY_TX_TESTS}/ttValue/TransactionWithLeadingZerosValueFiller.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_gasLimit_Prefixed0000Copier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_rvalue_Prefixed0000Copier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_svalue_Prefixed0000Copier.json", + ], +) +@pytest.mark.exception_test +@pytest.mark.parametrize( + "field, error", + [ + ("nonce", TransactionException.RLP_LEADING_ZEROS_NONCE), + ("gas_price", TransactionException.RLP_LEADING_ZEROS_GASPRICE), + ("gas_limit", TransactionException.RLP_LEADING_ZEROS_GASLIMIT), + ("value", TransactionException.RLP_LEADING_ZEROS_VALUE), + ("v", TransactionException.RLP_LEADING_ZEROS_V), + ("r", TransactionException.RLP_LEADING_ZEROS_R), + ("s", TransactionException.RLP_LEADING_ZEROS_S), + ], +) +def test_field_leading_zeros( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, + field: str, + error: TransactionException, +) -> None: + """ + Prefix one integer field's payload with a zero byte; the + non-canonical encoding must be rejected. The base nonce is zero, so + its variant is the classic zero-encoded-as-0x00 case. + """ + fields = signed_tx_fields(pre, fork) + corrupted = rlp_bytes(b"\x00" + fields[field]) + rlp = encode_tx(fields, {field: corrupted}) + transaction_test(pre=pre, tx=invalid_tx(pre, rlp, error)) + + +@pytest.mark.ported_from( + [ + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPIncorrectByteEncoding00Copier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPIncorrectByteEncoding01Copier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPIncorrectByteEncoding127Copier.json", + ], +) +@pytest.mark.exception_test +def test_non_canonical_single_byte( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Encode the single-byte nonce payload behind a one-byte string + header (0x8101) instead of as the byte itself; the non-canonical + encoding must be rejected. + """ + fields = signed_tx_fields(pre, fork, nonce=1) + payload = fields["nonce"] + assert len(payload) == 1 and payload[0] < 0x80 + rlp = encode_tx(fields, {"nonce": b"\x81" + payload}) + transaction_test( + pre=pre, + tx=invalid_tx( + pre, rlp, TransactionException.RLP_LEADING_ZEROS_NONCE_SIZE + ), + ) + + +@pytest.mark.ported_from( + [ + f"{LEGACY_TX_TESTS}/ttWrongRLP/" + "RLPArrayLengthWithFirstZerosCopier.json", + ], +) +@pytest.mark.exception_test +def test_data_size_leading_zeros( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Encode the size of the data field's long-form string header with a + leading zero byte; the non-canonical encoding must be rejected. + """ + fields = signed_tx_fields(pre, fork, data=b"\xff" * 64) + payload = fields["data"] + # The corruption assumes the long-form string header. + assert len(payload) >= 56 + size = len(payload).to_bytes(2, "big") + assert size[0] == 0 + corrupted = bytes([0x80 + 55 + len(size)]) + size + payload + rlp = encode_tx(fields, {"data": corrupted}) + transaction_test( + pre=pre, + tx=invalid_tx( + pre, rlp, TransactionException.RLP_LEADING_ZEROS_DATA_SIZE + ), + ) + + +@pytest.mark.ported_from( + [ + f"{LEGACY_TX_TESTS}/ttNonce/TransactionWithNonceOverflowFiller.json", + f"{LEGACY_TX_TESTS}/ttValue/TransactionWithHighValueOverflowFiller.json", + f"{LEGACY_TX_TESTS}/ttRSValue/TransactionWithRvalueOverflowFiller.json", + f"{LEGACY_TX_TESTS}/ttRSValue/TransactionWithSvalueOverflowFiller.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_rvalue_TooLargeCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_svalue_TooLargeCopier.json", + ], +) +@pytest.mark.exception_test +@pytest.mark.parametrize( + "field, error", + [ + ("nonce", TransactionException.RLP_INVALID_NONCE), + ("value", TransactionException.VALUE_OVERFLOW), + ("r", TransactionException.RLP_INVALID_SIGNATURE_R), + ("s", TransactionException.RLP_INVALID_SIGNATURE_S), + ], +) +def test_field_overflow( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, + field: str, + error: TransactionException, +) -> None: + """ + Encode one integer field as a 33-byte value (2**256), exceeding the + 256-bit width of the field. The spec decodes the nonce as a 256-bit + scalar as well; its 64-bit bound is a validation rule (EIP-2681), + not a decoding one, and clients that store the nonce in 64 bits + reject the oversized encoding all the same. + + The gas limit and gas price are unbounded scalars in the spec, so + oversized values there are not a decoding error and are rejected + only in block context. The signature v is also a bounded 256-bit + field, but has no field-specific decoding exception, so its + oversized encoding is not covered here. + """ + fields = signed_tx_fields(pre, fork) + corrupted = rlp_bytes(int_payload(2**256)) + rlp = encode_tx(fields, {field: corrupted}) + transaction_test(pre=pre, tx=invalid_tx(pre, rlp, error)) + + +@pytest.mark.ported_from( + [ + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPAddressWrongSizeCopier.json", + f"{LEGACY_TX_TESTS}/ttAddress/AddressLessThan20Filler.json", + f"{LEGACY_TX_TESTS}/ttAddress/AddressMoreThan20PrefixedBy0Filler.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPAddressWithFirstZerosCopier.json", + f"{LEGACY_TX_TESTS}/ttAddress/AddressMoreThan20Filler.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_to_Prefixed0000Copier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_to_TooLargeCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_to_TooShortCopier.json", + ], +) +@pytest.mark.exception_test +@pytest.mark.parametrize( + "size, error", + [ + (19, TransactionException.ADDRESS_TOO_SHORT), + (21, TransactionException.ADDRESS_TOO_LONG), + ], +) +def test_to_address_size( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, + size: int, + error: TransactionException, +) -> None: + """ + Encode the to field with a truncated 19-byte address or a 21-byte + address made of a zero byte prefixing a valid address. + """ + fields = signed_tx_fields(pre, fork) + if size < 20: + fields["to"] = fields["to"][:size] + else: + fields["to"] = fields["to"].rjust(size, b"\x00") + rlp = encode_tx(fields) + transaction_test(pre=pre, tx=invalid_tx(pre, rlp, error)) + + +@pytest.mark.ported_from( + [ + f"{LEGACY_TX_TESTS}/ttWrongRLP/" + "RLPElementIsListWhenItShouldntBeCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/" + "RLPElementIsListWhenItShouldntBe2Copier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_rvalue_GivenAsListCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_svalue_GivenAsListCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_data_GivenAsListCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_gasLimit_GivenAsListCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_to_GivenAsListCopier.json", + ], +) +@pytest.mark.exception_test +@pytest.mark.parametrize( + "field, error", + [ + ("nonce", TransactionException.RLP_INVALID_NONCE), + ("gas_limit", TransactionException.RLP_INVALID_GASLIMIT), + ("to", TransactionException.RLP_INVALID_TO), + ("value", TransactionException.RLP_INVALID_VALUE), + ("data", TransactionException.RLP_INVALID_DATA), + ("r", TransactionException.RLP_INVALID_SIGNATURE_R), + ("s", TransactionException.RLP_INVALID_SIGNATURE_S), + ], +) +def test_field_as_list( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, + field: str, + error: TransactionException, +) -> None: + """ + Encode one field as an RLP list instead of a byte string. + + The gas price and v fields are equally rejected but have no + field-specific decoding exception, so they are not covered. + """ + fields = signed_tx_fields(pre, fork) + corrupted = rlp_list([rlp_bytes(fields[field])]) + rlp = encode_tx(fields, {field: corrupted}) + transaction_test(pre=pre, tx=invalid_tx(pre, rlp, error)) + + +@pytest.mark.ported_from( + [ + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPExtraRandomByteAtTheEndCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_HeaderLargerThanRLP_0Copier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_HeaderGivenAsArray_0Copier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPListLengthWithFirstZerosCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/aMaliciousRLPCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPTransactionGivenAsArrayCopier.json", + ], +) +@pytest.mark.exception_test +@pytest.mark.parametrize( + "mutation, error", + [ + ("truncated", TransactionException.RLP_ERROR_EOF), + ("extra_byte", TransactionException.RLP_ERROR_SIZE), + ("too_few_elements", TransactionException.RLP_TOO_FEW_ELEMENTS), + ("too_many_elements", TransactionException.RLP_TOO_MANY_ELEMENTS), + ("header_declares_more", TransactionException.RLP_ERROR_EOF), + ( + "header_declares_less", + [ + TransactionException.RLP_ERROR_EOF, + TransactionException.RLP_ERROR_SIZE, + ], + ), + ( + "tx_as_byte_string", + [ + TransactionException.RLP_INVALID_HEADER, + TransactionException.TYPE_NOT_SUPPORTED, + ], + ), + ( + "list_size_leading_zeros", + TransactionException.RLP_ERROR_SIZE_LEADING_ZEROS, + ), + ], +) +def test_invalid_structure( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, + mutation: str, + error: TransactionException | list[TransactionException], +) -> None: + """ + Corrupt the RLP structure of the whole transaction. + + A list header that declares less than the actual payload leaves + both a truncated final field and a trailing byte at the top level, + so clients report it as either an EOF or a size error. + + Encoding the transaction as a byte string is a plain RLP error + before EIP-2718; afterwards the string is read as a typed + transaction whose type byte is unsupported. + """ + fields = signed_tx_fields(pre, fork) + items = [rlp_bytes(fields[name]) for name in fields] + payload = b"".join(items) + # The header mutations assume the long-form list header. + assert len(payload) >= 56 + good = encode_tx(fields) + if mutation == "truncated": + rlp = good[:-1] + elif mutation == "extra_byte": + rlp = good + b"\x00" + elif mutation == "too_few_elements": + rlp = rlp_list(items[:-1]) + elif mutation == "too_many_elements": + rlp = rlp_list(items + [rlp_bytes(b"")]) + elif mutation == "header_declares_more": + rlp = encode_header(len(payload) + 1, 0xC0) + payload + elif mutation == "header_declares_less": + rlp = encode_header(len(payload) - 1, 0xC0) + payload + elif mutation == "tx_as_byte_string": + rlp = encode_header(len(payload), 0x80) + payload + elif mutation == "list_size_leading_zeros": + size = len(payload).to_bytes(2, "big") + assert size[0] == 0 + rlp = bytes([0xC0 + 55 + len(size)]) + size + payload + transaction_test(pre=pre, tx=invalid_tx(pre, rlp, error)) + + +@pytest.mark.ported_from( + [ + f"{LEGACY_TX_TESTS}/ttRSValue/TransactionWithRvalue0Filler.json", + f"{LEGACY_TX_TESTS}/ttRSValue/TransactionWithSvalue0Filler.json", + f"{LEGACY_TX_TESTS}/ttVValue/V_wrongvalue_ffFiller.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/tr201506052141PYTHONCopier.json", + ], +) +@pytest.mark.exception_test +@pytest.mark.parametrize( + "field, payload, error", + [ + ("r", b"", TransactionException.INVALID_SIGNATURE_VRS), + ("s", b"", TransactionException.INVALID_SIGNATURE_VRS), + ( + "v", + b"", + [ + TransactionException.INVALID_SIGNATURE_VRS, + TransactionException.INVALID_CHAINID, + ], + ), + ( + "v", + b"\x1d", + [ + TransactionException.INVALID_SIGNATURE_VRS, + TransactionException.INVALID_CHAINID, + ], + ), + ( + "v", + b"\xff", + [ + TransactionException.INVALID_SIGNATURE_VRS, + TransactionException.INVALID_CHAINID, + ], + ), + ], + ids=["r_zero", "s_zero", "v_zero", "v_29", "v_255"], +) +def test_invalid_signature_values( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, + field: str, + payload: bytes, + error: TransactionException | list[TransactionException], +) -> None: + """ + Replace a signature field with a well-encoded but invalid value: + zero r or s, or a v that is neither 27, 28 nor an EIP-155 value. + + Before EIP-155 any v other than 27 or 28 is a plain invalid v; + afterwards clients may instead derive a chain id from the invalid + v and reject the transaction for the chain id mismatch. + """ + fields = signed_tx_fields(pre, fork) + fields[field] = payload + rlp = encode_tx(fields) + transaction_test(pre=pre, tx=invalid_tx(pre, rlp, error)) From ef914fe55dd252b60a718fec013ae2a6ee15054e Mon Sep 17 00:00:00 2001 From: felipe Date: Tue, 1 Sep 2026 11:44:01 -0600 Subject: [PATCH 39/59] chore(tests): clean up EIP-7928 spec.py ``Spec`` class (#3496) No spec-specific constants were useful to add here. This may change but the import from the relevant spec is preferred over adding them here if they are not BALs specific. --- .../eip7928_block_level_access_lists/spec.py | 24 +------------------ .../test_block_access_lists_opcodes.py | 16 +++++++------ 2 files changed, 10 insertions(+), 30 deletions(-) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/spec.py b/tests/amsterdam/eip7928_block_level_access_lists/spec.py index b51e8d412ec..eb781125d47 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/spec.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/spec.py @@ -18,26 +18,4 @@ class ReferenceSpec: class Spec: - """Constants and parameters from EIP-7928.""" - - # RLP encoding is used for block access list data structures - BAL_ENCODING_FORMAT: str = "RLP" - - # Maximum limits for block access list data structures - TARGET_MAX_GAS_LIMIT = 600_000_000 - MAX_TXS: int = 30_000 - MAX_SLOTS: int = 300_000 - MAX_ACCOUNTS: int = 300_000 - # TODO: Use this as a function of the current fork. - MAX_CODE_SIZE: int = 24_576 # 24 KiB - - # Type size constants - ADDRESS_SIZE: int = 20 # Ethereum address size in bytes - STORAGE_KEY_SIZE: int = 32 # Storage slot key size in bytes - STORAGE_VALUE_SIZE: int = 32 # Storage value size in bytes - HASH_SIZE: int = 32 # Hash size in bytes - - # Numeric type limits - MAX_TX_INDEX: int = 2**32 - 1 # uint32 max value - MAX_BALANCE: int = 2**128 - 1 # uint128 max value - MAX_NONCE: int = 2**64 - 1 # uint64 max value + """Constants from EIP-7928.""" diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index 74aa1a089a9..d1b1c4e7e4c 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -43,7 +43,9 @@ ) from execution_testing import Macros as Om -from .spec import Spec, ref_spec_7928 +from tests.frontier.eip2681_limit_account_nonce.spec import Spec as Spec2681 + +from .spec import ref_spec_7928 from .test_block_access_lists_eip4788 import SYSTEM_ADDRESS REFERENCE_SPEC_GIT_PATH = ref_spec_7928.git_path @@ -3576,8 +3578,8 @@ def test_bal_create_early_failure( @pytest.mark.parametrize( "factory_nonce", [ - pytest.param(Spec.MAX_NONCE, id="nonce_at_max"), - pytest.param(Spec.MAX_NONCE - 1, id="nonce_below_max"), + pytest.param(Spec2681.max_nonce, id="nonce_at_max"), + pytest.param(Spec2681.max_nonce - 1, id="nonce_below_max"), ], ) def test_bal_create_nonce_overflow( @@ -3635,15 +3637,15 @@ def test_bal_create_nonce_overflow( target_expectation: BalAccountExpectation | None target_post: Account | None - if factory_nonce == Spec.MAX_NONCE: + if factory_nonce == Spec2681.max_nonce: create_result = 0 factory_nonce_changes = [] target_expectation = None target_post = Account.NONEXISTENT - elif factory_nonce == Spec.MAX_NONCE - 1: + elif factory_nonce == Spec2681.max_nonce - 1: create_result = 1 factory_nonce_changes = [ - BalNonceChange(block_access_index=1, post_nonce=Spec.MAX_NONCE) + BalNonceChange(block_access_index=1, post_nonce=Spec2681.max_nonce) ] target_expectation = BalAccountExpectation( nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], @@ -3662,7 +3664,7 @@ def test_bal_create_nonce_overflow( # At the boundary the nonce is unchanged; one below, it is # incremented into it. Both arms end at the maximum. factory: Account( - nonce=Spec.MAX_NONCE, storage={0x00: create_result} + nonce=Spec2681.max_nonce, storage={0x00: create_result} ), target: target_post, }, From 70c3511ba1b454c6f0458e1e1b949cf383a6894e Mon Sep 17 00:00:00 2001 From: spencer Date: Tue, 1 Sep 2026 21:19:20 +0200 Subject: [PATCH 40/59] feat(tests): pin cross-frame state gas refund placement and settlement (#3490) * feat(tests): pin cross-frame state gas refund placement and settlement * feat(tests): EIP-8037 cross-frame refund split across a child's own spill Test that one frame's refund both repays a different slot's borrow and puts the excess in the reservoir, that the split state merges cleanly on success, and that it is fully unwound on revert and halt. * chore(tests): use fork transaction gas limit cap, not constant val * fix: apply comments from PR #3490 --------- Co-authored-by: fselmo --- .../test_state_gas_cross_frame_refund.py | 516 ++++++++++++++++++ 1 file changed, 516 insertions(+) create mode 100644 tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py new file mode 100644 index 00000000000..f772c9d8bc4 --- /dev/null +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py @@ -0,0 +1,516 @@ +""" +Test where a state gas refund lands when it is credited in a +different frame than the spilled charge it undoes. + +A state charge spilled from `gas_left` can be refunded in a child +frame. The credit lands in the child's reservoir and merges upward as +reservoir, so `gas_left` is never repaid mid-transaction. The parked +credit still funds later state creation at full price and returns to +the sender at settlement, so cross-frame placement opens no discount +on state and costs the sender nothing at the transaction boundary. + +The merge-time repayment proposed in [ethereum/EIPs#12265] +(https://github.com/ethereum/EIPs/pull/12265) moves the credit back +to `gas_left` when a successful child merges. The placement pins here +flip under it, while the settlement pins are placement-independent +and must hold unchanged. + +Tests for [EIP-8037: State Creation Gas Cost Increase] +(https://eips.ethereum.org/EIPS/eip-8037). +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Bytecode, + Fork, + Op, + Opcode, + StateTestFiller, + Transaction, + TransactionReceipt, +) + +from .spec import ref_spec_8037 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8037.git_path +REFERENCE_SPEC_VERSION = ref_spec_8037.version + +SLOT_X = 1 +SLOT_Y = 2 +SLOT_MARKER = 3 +SLOT_RESULT = 4 +SLOT_INCREASED = 5 + + +def window_cost_excess(result_sstore: Opcode = Op.SSTORE) -> Bytecode: + """ + Return code storing the first window's cost over the second's. + + Memory holds `g0`, `g1` and `g2` at 0, 32 and 64. The stored + value is `(g0 - g1) - (g1 - g2)`, the first window's cost minus + the second's, computed modulo 2**256. `result_sstore` lets + gas-settlement tests carry metadata on the storing opcode. + """ + return result_sstore( + SLOT_RESULT, + Op.SUB( + Op.ADD(Op.MLOAD(0), Op.MLOAD(64)), + Op.ADD(Op.MLOAD(32), Op.MLOAD(32)), + ), + ) + + +@pytest.mark.valid_from("EIP8037") +def test_cross_frame_refund_parks_in_reservoir( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Test a cross-frame refund credits the reservoir, not `gas_left`. + + The frame sets a slot with the reservoir empty, spilling the state + charge from `gas_left`. A delegated child clears the slot and the + credit lands in the reservoir, where it stays through the merge: + `gas_left` is lower after the clearing call than before it, and + the clearing window costs the same as a no-op window. + """ + clearer = pre.deploy_contract(code=Op.SSTORE(SLOT_X, 0)) + + call_window = Op.POP(Op.DELEGATECALL(address=clearer)) + code = ( + # Warm the clearer and the slot while it is still zero, and + # pre-expand the measurement memory, so the two measured + # windows below are byte-identical and cost-identical. + call_window + + Op.MSTORE(64, 0) + + Op.SSTORE(SLOT_X, 1) + + Op.MSTORE(0, Op.GAS) + + call_window + + Op.MSTORE(32, Op.GAS) + + call_window + + Op.MSTORE(64, Op.GAS) + + Op.SSTORE(SLOT_INCREASED, Op.GT(Op.MLOAD(32), Op.MLOAD(0))) + + window_cost_excess() + # Every other expected slot is zero, so the marker is what + # distinguishes the pinned run from a reverted one. + + Op.SSTORE(SLOT_MARKER, 1) + ) + contract = pre.deploy_contract(code=code) + + tx = Transaction( + to=contract, + state_gas_reservoir=0, + sender=pre.fund_eoa(), + ) + + # Under the merge-time repayment of ethereum/EIPs#12265 the + # clearing window repays the spill: the increase flag becomes 1 + # and the window excess wraps to minus the slot's state cost. + post = { + contract: Account( + storage={ + SLOT_X: 0, + SLOT_MARKER: 1, + SLOT_INCREASED: 0, + SLOT_RESULT: 0, + } + ) + } + state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.valid_from("EIP8037") +def test_parked_credit_returns_at_settlement( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test the parked credit refunds the sender at settlement. + + The frame's spilled set is cleared by a child, parking the credit + in the reservoir. Settlement sums `gas_left` and the reservoir, so + the spilled charge and the parked credit cancel and the receipt + carries no state term at all. The receipt is placement-independent + and holds unchanged under ethereum/EIPs#12265. + """ + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() + sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + + clearer_code = Op.SSTORE.with_metadata( + key_warm=True, + original_value=0, + current_value=1, + new_value=0, + )(SLOT_X, 0) + clearer = pre.deploy_contract(code=clearer_code) + + # A budget covering the child's SSTORE stipend sentry through the + # clear, so the child succeeds and returns the sentry unspent. + child_budget = ( + fork.call_value_stipend() + 1 + clearer_code.execution_cost(fork) + ) + code = Op.SSTORE( + SLOT_X, + 1, + key_warm=False, + original_value=0, + current_value=0, + new_value=1, + ) + Op.POP( + Op.DELEGATECALL(gas=child_budget, address=clearer, address_warm=False) + ) + contract = pre.deploy_contract(code=code) + + before_refund = ( + intrinsic_cost + + code.execution_cost(fork) + + clearer_code.execution_cost(fork) + ) + # Clearing the slot back to its original value also refunds the + # write cost through the classic refund counter at settlement. + restore_refund = clearer_code.refund(fork) - sstore_state_gas + expected_gas_used = before_refund - min( + before_refund // fork.max_refund_quotient(), restore_refund + ) + # The post-refund usage must clear the calldata floor, or the + # floor masks a lost or doubled credit. + assert expected_gas_used > fork.transaction_data_floor_cost_calculator()( + data=b"" + ) + + tx = Transaction( + to=contract, + state_gas_reservoir=0, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used + ), + ) + + post = {contract: Account(storage={SLOT_X: 0})} + state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.valid_from("EIP8037") +def test_parked_credit_funds_state_at_full_price( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test the parked credit funds a later creation at full price. + + After the cross-frame clear parks the credit, a fresh set draws + its state charge from the reservoir: `gas_left` drops by only the + execution premium across the set window. The receipt still bills + both surviving slots at the full state price, so routing a refund + through another frame buys no discount on state that persists. + """ + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() + sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + + clearer_code = Op.SSTORE.with_metadata( + key_warm=True, + original_value=0, + current_value=1, + new_value=0, + )(SLOT_X, 0) + clearer = pre.deploy_contract(code=clearer_code) + child_budget = ( + fork.call_value_stipend() + 1 + clearer_code.execution_cost(fork) + ) + + fresh_set = Op.SSTORE.with_metadata( + key_warm=False, + original_value=0, + current_value=0, + new_value=1, + ) + window_1 = fresh_set(SLOT_Y, 1) + window_2 = Op.SSTORE.with_metadata( + key_warm=True, + original_value=0, + current_value=1, + new_value=1, + )(SLOT_Y, 1) + # The windows are byte-identical, so the excess is the fresh set's + # execution premium plus whatever its state charge takes from + # `gas_left`. The parked credit covers the state charge, leaving + # the execution premium alone. Under ethereum/EIPs#12265 the merge + # drains the credit into `gas_left` first, so the set spills and + # the excess grows by the slot's state cost. + execution_premium = window_1.execution_cost( + fork + ) - window_2.execution_cost(fork) + + code = ( + Op.MSTORE(64, 0, new_memory_size=96, old_memory_size=0) + + fresh_set(SLOT_X, 1) + + Op.POP( + Op.DELEGATECALL( + gas=child_budget, address=clearer, address_warm=False + ) + ) + + Op.MSTORE(0, Op.GAS) + + window_1 + + Op.MSTORE(32, Op.GAS) + + window_2 + + Op.MSTORE(64, Op.GAS) + + window_cost_excess(result_sstore=fresh_set) + ) + contract = pre.deploy_contract(code=code) + + # Slot Y and the result slot survive, each fully priced. The + # cleared slot cancels out of the settlement sum. + before_refund = ( + intrinsic_cost + + code.execution_cost(fork) + + clearer_code.execution_cost(fork) + + 2 * sstore_state_gas + ) + restore_refund = clearer_code.refund(fork) - sstore_state_gas + expected_gas_used = before_refund - min( + before_refund // fork.max_refund_quotient(), restore_refund + ) + + tx = Transaction( + to=contract, + state_gas_reservoir=0, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used + ), + ) + + post = { + contract: Account( + storage={ + SLOT_X: 0, + SLOT_Y: 1, + SLOT_RESULT: execution_premium, + } + ) + } + state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.valid_from("EIP8037") +def test_parked_credit_cannot_fund_execution( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test the parked credit cannot fund execution work. + + The gas limit covers the transaction only up to the clearing + child's merge plus a sliver. An execution tail worth less than the + parked credit follows, and the transaction halts anyway: the + credit sits in the reservoir, spendable on state creation alone. + Under ethereum/EIPs#12265 the merge repays the spill and the same + budget completes. + """ + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() + sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + + clearer_code = Op.SSTORE.with_metadata( + key_warm=True, + original_value=0, + current_value=1, + new_value=0, + )(SLOT_X, 0) + clearer = pre.deploy_contract(code=clearer_code) + + fresh_set = Op.SSTORE.with_metadata( + key_warm=False, + original_value=0, + current_value=0, + new_value=1, + ) + head = ( + fresh_set(SLOT_MARKER, 1) + + fresh_set(SLOT_X, 1) + + Op.POP( + Op.DELEGATECALL(gas=Op.GAS, address=clearer, address_warm=False) + ) + ) + # TODO: The tail spends a set amount of execution gas; a JUMPDEST + # run is the most future-proof inline way until a fork util exists. + tail_ops = min( + sstore_state_gas // Op.JUMPDEST.gas_cost(fork), + fork.max_code_size() - len(head), + ) + tail = Op.JUMPDEST * tail_ops + code = head + tail + contract = pre.deploy_contract(code=code) + + # A sliver covering the child's SSTORE stipend sentry through the + # one-in-64 withholding. It survives the merge unspent. + sliver = ( + fork.call_value_stipend() + 1 + clearer_code.execution_cost(fork) + ) * 64 // 63 + 1 + tail_cost = tail.gas_cost(fork) + # The tail must overrun the sliver yet fit inside the parked + # credit, or the halt stops demonstrating the credit cannot buy + # execution. + assert sliver < tail_cost <= sstore_state_gas + + gas_limit = ( + intrinsic_cost + + head.gas_cost(fork) + + clearer_code.gas_cost(fork) + + sliver + ) + + tx = Transaction( + to=contract, + gas_limit=gas_limit, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt(cumulative_gas_used=gas_limit), + ) + + post = {contract: Account(storage={SLOT_MARKER: 0, SLOT_X: 0})} + state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.parametrize("child_ending", ["stop", "revert", "invalid"]) +@pytest.mark.valid_from("EIP8037") +def test_child_clear_repays_own_spill_first( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + child_ending: str, +) -> None: + """ + Test the cross-slot LIFO split of a cross-frame refund in a child. + + The parent spills two fresh sets; a delegated child spills a set + of its own, then clears both parent slots. The first credit repays + the child's borrow, the second parks in the reservoir, and a + failing child discards the parked credit with its rollback. + """ + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() + sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + + fresh_set = Op.SSTORE.with_metadata( + key_warm=False, + original_value=0, + current_value=0, + new_value=1, + ) + warm_clear = Op.SSTORE.with_metadata( + key_warm=True, + original_value=0, + current_value=1, + new_value=0, + ) + + child_body = ( + fresh_set(SLOT_MARKER, 1) + + warm_clear(SLOT_X, 0) + + warm_clear(SLOT_Y, 0) + ) + if child_ending == "stop": + child_code = child_body + Op.STOP + elif child_ending == "revert": + child_code = child_body + Op.REVERT(0, 0) + elif child_ending == "invalid": + child_code = child_body + Op.INVALID + else: + raise ValueError(f"unhandled child ending: {child_ending}") + child = pre.deploy_contract(code=child_code) + + # A budget covering the child's SSTORE stipend sentry through its + # own spilled set and both clears. + child_budget = fork.call_value_stipend() + 1 + child_code.gas_cost(fork) + + call_window = Op.POP( + Op.DELEGATECALL(gas=child_budget, address=child, address_warm=False) + ) + code = ( + fresh_set(SLOT_X, 1) + + fresh_set(SLOT_Y, 1) + + Op.MSTORE(32, 0, new_memory_size=64, old_memory_size=0) + + Op.MSTORE(0, Op.GAS) + + call_window + + Op.MSTORE(32, Op.GAS) + + fresh_set(SLOT_RESULT, Op.SUB(Op.MLOAD(0), Op.MLOAD(32))) + ) + contract = pre.deploy_contract(code=code) + + if child_ending == "stop": + child_consumed = child_code.execution_cost(fork) + elif child_ending == "revert": + child_consumed = child_code.execution_cost(fork) + elif child_ending == "invalid": + child_consumed = child_budget + else: + raise ValueError(f"unhandled child ending: {child_ending}") + # Gas measured between the two reads: the first stamp's store, the + # call window, the child's consumption, and the second read itself. + window_cost = ( + Op.MSTORE(0, Op.GAS).gas_cost(fork) + + call_window.execution_cost(fork) + + child_consumed + ) + + parent_exec = code.execution_cost(fork) + if child_ending == "stop": + # The child's slot and the result slot survive; the child's + # borrow was repaid by the first clear's credit, so only the + # parked second credit cancels a parent spill at settlement. + before_refund = ( + intrinsic_cost + + parent_exec + + child_code.execution_cost(fork) + + 2 * sstore_state_gas + ) + restore_refund = 2 * (warm_clear.refund(fork) - sstore_state_gas) + expected_gas_used = before_refund - min( + before_refund // fork.max_refund_quotient(), restore_refund + ) + elif child_ending == "revert": + expected_gas_used = ( + intrinsic_cost + + parent_exec + + child_code.execution_cost(fork) + + 3 * sstore_state_gas + ) + elif child_ending == "invalid": + expected_gas_used = ( + intrinsic_cost + parent_exec + child_budget + 3 * sstore_state_gas + ) + else: + raise ValueError(f"unhandled child ending: {child_ending}") + + tx = Transaction( + to=contract, + state_gas_reservoir=0, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used + ), + ) + + if child_ending == "stop": + storage = { + SLOT_X: 0, + SLOT_Y: 0, + SLOT_MARKER: 1, + SLOT_RESULT: window_cost, + } + elif child_ending in ("revert", "invalid"): + storage = { + SLOT_X: 1, + SLOT_Y: 1, + SLOT_MARKER: 0, + SLOT_RESULT: window_cost, + } + else: + raise ValueError(f"unhandled child ending: {child_ending}") + + post = {contract: Account(storage=storage)} + state_test(pre=pre, post=post, tx=tx) From 117c9240c86ececb57440cd3342b4b82688fec06 Mon Sep 17 00:00:00 2001 From: Om Kumar Date: Wed, 2 Sep 2026 02:08:42 +0530 Subject: [PATCH 41/59] fix(tests): assert zero withdrawal post-state (#3476) --- tests/shanghai/eip4895_withdrawals/test_withdrawals.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/shanghai/eip4895_withdrawals/test_withdrawals.py b/tests/shanghai/eip4895_withdrawals/test_withdrawals.py index 76ecad0b672..26e759e453c 100644 --- a/tests/shanghai/eip4895_withdrawals/test_withdrawals.py +++ b/tests/shanghai/eip4895_withdrawals/test_withdrawals.py @@ -673,16 +673,13 @@ def test_zero_amount( withdrawals = all_withdrawals[0:2] post = { account: all_post[account] - for account in post - if account in [empty_accounts[0], zero_balance_contract] + for account in [empty_accounts[0], zero_balance_contract] } elif test_case == ZeroAmountTestCases.THREE_ONE_WITH_VALUE: withdrawals = all_withdrawals[0:3] post = { account: all_post[account] - for account in post - if account - in [ + for account in [ empty_accounts[0], zero_balance_contract, empty_accounts[1], From 3d3d43b618ca52b27aba4af93ca9bf92f721e56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E4=BD=B3=E8=AA=A0=20Louis=20Tsai?= <72684086+LouisTsai-Csie@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:41:55 +0800 Subject: [PATCH 42/59] refactor(tests): enhance EIP-8037 test coverage part 3 (#3485) * refactor: state gas create scenario * feat(tests): Add more variants --------- Co-authored-by: marioevz --- .../test_state_gas_create.py | 1187 +++++++++++------ 1 file changed, 811 insertions(+), 376 deletions(-) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py index a2bb4448567..7f351aad913 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_create.py @@ -8,8 +8,6 @@ (https://eips.ethereum.org/EIPS/eip-8037). """ -from typing import Union - import pytest from execution_testing import ( Account, @@ -42,6 +40,7 @@ def test_create_charges_state_gas( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """ Test CREATE charges state gas for new account and code deposit. @@ -49,21 +48,38 @@ def test_create_charges_state_gas( A successful CREATE charges new-account state gas plus code deposit state gas proportional to the deployed code size. """ - init_code = Op.STOP + runtime_code = Op.STOP + init_code = Initcode(deploy_code=runtime_code) + mstore_value, size = init_code_at_high_bytes(init_code) + create_call = Op.CREATE(0, 0, size, init_code_size=size) storage = Storage() - contract = pre.deploy_contract( - code=( - Op.MSTORE( - 0, - int.from_bytes(bytes(init_code), "big") - << (256 - 8 * len(init_code)), - ) - + Op.SSTORE( - storage.store_next(True), - Op.GT(Op.CREATE(0, 0, len(init_code)), 0), - ) - ), + code = Op.MSTORE(0, mstore_value, new_memory_size=32) + Op.SSTORE( + storage.store_next(False), + Op.ISZERO(create_call), + original_value=0, + current_value=0, + new_value=0, + ) + contract = pre.deploy_contract(code=code) + created = compute_create_address(address=contract, nonce=1) + + new_account_state = create_call.state_cost(fork) + code_deposit_state = init_code.state_cost(fork) + + assert new_account_state > 0, "test requires a NEW_ACCOUNT charge" + assert code_deposit_state > 0, "test requires a code-deposit charge" + + expected_state = new_account_state + code_deposit_state + + expected_execution = ( + fork.transaction_intrinsic_cost_calculator()() + + fork.transaction_top_frame_gas_calculator()(contract_creation=False) + + code.execution_cost(fork) + + init_code.gas_cost(fork) + ) + assert expected_state > expected_execution, ( + "requires state gas > execution gas" ) tx = Transaction( @@ -72,104 +88,150 @@ def test_create_charges_state_gas( sender=pre.fund_eoa(), ) - post = {contract: Account(storage=storage)} - state_test(pre=pre, post=post, tx=tx) + post = { + contract: Account(nonce=2, storage=storage), + created: Account(nonce=1, code=runtime_code), + } + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_state), + ) +@pytest.mark.with_all_create_opcodes @pytest.mark.parametrize( - "opcode", - [ - pytest.param(Op.CREATE, id="create"), - pytest.param(Op.CREATE2, id="create2"), - ], + "gas_delta", + [pytest.param(0, id="exact_fit"), pytest.param(-1, id="one_short")], ) @pytest.mark.valid_from("EIP8037") def test_create_with_reservoir( state_test: StateTestFiller, pre: Alloc, - opcode: Op, + create_opcode: Op, + gas_delta: int, fork: Fork, ) -> None: """ Test CREATE/CREATE2 with state gas funded from the reservoir. - Provide gas above TX_MAX_GAS_LIMIT so the new account state gas - is drawn from the reservoir rather than gas_left. + The factory is forwarded only the execution gas it needs, so the + new-account charge can only come from the reservoir riding along. + One gas short of that grant the factory halts instead. """ - storage = Storage() init_code = Op.STOP + mstore_value, size = init_code_at_high_bytes(init_code) - if opcode == Op.CREATE: - create_call = Op.CREATE(0, 0, len(init_code)) - else: - create_call = Op.CREATE2(0, 0, len(init_code), 0) + create_call = create_opcode( + value=0, offset=0, size=size, init_code_size=size + ) + factory_code = Op.MSTORE(0, mstore_value, new_memory_size=32) + Op.POP( + create_call + ) + factory = pre.deploy_contract(code=factory_code) + + storage = Storage() contract = pre.deploy_contract( - code=( - Op.MSTORE( - 0, - int.from_bytes(bytes(init_code), "big") - << (256 - 8 * len(init_code)), - ) - + Op.SSTORE( - storage.store_next(True), - Op.GT(create_call, 0), - ) + code=Op.SSTORE( + storage.store_next(1 if gas_delta == 0 else 0, "create_succeeded"), + Op.CALL( + gas=factory_code.execution_cost(fork), + address=factory, + ), ), + storage=storage.canary(), ) tx = Transaction( to=contract, - state_gas_reservoir=create_call.state_cost(fork), + state_gas_reservoir=factory_code.state_cost(fork) + gas_delta, sender=pre.fund_eoa(), ) - post = {contract: Account(storage=storage)} + created = compute_create_address( + address=factory, + nonce=1, + salt=0, + initcode=init_code, + opcode=create_opcode, + ) + + post = { + contract: Account(storage=storage), + created: Account(nonce=1, code=b"") + if gas_delta == 0 + else Account.NONEXISTENT, + } state_test(pre=pre, post=post, tx=tx) +@pytest.mark.parametrize("enough_gas", [False, True]) +@pytest.mark.with_all_create_opcodes @pytest.mark.valid_from("EIP8037") -def test_create2_child_spill_not_double_charged( +def test_create_child_spill_not_double_charged( state_test: StateTestFiller, pre: Alloc, + fork: Fork, + create_opcode: Op, + enough_gas: bool, ) -> None: """ - Test CREATE2 child state gas paid from `gas_left` is not recharged. + Test CREATE/CREATE2 child state gas paid from `gas_left` is not recharged. - The factory executes below the Amsterdam tx gas cap, so the CREATE2 child + The factory executes below the Amsterdam tx gas cap, so the CREATE child pays new-account and storage state gas by spilling from `gas_left`. The - factory must not charge the same state growth again at frame end. + gas limit covers that bill once, so charging the same state growth again + at frame end would run the transaction out of gas. """ init_code = sum(Op.SSTORE(i, i + 1) for i in range(6)) + Op.STOP mstore_value, initcode_size = init_code_at_high_bytes(init_code) - factory = pre.deploy_contract( - code=( - Op.MSTORE(0, mstore_value) - + Op.POP( - Op.CREATE2( - value=0, - offset=0, - size=initcode_size, - salt=0, - ) - ) + factory_code = Op.MSTORE( + 0, + mstore_value, + # gas accounting + new_memory_size=32, + ) + ( + create_opcode( + value=0, + offset=0, + size=initcode_size, + # gas accounting + init_code_size=initcode_size, ) ) - created = compute_create2_address( + factory = pre.deploy_contract(code=factory_code) + created = compute_create_address( address=factory, salt=0, - initcode=bytes(init_code), + nonce=1, + initcode=init_code, + opcode=create_opcode, + ) + + # The child's grant is short a 64th of what the factory holds at + # dispatch, so its bill has to be grossed up by that fraction. + child_gas = init_code.gas_cost(fork) + gas_limit = ( + fork.transaction_intrinsic_cost_calculator()() + + factory_code.gas_cost(fork) + + child_gas * 64 // 63 ) + if not enough_gas: + gas_limit -= 1 tx = Transaction( to=factory, - gas_limit=1_000_000, + gas_limit=gas_limit, sender=pre.fund_eoa(), ) post = { - created: Account(nonce=1, storage={i: i + 1 for i in range(6)}), + created: Account(nonce=1, storage={i: i + 1 for i in range(6)}) + if enough_gas + else Account.NONEXISTENT, } state_test(pre=pre, post=post, tx=tx) @@ -189,7 +251,7 @@ def test_create2_child_spill_not_double_charged( def test_code_deposit_state_gas_scales_with_size( state_test: StateTestFiller, pre: Alloc, - code_size: Union[int, str], + code_size: int | str, fork: Fork, ) -> None: """ @@ -209,9 +271,13 @@ def test_code_deposit_state_gas_scales_with_size( # State gas: new account + code deposit total_state_gas = fork.create_state_gas(code_size=code_size) - # Build init code that returns `code_size` bytes of 0x00 - # PUSH2 code_size, PUSH1 0, RETURN - init_code = Op.RETURN(0, code_size) + init_code = Op.RETURN( + 0, code_size, new_memory_size=code_size, code_deposit_size=code_size + ) + + total_execution_gas = fork.transaction_intrinsic_cost_calculator()( + calldata=bytes(init_code), contract_creation=True + ) + init_code.execution_cost(fork) sender = pre.fund_eoa() tx = Transaction( @@ -221,13 +287,27 @@ def test_code_deposit_state_gas_scales_with_size( sender=sender, ) + create_address = compute_create_address(address=sender, nonce=0) + gas_limit_cap = fork.transaction_gas_limit_cap() + assert gas_limit_cap is not None + if code_size > fork.max_code_size(): - create_address = compute_create_address(address=sender, nonce=0) post = {create_address: Account.NONEXISTENT} + # The halt rolls the reservoir back, so the sender pays the cap. + expected_gas_used = gas_limit_cap else: post = {} + assert total_state_gas > total_execution_gas, ( + "requires state gas > execution gas" + ) + expected_gas_used = max(total_execution_gas, total_state_gas) - state_test(pre=pre, post=post, tx=tx) + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) @pytest.mark.parametrize( @@ -389,51 +469,78 @@ def test_repeated_create_same_code_charges_each_account( def test_create_tx_state_gas( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """ - Test contract creation transaction charges intrinsic state gas. + Test contract creation transaction charges top-frame state gas. - A create transaction (to=None) charges new-account state gas - as intrinsic state gas for the new account, plus code deposit state - gas for the deployed bytecode. + A create transaction charges the new account's state gas during + top-frame preparation, separately from transaction intrinsic gas. """ + init_code = Op.STOP + + expected_top_frame_state = fork.transaction_top_frame_state_gas( + contract_creation=True + ) + expected_execution = fork.transaction_intrinsic_cost_calculator()( + calldata=bytes(init_code), contract_creation=True + ) + init_code.execution_cost(fork) + + assert expected_top_frame_state > expected_execution, ( + "requires top-frame state gas > execution gas" + ) + + sender = pre.fund_eoa() tx = Transaction( to=None, - data=Op.STOP, + data=init_code, state_gas_reservoir=0, - sender=pre.fund_eoa(), + sender=sender, ) - state_test(pre=pre, post={}, tx=tx) + created = compute_create_address(address=sender, nonce=0) + state_test( + pre=pre, + post={created: Account(nonce=1, code=b"")}, + tx=tx, + blockchain_test_header_verify=Header( + gas_used=max(expected_execution, expected_top_frame_state) + ), + ) @pytest.mark.valid_from("EIP8037") def test_create_revert_no_code_deposit_state_gas( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """ - Test reverted CREATE does not charge code deposit state gas. + Test reverted CREATE does not charge state gas. - When CREATE fails during init code execution (REVERT), the new - account state gas is consumed but no code deposit state gas is - charged because no code was deployed. + Account-creation state gas is charged in the creating frame but + refilled when the creation rolls back, so the net state gas is zero, + and no code deposit state gas is charged because no code was + deployed. The block therefore bills execution gas alone. """ init_code = Op.REVERT(0, 0) + mstore_value, size = init_code_at_high_bytes(init_code) storage = Storage() - contract = pre.deploy_contract( - code=( - Op.MSTORE( - 0, - int.from_bytes(bytes(init_code), "big") - << (256 - 8 * len(init_code)), - ) - + Op.SSTORE( - storage.store_next(0), # CREATE returns 0 on failure - Op.CREATE(0, 0, len(init_code)), - ) - ), + code = Op.MSTORE(0, mstore_value, new_memory_size=32) + Op.SSTORE( + storage.store_next(0), # CREATE returns 0 on failure + Op.CREATE(0, 0, size, init_code_size=size, account_new=False), + original_value=0, + current_value=0, + new_value=0, + ) + contract = pre.deploy_contract(code=code) + + assert code.state_cost(fork) == 0, "the rolled-back creation refills" + expected_execution = ( + fork.transaction_intrinsic_cost_calculator()() + + code.execution_cost(fork) + + init_code.execution_cost(fork) ) tx = Transaction( @@ -442,46 +549,56 @@ def test_create_revert_no_code_deposit_state_gas( sender=pre.fund_eoa(), ) - post = {contract: Account(storage=storage)} - state_test(pre=pre, post=post, tx=tx) + post = { + contract: Account(storage=storage), + compute_create_address(address=contract, nonce=1): ( + Account.NONEXISTENT + ), + } + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_execution), + ) @EIPChecklist.GasCostChanges.Test.OutOfGas() +@pytest.mark.with_all_create_opcodes +@pytest.mark.parametrize("enough_gas", [False, True]) @pytest.mark.valid_from("EIP8037") -def test_create_insufficient_state_gas( +def test_create_insufficient_account_state_gas( state_test: StateTestFiller, pre: Alloc, fork: Fork, + enough_gas: bool, + create_opcode: Op, ) -> None: """ - Test CREATE OOGs when state gas is insufficient. + Test CREATE OOGs when state gas is insufficient for account creation. - Provide enough gas for CREATE's execution gas cost but not enough - to cover the new-account state gas. The CREATE should fail, - returning 0. + The gas limit covers the frame's execution gas and the required state gas + minus one, so the new-account state charge has neither a reservoir nor + spare `gas_left` to draw from. The frame halts before the account is + created and the whole limit is billed. """ init_code = Op.STOP - create_call = Op.CREATE(0, 0, len(init_code)) + mstore_value, size = init_code_at_high_bytes(init_code) - storage = Storage() - contract = pre.deploy_contract( - code=( - Op.MSTORE( - 0, - int.from_bytes(bytes(init_code), "big") - << (256 - 8 * len(init_code)), - ) - + Op.SSTORE( - storage.store_next(0), # CREATE returns 0 on OOG - create_call, - ) - ), + code = Op.MSTORE(0, mstore_value, new_memory_size=32) + ( + create_opcode(value=0, offset=0, size=size, init_code_size=size) ) + contract = pre.deploy_contract(code=code) - # Tight gas — enough for intrinsic + CREATE execution gas but not - # enough for the new account state gas - intrinsic_cost = fork.transaction_intrinsic_cost_calculator() - gas_limit = intrinsic_cost() + create_call.execution_cost(fork) + 10_000 + gas_limit = fork.transaction_intrinsic_cost_calculator()() + ( + code.gas_cost(fork) + ) + if not enough_gas: + gas_limit -= 1 + + assert code.state_cost(fork) > 0, ( + f"create opcode does not charge state gas at {fork}" + ) tx = Transaction( to=contract, @@ -489,7 +606,12 @@ def test_create_insufficient_state_gas( sender=pre.fund_eoa(), ) - post = {contract: Account(storage=storage)} + post = { + contract: Account(storage={0: 0}), + compute_create_address( + address=contract, nonce=1, initcode=init_code, opcode=create_opcode + ): (Account(nonce=1) if enough_gas else Account.NONEXISTENT), + } state_test(pre=pre, post=post, tx=tx) @@ -510,37 +632,159 @@ def test_create2_address_collision( gas_limit_cap = fork.transaction_gas_limit_cap() assert gas_limit_cap is not None init_code = Op.STOP + mstore_value, size = init_code_at_high_bytes(init_code) salt = 0 storage = Storage() - contract = pre.deploy_contract( - code=( - Op.MSTORE( - 0, - int.from_bytes(bytes(init_code), "big") - << (256 - 8 * len(init_code)), - ) - # First CREATE2 succeeds - + Op.SSTORE( - storage.store_next(1, "first_create2"), - Op.ISZERO(Op.ISZERO(Op.CREATE2(0, 0, len(init_code), salt))), - ) - # Second CREATE2 with same salt collides - + Op.SSTORE( - storage.store_next(0, "collision_create2"), - Op.CREATE2(0, 0, len(init_code), salt), - ) - ), + factory_prefix_code = ( + Op.MSTORE(0, mstore_value, new_memory_size=32) + # First CREATE + + Op.SSTORE( + storage.store_next(1), + Op.ISZERO( + Op.ISZERO( + Op.CREATE2( + 0, + 0, + size, + salt, + # gas accounting + init_code_size=size, + account_new=True, + ) + ) + ), + # gas accounting + original_value=0, + new_value=1, + ) + ) + factory_create_code = Op.CREATE2( + 0, + 0, + size, + salt, + # gas accounting + init_code_size=size, + account_new=False, + ) + factory_code = factory_prefix_code + factory_create_code + contract = pre.deploy_contract(code=factory_code) + collision_target = compute_create2_address( + address=contract, salt=salt, initcode=bytes(init_code) ) + state_gas = factory_prefix_code.state_cost(fork) + # The collision burns all but a 64th of the factory's execution + # gas, so half again its own cost keeps the trailing SSTORE alive. + # Execution gas is at most what the limit has left once the state + # charge is paid, so the assert keeps that burn under the state gas. + gas_limit = ( + ( + fork.transaction_intrinsic_cost_calculator()() + + factory_code.gas_cost(fork) + ) + * 3 + // 2 + ) + assert gas_limit - state_gas < state_gas, "state gas must dominate" + tx = Transaction( to=contract, - gas_limit=gas_limit_cap * 2, + gas_limit=gas_limit, sender=pre.fund_eoa(), ) - post = {contract: Account(storage=storage)} - state_test(pre=pre, post=post, tx=tx) + post = { + contract: Account(storage=storage), + collision_target: Account(nonce=1, code=b""), + } + + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=state_gas), + ) + + +@pytest.mark.pre_alloc_mutable +@pytest.mark.valid_from("EIP8037") +def test_create_address_collision( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test CREATE returns zero on address collision. + + When CREATE targets an address that already has code or a + non-zero nonce (EIP-684), the collision is detected early and + returns zero without charging state gas. The existing account is + left unchanged. + + Requires mutable pre-alloc in order to prepare the collision that normally + would require a hash-collision. + """ + gas_limit_cap = fork.transaction_gas_limit_cap() + assert gas_limit_cap is not None + init_code = Op.STOP + mstore_value, size = init_code_at_high_bytes(init_code) + + storage = Storage() + factory_prefix_code = ( + Op.MSTORE(0, mstore_value, new_memory_size=32) + # Fill state gas usage just enough to pass the execution gas + + Op.SSTORE(storage.store_next(1), 1, original_value=0, new_value=1) + + Op.SSTORE(storage.store_next(1), 1, original_value=0, new_value=1) + ) + factory_create_code = Op.CREATE( + 0, + 0, + size, + # gas accounting + init_code_size=size, + account_new=False, + ) + factory_code = factory_prefix_code + factory_create_code + contract = pre.deploy_contract(code=factory_code) + collision_target = compute_create_address(address=contract, nonce=1) + pre[collision_target] = Account(nonce=1) + + state_gas = factory_prefix_code.state_cost(fork) + # The collision burns all but a 64th of the factory's execution + # gas, so half again its own cost keeps the trailing SSTORE alive. + # Execution gas is at most what the limit has left once the state + # charge is paid, so the assert keeps that burn under the state gas. + gas_limit = ( + ( + fork.transaction_intrinsic_cost_calculator()() + + factory_code.gas_cost(fork) + ) + * 3 + // 2 + ) + assert factory_code.gas_cost(fork) - state_gas < state_gas, ( + "state gas must dominate" + ) + + tx = Transaction( + to=contract, + gas_limit=gas_limit, + sender=pre.fund_eoa(), + ) + + post = { + contract: Account(storage=storage), + collision_target: Account(nonce=1, code=b""), + } + + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header(gas_used=state_gas), + ) @pytest.mark.inclusion_test @@ -563,21 +807,21 @@ def test_create_tx_intrinsic_gas_boundary( gas_delta: int, ) -> None: """ - Test CREATE tx intrinsic gas boundary includes state component. + Test the creation transaction intrinsic gas boundary. - The intrinsic gas for a contract-creating transaction includes - both execution gas and state gas. A transaction with gas_limit - exactly at the boundary succeeds; one gas below is rejected. + Intrinsic gas covers execution only. At the boundary the transaction is + accepted but cannot create a contract; one gas below is rejected. """ intrinsic_cost = fork.transaction_intrinsic_cost_calculator() gas_limit = intrinsic_cost( contract_creation=True, ) + sender = pre.fund_eoa() tx = Transaction( to=None, gas_limit=gas_limit + gas_delta, - sender=pre.fund_eoa(), + sender=sender, error=( TransactionException.INTRINSIC_GAS_TOO_LOW if gas_delta < 0 @@ -585,7 +829,10 @@ def test_create_tx_intrinsic_gas_boundary( ), ) - state_test(pre=pre, post={}, tx=tx) + post = { + compute_create_address(address=sender, nonce=0): Account.NONEXISTENT + } + state_test(pre=pre, post=post, tx=tx) @pytest.mark.inclusion_test @@ -622,18 +869,22 @@ def test_create_tx_below_total_intrinsic( """ intrinsic = fork.transaction_intrinsic_cost_calculator()( contract_creation=True, - calldata=bytes(initcode), + calldata=initcode, ) + sender = pre.fund_eoa() tx = Transaction( to=None, - data=bytes(initcode), + data=initcode, gas_limit=intrinsic - 1, - sender=pre.fund_eoa(), + sender=sender, error=TransactionException.INTRINSIC_GAS_TOO_LOW, ) - state_test(pre=pre, post={}, tx=tx) + post = { + compute_create_address(address=sender, nonce=0): Account.NONEXISTENT + } + state_test(pre=pre, post=post, tx=tx) @pytest.mark.valid_from("EIP8037") @@ -645,62 +896,106 @@ def test_code_deposit_oog_preserves_parent_reservoir( """ Test parent reservoir preserved after child code deposit OOG. - A caller contract invokes the factory via CALL with limited gas. - The child CREATE returns enough bytes that code deposit state gas - exceeds the child frame's available gas (reservoir spillover plus - the limited gas_left). The factory's SSTORE after the failed - CREATE proves the reservoir was not inflated by a spill-then-halt - refund. + A caller invokes the factory with limited gas and a reservoir consumed by + the CREATE account charge. The child returns enough bytes that code-deposit + state gas cannot spill entirely into its limited gas_left. The factory's + SSTORE proves the account charge was refunded, while the exact receipt + proves the reservoir was not inflated by the failed spill. """ - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - - # Small deploy size; code deposit state gas will exceed the - # limited gas available in the CREATE child frame. deploy_size = 4096 - init_code = Op.RETURN(0, deploy_size) + init_code = Op.RETURN( + 0, + deploy_size, + new_memory_size=deploy_size, + code_deposit_size=deploy_size, + ) create_call = Op.CREATE( value=0, offset=32 - len(init_code), size=len(init_code), + init_code_size=len(init_code), ) - # Limited execution gas forwarded to the factory. After CREATE - # takes 63/64, the factory retains ~23 K for its SSTOREs. - child_gas = 1_500_000 - factory_storage = Storage() - factory = pre.deploy_contract( - code=( - Op.MSTORE(0, Op.PUSH32(bytes(init_code))) - + Op.SSTORE( - factory_storage.store_next(0, "create_fails"), - create_call, - ) - # Reservoir must be fully preserved after failed CREATE; - # parent can still perform its own SSTORE. - + Op.SSTORE( - factory_storage.store_next(1, "parent_sstore"), - 1, - ) - ), + factory_create_code = ( + Op.MSTORE(0, Op.PUSH32(bytes(init_code)), new_memory_size=32) + + create_call ) + factory_post_create_code = Op.SSTORE( + factory_storage.store_next(1, "parent_sstore"), + 1, + original_value=0, + current_value=0, + new_value=1, + ) + assert factory_create_code.state_cost( + fork + ) > factory_post_create_code.state_cost(fork) + factory_code = factory_create_code + factory_post_create_code + factory = pre.deploy_contract(code=factory_code) - # Caller invokes factory with limited gas via CALL. + # Limited execution gas forwarded to the factory. CREATE leaves it + # only a 64th of what it holds, so fund 64 times its own execution + # gas for the SSTOREs that follow. + child_gas = factory_code.execution_cost(fork) * 64 + caller_code = Op.CALL(gas=child_gas, address=factory) caller = pre.deploy_contract( - code=Op.CALL(gas=child_gas, address=factory), + code=caller_code, ) - # Reservoir = new-account state gas + one SSTORE's state gas. - # Code deposit draws from the reservoir first then spills into - # gas_left, which the limited CALL gas cannot cover. + gas_before_create_child = child_gas - factory_create_code.execution_cost( + fork + ) + create_child_gas = gas_before_create_child - gas_before_create_child // 64 + code_deposit = Op.RETURN(code_deposit_size=deploy_size) + child_pre_deposit_execution = init_code.execution_cost( + fork + ) + code_deposit.execution_cost(fork) + assert create_child_gas >= child_pre_deposit_execution, ( + "child must reach code deposit" + ) + assert ( + create_child_gas - child_pre_deposit_execution + < code_deposit.state_cost(fork) + ), "child must fail the code-deposit state charge" + + expected_execution = ( + fork.transaction_intrinsic_cost_calculator()() + + fork.transaction_top_frame_gas_calculator()(contract_creation=False) + + caller_code.execution_cost(fork) + + factory_create_code.execution_cost(fork) + + create_child_gas + + factory_post_create_code.execution_cost(fork) + ) + expected_state = factory_post_create_code.state_cost(fork) + expected_cumulative = expected_execution + expected_state + + # NEW_ACCOUNT consumes the whole reservoir before the child starts, so + # code deposit must spill entirely into the child's limited gas_left. + # After failure, its refund funds the factory's SSTORE. tx = Transaction( to=caller, - state_gas_reservoir=create_call.state_cost(fork) + sstore_state_gas, + state_gas_reservoir=create_call.state_cost(fork), sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative, + ), ) - post = {factory: Account(storage=factory_storage)} - state_test(pre=pre, post=post, tx=tx) + # The deposit halts the child, so the factory's nonce bump is the + # only trace the creation leaves. + post = { + factory: Account(nonce=2, storage=factory_storage), + compute_create_address(address=factory, nonce=1): Account.NONEXISTENT, + } + state_test( + pre=pre, + post=post, + tx=tx, + blockchain_test_header_verify=Header( + gas_used=max(expected_execution, expected_state) + ), + ) @pytest.mark.parametrize( @@ -877,65 +1172,76 @@ def test_parent_state_gas_after_child_failure( ) +@pytest.mark.parametrize("enough_gas", [False, True]) +@pytest.mark.with_all_create_opcodes @pytest.mark.valid_from("EIP8037") def test_nested_create_code_deposit_cannot_borrow_parent_gas( state_test: StateTestFiller, pre: Alloc, fork: Fork, + create_opcode: Op, + enough_gas: bool, ) -> None: """ Test nested CREATE code deposit does not borrow parent gas. - Provide just enough gas for CREATE to start (new account state - gas + execution gas) but not enough for the child frame to cover - code deposit after init code runs. The CREATE increments the - factory nonce but code deposit fails, so no contract is deployed. + Give the factory exactly enough remaining execution gas to cover the + child's initcode, code hash, and code-deposit state gas in aggregate. + EIP-150 retains 1/64 in the factory, leaving the child short by exactly + that retained amount. The child cannot borrow it, so code deposit fails: + the factory nonce increments but no contract is deployed. """ - init_code = Op.RETURN(0, 1, new_memory_size=32) - code_deposit_state = Op.RETURN(0, 1, code_deposit_size=1).state_cost(fork) - + deployed_code_size = 1 + deployed_code = Op.STOP + init_code = Op.RETURN( + 0, + deployed_code_size, + new_memory_size=32, + code_deposit_size=deployed_code_size, + ) factory_mstore = Op.MSTORE( 0, Op.PUSH32(bytes(init_code)), new_memory_size=32 ) - factory_create = Op.CREATE( + factory_create = create_opcode( value=0, offset=32 - len(init_code), size=len(init_code), init_code_size=len(init_code), ) - factory = pre.deploy_contract( - code=factory_mstore + Op.POP(factory_create), + factory_code = factory_mstore + factory_create + factory = pre.deploy_contract(code=factory_code) + created = compute_create_address( + address=factory, + nonce=1, + salt=0, + initcode=init_code, + opcode=create_opcode, ) - created = compute_create_address(address=factory, nonce=1) - # Init code child execution: PUSH1 + PUSH1 + RETURN's mem_exp. - # Code deposit (keccak + state) is charged AFTER the child returns. - init_cost = init_code.execution_cost(fork) - # Target child: enough for init, not enough for code deposit state. - target_child = (init_cost + code_deposit_state) // 2 - # Invert EIP-150 63/64ths rule: ceil(target_child * 64 / 63). - factory_remaining = (target_child * 64 + 62) // 63 - - # NEW_ACCOUNT state gas spills into gas_left (no reservoir at the - # top level), so it must be funded out of the execution budget. - intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() - gas_limit = ( - intrinsic_cost - + factory_mstore.execution_cost(fork) - + factory_create.execution_cost(fork) - + factory_create.state_cost(fork) - + factory_remaining + # Everything is funded by the execution gas, so we can apply the 1/64th + # rule directly. + factory_gas = factory_code.gas_cost(fork) + ( + init_code.gas_cost(fork) * 64 // 63 ) + if not enough_gas: + factory_gas -= 1 - tx = Transaction( - to=factory, - gas_limit=gas_limit, - sender=pre.fund_eoa(), + # Limit the execution gas via a subcall to avoid having to calculate the + # intrinsic gas cost. + caller_code = Op.CALL( + gas=factory_gas, + address=factory, ) + caller = pre.deploy_contract(caller_code) + + # The only guarantee needed is that there will be no gas in the reservoir + tx = Transaction(to=caller, state_gas_reservoir=0, sender=pre.fund_eoa()) post = { factory: Account(nonce=2), - created: Account.NONEXISTENT, + created: Account(nonce=1, code=deployed_code) + if enough_gas + else Account.NONEXISTENT, } state_test(pre=pre, post=post, tx=tx) @@ -955,17 +1261,14 @@ def test_sstore_oog_no_reservoir_inflation( gas_shortfall: int, ) -> None: """ - Verify SSTORE state gas is not charged when execution gas OOGs. - - With zero reservoir, all state gas spills into gas_left. A child - frame does CREATE (charging state gas from gas_left) followed by - SSTORE. When the factory is 1 gas short, SSTORE OOGs. If state - gas is incorrectly charged before execution gas, the extra state gas - inflates the parent's reservoir on frame failure, changing the - transaction's effective gas consumption. + Verify SSTORE does not inflate the parent reservoir on execution OOG. - Regression test for SSTORE gas ordering: execution gas must be - checked before state gas. + With zero reservoir, all state gas spills into gas_left. The exact-gas + case succeeds; one gas less makes the factory's SSTORE fail its execution + gas check. The failing frame has no surviving state charge, so its full + gas allowance must be reported as execution gas. The exact receipt and + header catch state gas charged before the execution OOG and incorrectly + returned to the parent as reservoir gas. """ initcode = Initcode(deploy_code=Op.STOP) initcode_len = len(initcode) @@ -996,20 +1299,42 @@ def test_sstore_oog_no_reservoir_inflation( + initcode.deployment_gas(fork) ) - # Caller forwards total gas (execution + state) through CALL. - # With zero reservoir, the CALL gas parameter is the only source. - caller = pre.deploy_contract( - Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) - + Op.CALL( - gas=factory_gas - gas_shortfall, - address=factory, - value=0, - args_offset=0, - args_size=Op.CALLDATASIZE, - ret_offset=0, - ret_size=0, + # Caller forwards total gas (execution + state) through CALL. With zero + # reservoir, the CALL gas parameter is the factory's only source. + caller_code = Op.CALLDATACOPY( + 0, + 0, + Op.CALLDATASIZE, + data_size=initcode_len, + new_memory_size=initcode_len, + ) + Op.CALL( + gas=factory_gas - gas_shortfall, + address=factory, + value=0, + args_offset=0, + args_size=Op.CALLDATASIZE, + ret_offset=0, + ret_size=0, + ) + caller = pre.deploy_contract(caller_code) + + expected_cumulative = ( + fork.transaction_intrinsic_cost_calculator()( + calldata=bytes(initcode), + return_cost_deducted_prior_execution=True, ) + + fork.transaction_top_frame_gas_calculator()(contract_creation=False) + + caller_code.execution_cost(fork) + + factory_gas + - gas_shortfall + ) + code_deposit = Op.RETURN(code_deposit_size=len(Op.STOP)) + expected_state = ( + factory_code.state_cost(fork) + code_deposit.state_cost(fork) + if gas_shortfall == 0 + else 0 ) + expected_execution = expected_cumulative - expected_state sender = pre.fund_eoa() tx = Transaction( @@ -1017,6 +1342,9 @@ def test_sstore_oog_no_reservoir_inflation( to=caller, data=bytes(initcode), state_gas_reservoir=0, + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative, + ), ) created = not gas_shortfall @@ -1027,7 +1355,14 @@ def test_sstore_oog_no_reservoir_inflation( factory: Account(storage={0: create_address if created else 0}), } - state_test(pre=pre, tx=tx, post=post) + state_test( + pre=pre, + tx=tx, + post=post, + blockchain_test_header_verify=Header( + gas_used=max(expected_execution, expected_state) + ), + ) @pytest.mark.parametrize( @@ -1157,35 +1492,27 @@ def test_create_no_double_charge_new_account( GAS_NEW_ACCOUNT were charged twice (once in execution, once in state), the CREATE would OOG. """ - create_state_gas = fork.create_state_gas(code_size=0) - # Child: just does CREATE(value=0, offset=0, size=0) and stores result. # This creates an empty account (no code deposit). child_code = Op.SSTORE(0, Op.CREATE(value=0, offset=0, size=0)) child = pre.deploy_contract(child_code) - # Compute exact gas: child bytecode + CREATE child frame. - # The child frame is empty (size=0) so only the CREATE opcode - # charges matter: execution (EXECUTION_GAS_CREATE) + state (new account). - child_total = child_code.gas_cost(fork) - create_address = compute_create_address(address=child, nonce=1) # Caller forwards exact execution gas via CALL. State gas for # new account comes from the reservoir (gas_limit above the cap). caller_storage = Storage() - execution_gas = child_total - create_state_gas caller = pre.deploy_contract( Op.SSTORE( caller_storage.store_next(1, "create_succeeds"), - Op.CALL(gas=execution_gas, address=child), + Op.CALL(gas=child_code.execution_cost(fork), address=child), ) ) tx = Transaction( sender=pre.fund_eoa(), to=caller, - state_gas_reservoir=create_state_gas, + state_gas_reservoir=child_code.state_cost(fork), ) post = { @@ -1196,9 +1523,6 @@ def test_create_no_double_charge_new_account( state_test(pre=pre, tx=tx, post=post) -# TODO: Review for bal-devnet-4. If EIP-8037 adopts top-level state gas -# refund (https://github.com/ethereum/EIPs/pull/11476), the expected block -# gas accounting in these tests will change and may need updating. @pytest.mark.parametrize( "state_opcode", [ @@ -1223,16 +1547,17 @@ def test_code_deposit_halt_discards_initcode_state_gas( deposit_fail_mode: str, ) -> None: """ - Verify initcode state gas excluded from block on deposit halt. + Verify deposit halt discards all state gas charged by initcode. A CREATE tx runs initcode that first performs a state-creating operation (charging GAS_NEW_ACCOUNT state gas), then returns code that triggers a deposit failure (oversized, OOG, or an EIP-3541 0xEF prefix). The exceptional halt reverts all initcode state changes including the new account. The reverted - GAS_NEW_ACCOUNT must NOT count in block_state_gas_used, which - determines the block header gas_used via - max(block_execution_gas, block_state_gas). + GAS_NEW_ACCOUNT must not count in block state gas. The deposit halt burns + the full execution grant, so the exact receipt and header both equal the + transaction gas-limit cap. Any surviving state charge would split that + fixed total across the two dimensions and lower the header below the cap. """ subcall_forwarded_value = 1 entry_account_value = 1 @@ -1241,11 +1566,18 @@ def test_code_deposit_halt_discards_initcode_state_gas( Op.CALL( address=pre.nonexistent_account(), value=subcall_forwarded_value, + # gas accounting + value_transfer=True, + account_new=True, ) ) else: state_op = Op.POP(Op.CREATE(value=0, offset=0, size=1)) + assert state_op.state_cost(fork) > 0, ( + "initcode must perform a non-zero state-gas operation" + ) + if deposit_fail_mode == "oversized_code": deposit_fail = Op.RETURN(0, fork.max_code_size() + 1) elif deposit_fail_mode == "oog_deposit": @@ -1259,6 +1591,8 @@ def test_code_deposit_halt_discards_initcode_state_gas( deposit_fail = Op.MSTORE8(0, 0xEF) + Op.RETURN(0, 1) initcode = state_op + deposit_fail + gas_limit_cap = fork.transaction_gas_limit_cap() + assert gas_limit_cap is not None blockchain_test( pre=pre, @@ -1271,8 +1605,13 @@ def test_code_deposit_halt_discards_initcode_state_gas( value=entry_account_value + subcall_forwarded_value, state_gas_reservoir=0, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + status=0, + cumulative_gas_used=gas_limit_cap, + ), ), ], + header_verify=Header(gas_used=gas_limit_cap), ), ], post={}, @@ -1377,48 +1716,52 @@ def test_create_initcode_halt_no_code_deposit_state_gas( fork: Fork, ) -> None: """ - Verify initcode exceptional halt excludes code deposit state gas. + Verify an initcode exceptional halt leaves no state gas charged. - A CREATE tx runs initcode that hits INVALID (exceptional halt) - before returning any code. Code deposit never happens, so code - deposit state gas must NOT be charged. Only the intrinsic state - gas (new account creation) should count. + A CREATE transaction runs INVALID before returning any code, so code + deposit never happens. The exceptional halt also rolls back the top-frame + new-account charge. The transaction is deliberately funded below the gas + limit cap, making that charge spill into execution gas. After rollback the + restored spill is forfeited as execution gas, so the receipt and header + must both equal the full transaction gas limit and state gas used is zero. Complements test_create_revert_no_code_deposit_state_gas which covers the REVERT path. """ - gas_limit_cap = fork.transaction_gas_limit_cap() - assert gas_limit_cap is not None - - # Initcode that immediately halts, no code returned initcode = Op.INVALID + intrinsic_execution = fork.transaction_intrinsic_cost_calculator()( + calldata=initcode, + contract_creation=True, + return_cost_deducted_prior_execution=True, + ) + new_account_state = fork.transaction_top_frame_state_gas( + contract_creation=True + ) + assert new_account_state > intrinsic_execution, ( + "the rolled-back state charge must dominate the intrinsic execution" + ) - # State gas = new account only (no code deposit on halt) - intrinsic_state_gas = fork.create_state_gas(code_size=0) - - gas_limit = gas_limit_cap + intrinsic_state_gas + # With no reservoir, the account charge spills into gas_left. INVALID + # restores that spill and then forfeits it as execution gas. + gas_limit = intrinsic_execution + new_account_state tx = Transaction( to=None, data=initcode, - state_gas_reservoir=intrinsic_state_gas, + gas_limit=gas_limit, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + status=0, + cumulative_gas_used=gas_limit, + ), ) - # On exceptional halt all gas_left is consumed. - # block_gas_used = max(block_execution, block_state) - # block_state = intrinsic_state_gas (new account only, no deposit) - # block_execution = gas_limit - intrinsic_state_gas (all remaining) - tx_execution = gas_limit - intrinsic_state_gas - tx_state = intrinsic_state_gas - expected_gas_used = max(tx_execution, tx_state) - blockchain_test( pre=pre, blocks=[ Block( txs=[tx], - header_verify=Header(gas_used=expected_gas_used), + header_verify=Header(gas_used=gas_limit), ), ], post={}, @@ -1496,6 +1839,9 @@ def test_failed_create_header_gas_used( halt). Verify the block is accepted with correct gas accounting. Parametrized across failure modes and create opcodes. """ + gas_limit_cap = fork.transaction_gas_limit_cap() + assert gas_limit_cap is not None + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() create_state_gas = fork.create_state_gas(code_size=0) if failure_mode == "revert": @@ -1503,22 +1849,25 @@ def test_failed_create_header_gas_used( else: init_code = Op.INVALID + mstore_value, size = init_code_at_high_bytes(init_code) + create_call = ( - create_opcode(value=0, offset=0, size=len(init_code), salt=0) + create_opcode( + value=0, offset=0, size=size, salt=0, init_code_size=size + ) if create_opcode == Op.CREATE2 - else create_opcode(value=0, offset=0, size=len(init_code)) - ) - - storage = Storage() - factory_code = Op.MSTORE( - 0, - int.from_bytes(bytes(init_code), "big") << (256 - 8 * len(init_code)), - ) + Op.SSTORE( - storage.store_next(0, "create_fails"), - create_call, + else create_opcode(value=0, offset=0, size=size, init_code_size=size) ) + factory_code = Op.MSTORE(0, mstore_value, new_memory_size=32) + create_call factory = pre.deploy_contract(factory_code) + create_address = compute_create_address( + address=factory, + nonce=1, + salt=0, + initcode=bytes(init_code), + opcode=create_opcode, + ) tx = Transaction( to=factory, @@ -1526,12 +1875,35 @@ def test_failed_create_header_gas_used( sender=pre.fund_eoa(), ) + if failure_mode == "revert": + # REVERT returns the unused child grant. Net execution consists only + # of the factory and the initcode instructions that actually ran. + expected_gas_used = ( + intrinsic_cost + + factory_code.execution_cost(fork) + + init_code.execution_cost(fork) + ) + else: + # INVALID burns the child's all-but-one-64th grant. The factory keeps + # one 64th and reaches the end of its code without spending it. + gas_left_before_child = ( + gas_limit_cap - intrinsic_cost - factory_code.execution_cost(fork) + ) + parent_gas_left = gas_left_before_child // 64 + expected_gas_used = gas_limit_cap - parent_gas_left + blockchain_test( pre=pre, blocks=[ - Block(txs=[tx]), + Block( + txs=[tx], + header_verify=Header(gas_used=expected_gas_used), + ), ], - post={factory: Account(storage=storage)}, + post={ + factory: Account(nonce=2), + create_address: Account.NONEXISTENT, + }, ) @@ -1616,38 +1988,31 @@ def test_create_child_revert_refunds_state_gas( `incorporate_child_on_error`). Block state gas reflects only the probe SSTORE. The spillover variant runs with tx.gas at the cap (reservoir zero), so the state gas charge spills into `gas_left` - and the refund returns to the reservoir (not back to `gas_left`). + and the LIFO refund returns it to `gas_left`. """ - gas_limit_cap = fork.transaction_gas_limit_cap() - assert gas_limit_cap is not None sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() init_code = Op.REVERT(0, 0) mstore_value, size = init_code_at_high_bytes(init_code) - create_call = ( - create_opcode(value=0, offset=0, size=size, salt=0) - if create_opcode == Op.CREATE2 - else create_opcode(value=0, offset=0, size=size) + create_call = create_opcode( + value=0, offset=0, size=size, init_code_size=size ) storage = Storage() factory_code = ( - Op.MSTORE(0, mstore_value) + Op.MSTORE(0, mstore_value, new_memory_size=32) + Op.POP(create_call) + Op.SSTORE(storage.store_next(1, "reservoir_ok"), 1) ) factory = pre.deploy_contract(code=factory_code) - gas_limit = ( - gas_limit_cap - if gas_limit_mode == "spillover" - else gas_limit_cap + sstore_state_gas - ) tx = Transaction( to=factory, - gas_limit=gas_limit, + state_gas_reservoir=( + 0 if gas_limit_mode == "spillover" else sstore_state_gas + ), sender=pre.fund_eoa(), ) @@ -1785,11 +2150,8 @@ def call(size: int, salt: int) -> Bytecode: # STOP deploys empty code, so only GAS_NEW_ACCOUNT counts for # the successful CREATE, and the failed CREATE is refunded. block_state = create_account_state_gas - tx_execution = ( - intrinsic_gas - + factory_code.gas_cost(fork) - - 2 * create_account_state_gas - ) + tx_execution = intrinsic_gas + factory_code.execution_cost(fork) + assert block_state > tx_execution, "state gas must dominate" expected = max(tx_execution, block_state) tx = Transaction( @@ -2024,6 +2386,7 @@ def test_create2_failed_deposit_refunds_storage_state_gas( pre=pre, post={factory: Account(storage=storage)}, tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_cumulative), ) @@ -2053,8 +2416,9 @@ def test_create_account_charge_reduces_child_gas( child receives `NEW_ACCOUNT * 63 / 64` less. The init code burns a fixed amount sized between the two shares, so it deploys when the charge comes from the reservoir and runs out of gas when it spills. - The target is a pre-existing balance-only leaf, the EIP-8037 - success-refund path that the old conditional charge skipped. + The target is fresh, so NEW_ACCOUNT is required. The created account is + checked directly, avoiding a post-CREATE SSTORE with an unrelated state + gas requirement. """ new_account = create_opcode(account_new=True).state_cost(fork) memory_gas = fork.memory_expansion_gas_calculator() @@ -2236,43 +2600,42 @@ def test_failed_create_tx_refills_top_frame_new_account( @pytest.mark.pre_alloc_mutable() @pytest.mark.valid_from("EIP8037") -def test_create_tx_collision_no_new_account_charge( +def test_create_tx_collision_has_no_net_new_account_charge( blockchain_test: BlockchainTestFiller, pre: Alloc, fork: Fork, ) -> None: """ - Verify a creation-tx address collision charges no NEW_ACCOUNT. + Verify a creation-tx collision leaves no net NEW_ACCOUNT charge. Under EIP-2780 the created account's ``NEW_ACCOUNT`` is a top-frame charge, but on an address collision the target already exists pre-tx, the create path returns ``AddressCollision`` before the top - frame is prepared, and no ``NEW_ACCOUNT`` is ever charged. The full - forwarded gas is burned as execution (no initcode runs) and block - state-gas is zero, so header ``gas_used`` equals the whole - ``gas_limit``. + frame is prepared. A full NEW_ACCOUNT-sized reservoir is supplied as an + oracle: collision burns the capped execution grant but must return that + reservoir unused. The exact receipt and header therefore equal the gas + limit cap; any surviving account charge increases the receipt. """ - intrinsic_calc = fork.transaction_intrinsic_cost_calculator() - init_code = Op.STOP - intrinsic_execution = intrinsic_calc( - calldata=bytes(init_code), contract_creation=True + gas_limit_cap = fork.transaction_gas_limit_cap() + assert gas_limit_cap is not None + new_account_state = fork.transaction_top_frame_state_gas( + contract_creation=True ) - gas_limit = intrinsic_execution + 1000 sender = pre.fund_eoa() collision_target = compute_create_address(address=sender, nonce=0) pre[collision_target] = Account(nonce=1) - # Collision burns the full forwarded gas as execution; state block is - # zero (no NEW_ACCOUNT charged). - expected_gas_used = gas_limit - tx = Transaction( to=None, data=init_code, - gas_limit=gas_limit, + state_gas_reservoir=new_account_state, sender=sender, + expected_receipt=TransactionReceipt( + status=0, + cumulative_gas_used=gas_limit_cap, + ), ) blockchain_test( @@ -2280,7 +2643,7 @@ def test_create_tx_collision_no_new_account_charge( blocks=[ Block( txs=[tx], - header_verify=Header(gas_used=expected_gas_used), + header_verify=Header(gas_used=gas_limit_cap), ), ], post={collision_target: Account(nonce=1)}, @@ -2313,7 +2676,6 @@ def test_create_tx_collision_refunds_reservoir( # +1 above intrinsic_state_gas (= create_state_gas(code_size=0) # for empty-code CREATE-tx) makes message.state_gas_reservoir > 0. reservoir = fork.create_state_gas(code_size=0) + 1 - gas_limit = gas_limit_cap + reservoir initial_fund = 10**18 sender = pre.fund_eoa(initial_fund) @@ -2324,7 +2686,7 @@ def test_create_tx_collision_refunds_reservoir( tx = Transaction( to=None, data=init_code, - gas_limit=gas_limit, + state_gas_reservoir=reservoir, sender=sender, gas_price=tx_gas_price, ) @@ -2348,19 +2710,19 @@ def test_create_tx_collision_refunds_reservoir( @pytest.mark.valid_from("EIP8037") -def test_create_onto_alive_refunds_to_gas_left( +def test_create_onto_alive_skips_new_account_charge( state_test: StateTestFiller, pre: Alloc, fork: Fork, ) -> None: """ - Verify a refunded CREATE NEW_ACCOUNT charge returns to gas_left. + Verify CREATE2 skips the NEW_ACCOUNT charge for an alive target. - A CREATE2 onto an already-alive (pre-funded, code-less) address - spills the NEW_ACCOUNT charge from the empty reservoir into - gas_left, succeeds, and refunds it. `gas_limit` leaves exactly - `NEW_ACCOUNT` after the create, so the following SSTORE runs only - if the refund returned to gas_left (LIFO) rather than the reservoir. + A pre-funded, code-less target is alive but remains deployable. The + transaction budget includes the static NEW_ACCOUNT estimate; because + runtime must skip that charge, the unused gas lets the following SSTORE + succeed. Charging NEW_ACCOUNT incorrectly consumes that headroom and + prevents the storage probe. """ salt = 0 create = Op.POP(Op.CREATE2(0, 0, 0, salt)) @@ -2462,24 +2824,37 @@ def test_oversized_initcode_opcode_no_state_gas( """ max_size = fork.max_initcode_size() size = max_size + initcode_size_delta - initcode = Initcode(deploy_code=Op.STOP, initcode_length=size) - initcode_bytes = bytes(initcode) + initcode = bytes(size) - create_call = ( + factory_code = ( create_opcode( value=0, offset=0, - size=Op.CALLDATASIZE, + size=size, salt=0, - init_code_size=len(initcode_bytes), + init_code_size=size, + new_memory_size=size, ) if create_opcode == Op.CREATE2 - else create_opcode(value=0, offset=0, size=Op.CALLDATASIZE) + else create_opcode( + value=0, + offset=0, + size=size, + init_code_size=size, + new_memory_size=size, + ) ) - factory = pre.deploy_contract( - Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + Op.SSTORE(0, create_call) + factory = pre.deploy_contract(factory_code) + factory_execution = factory_code.execution_cost(fork) + + caller_code = Op.POP( + Op.CALL( + gas=factory_execution, + address=factory, + ) ) + caller = pre.deploy_contract(caller_code) create_address = compute_create_address( address=factory, @@ -2489,25 +2864,38 @@ def test_oversized_initcode_opcode_no_state_gas( opcode=create_opcode, ) - storage = Storage() - storage[0] = create_address if initcode_size_delta == 0 else 0 + create_state_gas = factory_code.state_cost(fork) + expected_execution = ( + fork.transaction_intrinsic_cost_calculator()() + + caller_code.execution_cost(fork) + + factory_execution + ) + assert create_state_gas > expected_execution, ( + "NEW_ACCOUNT must dominate execution to detect a stray state charge" + ) + expected_state = create_state_gas if initcode_size_delta == 0 else 0 + expected_gas_used = max(expected_execution, expected_state) tx = Transaction( sender=pre.fund_eoa(), - to=factory, - data=initcode_bytes, - state_gas_reservoir=create_call.state_cost(fork), + to=caller, + state_gas_reservoir=create_state_gas, ) - post: dict = {factory: Account(storage=storage)} + post: dict = {factory: Account(nonce=2 if initcode_size_delta == 0 else 1)} if initcode_size_delta == 0: - post[create_address] = Account(code=Op.STOP) + post[create_address] = Account(nonce=1, code=b"") else: post[create_address] = Account.NONEXISTENT blockchain_test( pre=pre, - blocks=[Block(txs=[tx])], + blocks=[ + Block( + txs=[tx], + header_verify=Header(gas_used=expected_gas_used), + ) + ], post=post, ) @@ -2684,7 +3072,7 @@ def test_inner_create_succeeds_code_deposit_state_gas( ) @pytest.mark.with_all_create_opcodes() @pytest.mark.valid_from("EIP8037") -def test_nested_create_fail_parent_revert_state_gas( +def test_nested_create_failure_refunds_state_gas_before_parent_exit( blockchain_test: BlockchainTestFiller, pre: Alloc, fork: Fork, @@ -2693,43 +3081,85 @@ def test_nested_create_fail_parent_revert_state_gas( create_opcode: Op, ) -> None: """ - Verify factory nonce is rolled back when the factory reverts after - a failed inner CREATE, and preserved when the factory returns. + Verify failed inner CREATE state gas is refunded before its parent exits. + + The child fails by REVERT or exceptional halt, and the factory then either + returns or reverts. In every case the failed creation leaves no state gas + charged, so the exact receipt and header contain execution gas only. The + factory nonce additionally proves that a parent revert rolls back the + failed CREATE's nonce bump while a successful parent preserves it. """ if child_failure == "revert": init_code = Op.REVERT(0, 0) else: init_code = Op.INVALID + setup = Op.MSTORE( + 0, + int.from_bytes(bytes(init_code), "big") << (256 - 8 * len(init_code)), + new_memory_size=32, + ) + create_call = ( - create_opcode(value=0, offset=0, size=len(init_code), salt=0) + create_opcode( + value=0, + offset=0, + size=len(init_code), + salt=0, + # gas accounting + init_code_size=len(init_code), + ) if create_opcode == Op.CREATE2 - else create_opcode(value=0, offset=0, size=len(init_code)) + else create_opcode( + value=0, + offset=0, + size=len(init_code), + # gas accounting + init_code_size=len(init_code), + ) ) create_state_gas = create_call.state_cost(fork) - factory = pre.deploy_contract( - code=( - Op.MSTORE( - 0, - int.from_bytes(bytes(init_code), "big") - << (256 - 8 * len(init_code)), - ) - + Op.POP(create_call) - + (Op.REVERT(0, 0) if parent_reverts else Op.STOP) - ), - ) + factory_before_child = setup + create_call + factory_after_child = Op.REVERT(0, 0) if parent_reverts else Op.STOP + factory_code = factory_before_child + factory_after_child + factory = pre.deploy_contract(code=factory_code) # Nested CALL required so the child-error path has a parent # frame to receive the restored state gas. - caller = pre.deploy_contract( - code=Op.POP(Op.CALL(gas=500_000, address=factory)), - ) + factory_gas = 500_000 + caller_code = Op.POP(Op.CALL(gas=factory_gas, address=factory)) + caller = pre.deploy_contract(code=caller_code) + + intrinsic_execution = fork.transaction_intrinsic_cost_calculator()() + if child_failure == "revert": + expected_execution = ( + intrinsic_execution + + caller_code.execution_cost(fork) + + factory_code.execution_cost(fork) + + init_code.execution_cost(fork) + ) + else: + gas_before_child = factory_gas - factory_before_child.execution_cost( + fork + ) + child_gas = gas_before_child - gas_before_child // 64 + expected_execution = ( + intrinsic_execution + + caller_code.execution_cost(fork) + + factory_before_child.execution_cost(fork) + + child_gas + + factory_after_child.execution_cost(fork) + ) tx = Transaction( to=caller, state_gas_reservoir=create_state_gas, sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + status=1, + cumulative_gas_used=expected_execution, + ), ) inner_address = compute_create_address( @@ -2753,7 +3183,12 @@ def test_nested_create_fail_parent_revert_state_gas( blockchain_test( pre=pre, - blocks=[Block(txs=[tx])], + blocks=[ + Block( + txs=[tx], + header_verify=Header(gas_used=expected_execution), + ) + ], post=post, ) From 814b31327b2f1f139fe5ffd7fd1c2e91e8f9e7e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 2 Sep 2026 14:09:16 +0200 Subject: [PATCH 43/59] feat(tests): cover a cross-frame state gas refund after a delegation spill (#3499) --- .../test_state_gas_cross_frame_refund.py | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py index f772c9d8bc4..f1cb5387a3e 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py @@ -23,6 +23,7 @@ from execution_testing import ( Account, Alloc, + AuthorizationTuple, Bytecode, Fork, Op, @@ -32,6 +33,8 @@ TransactionReceipt, ) +from tests.prague.eip7702_set_code_tx.spec import Spec as Spec7702 + from .spec import ref_spec_8037 REFERENCE_SPEC_GIT_PATH = ref_spec_8037.git_path @@ -514,3 +517,102 @@ def test_child_clear_repays_own_spill_first( post = {contract: Account(storage=storage)} state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.valid_from("EIP8037") +def test_cross_frame_refund_after_delegation_spill( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test a cross-frame refund after the sender's delegation spilled. + + A set-code transaction with an empty reservoir pays its delegation + from `gas_left` and commits that spill before the code runs. The + code spills a fresh set and a delegated child clears it. The call + costs the same as without the delegation and the delegation stays + billed. + """ + fresh_set = Op.SSTORE.with_metadata( + key_warm=False, + original_value=0, + current_value=0, + new_value=1, + ) + warm_clear = Op.SSTORE.with_metadata( + key_warm=True, + original_value=0, + current_value=1, + new_value=0, + ) + + child_code = warm_clear(SLOT_X, 0) + child = pre.deploy_contract(code=child_code) + # SSTORE needs more than the call stipend left, so give the child + # that much on top of its cost. + child_budget = fork.call_value_stipend() + 1 + child_code.gas_cost(fork) + + call = Op.POP( + Op.DELEGATECALL(gas=child_budget, address=child, address_warm=False) + ) + code = ( + Op.MSTORE(32, 0, new_memory_size=64, old_memory_size=0) + + fresh_set(SLOT_X, 1) + + Op.MSTORE(0, Op.GAS) + + call + + Op.MSTORE(32, Op.GAS) + + fresh_set(SLOT_RESULT, Op.SUB(Op.MLOAD(0), Op.MLOAD(32))) + ) + contract = pre.deploy_contract(code=code) + + signer = pre.fund_eoa() + authorization_list = [ + AuthorizationTuple( + address=contract, + nonce=0, + signer=signer, + creates_account=False, + writes_delegation=True, + ) + ] + + # The window runs from one GAS read to the next: the store of the + # first read, the call and the second read. + call_cost = ( + Op.MSTORE(0, Op.GAS).gas_cost(fork) + + call.execution_cost(fork) + + child_code.execution_cost(fork) + ) + + gas_used = ( + fork.transaction_intrinsic_cost_calculator()( + authorization_list_or_count=authorization_list, + return_cost_deducted_prior_execution=True, + ) + + fork.transaction_top_frame_gas_calculator()( + authorizations=authorization_list + ) + + fork.transaction_top_frame_state_gas( + authorizations=authorization_list + ) + + code.gas_cost(fork) + + child_code.gas_cost(fork) + - child_code.state_refund(fork) + ) + refund = child_code.refund(fork) - child_code.state_refund(fork) + gas_used -= min(gas_used // fork.max_refund_quotient(), refund) + + tx = Transaction( + to=contract, + authorization_list=authorization_list, + state_gas_reservoir=0, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt(cumulative_gas_used=gas_used), + ) + + post = { + contract: Account(storage={SLOT_X: 0, SLOT_RESULT: call_cost}), + signer: Account(code=Spec7702.delegation_designation(contract)), + } + state_test(pre=pre, post=post, tx=tx) From 8faf36a445d46a8f40ab1f4d274edcbe5a75ba17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 2 Sep 2026 14:43:41 +0200 Subject: [PATCH 44/59] feat(tests): cover a cross-frame state gas refund with a funded reservoir (#3498) --- .../test_state_gas_cross_frame_refund.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py index f1cb5387a3e..2e657215d26 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py @@ -45,6 +45,8 @@ SLOT_MARKER = 3 SLOT_RESULT = 4 SLOT_INCREASED = 5 +SLOT_PROBE = 6 +SLOT_PROBE_RESULT = 7 def window_cost_excess(result_sstore: Opcode = Op.SSTORE) -> Bytecode: @@ -616,3 +618,97 @@ def test_cross_frame_refund_after_delegation_spill( signer: Account(code=Spec7702.delegation_designation(contract)), } state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.parametrize("reservoir_slots", [0, 1, 2]) +@pytest.mark.valid_from("EIP8037") +def test_cross_frame_refund_with_reservoir_grant( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + reservoir_slots: int, +) -> None: + """ + Test a cross-frame refund with a reservoir the sender paid for. + + The reservoir covers none, one or both of the parent's two sets and + the rest spill. A delegated child clears both slots. The call and a + later set cost the same in every case: the refund stays in the + reservoir and the spill is not repaid. + """ + sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + fresh_set = Op.SSTORE.with_metadata( + key_warm=False, + original_value=0, + current_value=0, + new_value=1, + ) + warm_clear = Op.SSTORE.with_metadata( + key_warm=True, + original_value=0, + current_value=1, + new_value=0, + ) + + child_code = warm_clear(SLOT_X, 0) + warm_clear(SLOT_Y, 0) + child = pre.deploy_contract(code=child_code) + # SSTORE needs more than the call stipend left, so give the child + # that much on top of its cost. + child_budget = fork.call_value_stipend() + 1 + child_code.gas_cost(fork) + + call = Op.POP( + Op.DELEGATECALL(gas=child_budget, address=child, address_warm=False) + ) + probe = fresh_set(SLOT_PROBE, 1) + code = ( + Op.MSTORE(64, 0, new_memory_size=96, old_memory_size=0) + + fresh_set(SLOT_X, 1) + + fresh_set(SLOT_Y, 1) + + Op.MSTORE(0, Op.GAS) + + call + + Op.MSTORE(32, Op.GAS) + + probe + + Op.MSTORE(64, Op.GAS) + + fresh_set(SLOT_RESULT, Op.SUB(Op.MLOAD(0), Op.MLOAD(32))) + + fresh_set(SLOT_PROBE_RESULT, Op.SUB(Op.MLOAD(32), Op.MLOAD(64))) + ) + contract = pre.deploy_contract(code=code) + + # A window runs from one GAS read to the next: the store of the + # first read, the window's code and the second read. + stamp_cost = Op.MSTORE(0, Op.GAS).gas_cost(fork) + call_cost = ( + stamp_cost + + call.execution_cost(fork) + + child_code.execution_cost(fork) + ) + probe_cost = stamp_cost + probe.execution_cost(fork) + + gas_used = ( + fork.transaction_intrinsic_cost_calculator()() + + code.gas_cost(fork) + + child_code.gas_cost(fork) + - child_code.state_refund(fork) + ) + refund = child_code.refund(fork) - child_code.state_refund(fork) + gas_used -= min(gas_used // fork.max_refund_quotient(), refund) + + tx = Transaction( + to=contract, + state_gas_reservoir=reservoir_slots * sstore_state_gas, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt(cumulative_gas_used=gas_used), + ) + + post = { + contract: Account( + storage={ + SLOT_X: 0, + SLOT_Y: 0, + SLOT_PROBE: 1, + SLOT_RESULT: call_cost, + SLOT_PROBE_RESULT: probe_cost, + } + ) + } + state_test(pre=pre, post=post, tx=tx) From 76910d3138a2dcdfee5d69d93a7b6a7d827ffbfe Mon Sep 17 00:00:00 2001 From: spencer Date: Wed, 2 Sep 2026 15:38:55 +0200 Subject: [PATCH 45/59] fix(test-specs): refuse RLP blockchain fixtures for engine-payload-only block overrides (#3501) --- .../src/execution_testing/specs/blockchain.py | 25 ++++++++++ .../specs/tests/test_types.py | 46 ++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 0163cebfad7..a77af83a4df 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -375,6 +375,21 @@ def phase(self) -> TestPhase | None: "split via _split_blocks_by_phase first." ) + def engine_payload_only_overrides(self) -> List[str]: + """ + Return the names of the settings that only reach the engine payload. + + An RLP block cannot carry them, so a fixture that delivers blocks as + RLP would present a valid block while claiming the exception they + are meant to cause. + """ + overrides: List[str] = [] + if self.engine_new_payload_block_access_list is not None: + overrides.append("engine_new_payload_block_access_list") + if self.engine_new_payload_slot_number is not None: + overrides.append("engine_new_payload_slot_number") + return overrides + def set_environment(self, env: Environment) -> Environment: """ Create copy of the environment with the characteristics of this @@ -1158,6 +1173,16 @@ def make_fixture( t8n: FillerBackend, ) -> FillResult: """Create a fixture from the blockchain test definition.""" + for i, block in enumerate(self.blocks): + overrides = block.engine_payload_only_overrides() + if overrides: + raise Exception( + f"test correctness: block {i} sets {', '.join(overrides)}" + ", which only reach the engine payload and cannot be " + "expressed in an RLP blockchain fixture. Mark the test " + "`blockchain_test_engine_only`." + ) + fixture_blocks: List[FixtureBlock | InvalidFixtureBlock] = [] pre, genesis = self.make_genesis(apply_pre_allocation_blockchain=True) diff --git a/packages/testing/src/execution_testing/specs/tests/test_types.py b/packages/testing/src/execution_testing/specs/tests/test_types.py index 838f98f68b5..186f2428c5f 100644 --- a/packages/testing/src/execution_testing/specs/tests/test_types.py +++ b/packages/testing/src/execution_testing/specs/tests/test_types.py @@ -1,5 +1,7 @@ """Test types from execution_testing.specs.""" +from unittest.mock import sentinel + import pytest from execution_testing.base_types import ( @@ -16,10 +18,10 @@ FixtureHeader, ) from execution_testing.forks import Amsterdam -from execution_testing.test_types import Environment +from execution_testing.test_types import Alloc, Environment from execution_testing.test_types.block_access_list import BlockAccessList -from ..blockchain import BuiltBlock, Header +from ..blockchain import Block, BlockchainTest, BuiltBlock, Header fixture_header_ones = FixtureHeader( parent_hash=Hash(1), @@ -256,3 +258,43 @@ def test_empty_bytes_override_sends_raw_body(self) -> None: ).engine_payload_modifier() assert isinstance(modifier, FixtureExecutionPayloadModifier) assert modifier.block_access_list == Bytes(b"") + + +class TestEnginePayloadOnlyOverrides: + """ + A block setting that only reaches the engine payload cannot be expressed + in an RLP blockchain fixture, so ``make_fixture`` refuses to build one. + """ + + @pytest.mark.parametrize( + "block,expected", + [ + pytest.param(Block(), [], id="none"), + pytest.param( + Block(engine_new_payload_block_access_list=Bytes(b"")), + ["engine_new_payload_block_access_list"], + id="payload_bal", + ), + pytest.param( + Block(engine_new_payload_slot_number=0), + ["engine_new_payload_slot_number"], + id="payload_slot_number", + ), + ], + ) + def test_overrides_are_named( + self, block: Block, expected: list[str] + ) -> None: + """Each payload-only setting is reported by name.""" + assert block.engine_payload_only_overrides() == expected + + def test_make_fixture_refuses_payload_only_override(self) -> None: + """The RLP fixture builder fails before it touches the t8n.""" + test = BlockchainTest( + fork=Amsterdam, + pre=Alloc(), + post=Alloc(), + blocks=[Block(engine_new_payload_slot_number=0)], + ) + with pytest.raises(Exception, match="blockchain_test_engine_only"): + test.make_fixture(sentinel.t8n) From a7d4186bdb9e14815bd6e49efc77537bf26fe866 Mon Sep 17 00:00:00 2001 From: shubham shinde Date: Wed, 2 Sep 2026 20:02:16 +0530 Subject: [PATCH 46/59] feat(tests): add precompile as block coinbase coverage (#3462) --- .../frontier/precompiles/test_precompiles.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/frontier/precompiles/test_precompiles.py b/tests/frontier/precompiles/test_precompiles.py index acb06089fff..782eb1ee18b 100644 --- a/tests/frontier/precompiles/test_precompiles.py +++ b/tests/frontier/precompiles/test_precompiles.py @@ -7,6 +7,8 @@ Account, Address, Alloc, + Block, + BlockchainTestFiller, Fork, Op, StateTestFiller, @@ -139,3 +141,43 @@ def test_precompiles( post = {account: Account(storage={0: 0 if precompile_exists else 1})} state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.valid_from("Frontier") +@pytest.mark.with_all_precompiles +def test_precompile_as_coinbase( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + precompile: int, +) -> None: + """ + Verify that an enabled precompile as block coinbase yields a valid + state transition. + """ + sender = pre.fund_eoa() + coinbase = Address(precompile) + + blocks = [ + Block( + fee_recipient=coinbase, + txs=[ + Transaction( + sender=sender, + to=sender, + protected=fork.supports_protected_txs(), + ), + ], + ), + Block( + fee_recipient=coinbase, + txs=[ + Transaction( + sender=sender, + to=coinbase, + protected=fork.supports_protected_txs(), + ), + ], + ), + ] + blockchain_test(pre=pre, post={}, blocks=blocks) From 1e7d0e74d588eb382e289c9dd50a60d7a6185297 Mon Sep 17 00:00:00 2001 From: spencer Date: Wed, 2 Sep 2026 16:42:50 +0200 Subject: [PATCH 47/59] fix(test-specs): fail loudly if a test sets env fields not supported by the target fork (#3488) Co-authored-by: danceratopz --- .../src/execution_testing/specs/blockchain.py | 6 ++ .../src/execution_testing/specs/state.py | 8 ++ .../specs/tests/test_specs.py | 22 ++++++ .../test_types/block_types.py | 65 ++++++++++++++- .../tests/test_environment_fork_fields.py | 79 +++++++++++++++++++ .../test_opcodes_transaction_init.py | 38 +++++---- 6 files changed, 203 insertions(+), 15 deletions(-) create mode 100644 packages/testing/src/execution_testing/test_types/tests/test_environment_fork_fields.py diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index a77af83a4df..d8982912dd3 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -828,6 +828,11 @@ def model_post_init(self, __context: Any, /) -> None: def get_genesis_environment(self) -> Environment: """Get the genesis environment for pre-allocation groups.""" + # Checked before the defaults below add the mix hash under + # `prev_randao`, which every genesis header carries. + self.genesis_environment.check_fork_fields( + self.fork.transitions_from() + ) modified_values = self.genesis_environment.set_fork_requirements( self.fork.transitions_from() ).model_dump(exclude_unset=True) @@ -893,6 +898,7 @@ def generate_block_data( block_number=env.number, timestamp=env.timestamp ) env = env.set_fork_requirements(fork) + env.check_fork_fields(fork) txs = block.txs[:] if any("gas_limit" not in tx.model_fields_set for tx in block.txs): max_tx_gas_limit = Transaction.calculate_max_gas_limit( diff --git a/packages/testing/src/execution_testing/specs/state.py b/packages/testing/src/execution_testing/specs/state.py index 16323dfad68..16b88605210 100644 --- a/packages/testing/src/execution_testing/specs/state.py +++ b/packages/testing/src/execution_testing/specs/state.py @@ -322,6 +322,13 @@ def _generate_blockchain_blocks(self) -> List[Block]: def generate_blockchain_test(self) -> BlockchainTest: """Generate a BlockchainTest fixture from this StateTest fixture.""" + # Checked before the genesis derivation below, which asks the + # fork for blob constants a fork without blobs does not have. + self.env.check_fork_fields( + self.fork.fork_at( + block_number=self.env.number, timestamp=self.env.timestamp + ) + ) return BlockchainTest.from_test( base_test=self, genesis_environment=self._generate_blockchain_genesis_environment(), @@ -343,6 +350,7 @@ def make_state_test_fixture( ) env = self.env.set_fork_requirements(fork) + env.check_fork_fields(fork) tx = self.tx.with_gas_limit( max_gas_limit=env.gas_limit, transaction_gas_limit_cap=fork.transaction_gas_limit_cap(), diff --git a/packages/testing/src/execution_testing/specs/tests/test_specs.py b/packages/testing/src/execution_testing/specs/tests/test_specs.py index 9204f1330ca..ddf2b9c8e80 100644 --- a/packages/testing/src/execution_testing/specs/tests/test_specs.py +++ b/packages/testing/src/execution_testing/specs/tests/test_specs.py @@ -11,6 +11,8 @@ LabeledFixtureFormat, StateFixture, ) +from execution_testing.forks import Istanbul +from execution_testing.test_types import Alloc, Environment, Transaction from ..base import BaseTest from ..blockchain import BlockchainTest @@ -177,3 +179,23 @@ def test_state_test_labels_every_blockchain_test_format() -> None: derived[label].transition_tool_cache_key == fixture_format.transition_tool_cache_key ) + + +def test_state_test_conversion_checks_the_env_first() -> None: + """ + Verify converting a state test to a blockchain test reports an env + field the fork lacks with the field check's message. + + The genesis derivation runs before `BlockchainTest` checks the env, + and for a blob field on a fork without blobs it fails on the fork's + missing blob constants instead, which does not name the field. + """ + state_test = StateTest( + env=Environment(excess_blob_gas=1), + pre=Alloc(), + post=Alloc(), + tx=Transaction(), + fork=Istanbul, + ) + with pytest.raises(ValueError, match="excess_blob_gas"): + state_test.generate_blockchain_test() diff --git a/packages/testing/src/execution_testing/test_types/block_types.py b/packages/testing/src/execution_testing/test_types/block_types.py index c905196ec47..98ddb6ce161 100644 --- a/packages/testing/src/execution_testing/test_types/block_types.py +++ b/packages/testing/src/execution_testing/test_types/block_types.py @@ -3,7 +3,7 @@ import json from dataclasses import dataclass from functools import cached_property -from typing import Any, Dict, Generic, List, Sequence +from typing import Any, Callable, Dict, Generic, List, Sequence import ethereum_rlp as eth_rlp from ethereum_types.numeric import Uint @@ -27,6 +27,28 @@ CURRENT_MAINNET_BLOCK_GAS_LIMIT = 60_000_000 DEFAULT_BLOCK_GAS_LIMIT = CURRENT_MAINNET_BLOCK_GAS_LIMIT * 2 +FORK_GATED_FIELDS: Dict[str, Callable[[Fork], bool]] = { + "prev_randao": lambda fork: fork.header_prev_randao_required(), + "base_fee_per_gas": lambda fork: fork.header_base_fee_required(), + "parent_base_fee_per_gas": lambda fork: fork.header_base_fee_required(), + "withdrawals": lambda fork: fork.header_withdrawals_required(), + "excess_blob_gas": lambda fork: fork.header_excess_blob_gas_required(), + "parent_excess_blob_gas": ( + lambda fork: fork.header_excess_blob_gas_required() + ), + "blob_gas_used": lambda fork: fork.header_blob_gas_used_required(), + "parent_blob_gas_used": lambda fork: fork.header_blob_gas_used_required(), + "parent_beacon_block_root": ( + lambda fork: fork.header_beacon_root_required() + ), + "slot_number": lambda fork: fork.header_slot_number_required(), + "parent_slot_number": lambda fork: fork.header_slot_number_required(), +} +""" +Environment fields, current and parent, that only some block headers +carry, keyed by the fork predicate that admits each one. +""" + @dataclass class EnvironmentDefaults: @@ -214,6 +236,47 @@ def set_fork_requirements(self, fork: Fork) -> "Environment": return self.copy(**updated_values) + @classmethod + def for_fork(cls, fork: Fork, **kwargs: Any) -> "Environment": + """ + Build an environment from only the fields the fork's header has. + + Use it when one pinned context spans forks whose headers differ: + the pins the fork lacks are dropped instead of raising in + `check_fork_fields`. + """ + return cls( + **{ + name: value + for name, value in kwargs.items() + if name not in FORK_GATED_FIELDS + or FORK_GATED_FIELDS[name](fork) + } + ) + + def check_fork_fields(self, fork: Fork) -> None: + """ + Raise if a set field is one the fork's block header lacks. + + Serializing such a field into a fixture would misstate the block + context, so the test has to state its intent instead. + """ + unsupported = [ + name + for name, required in FORK_GATED_FIELDS.items() + if getattr(self, name) is not None and not required(fork) + ] + if not unsupported: + return + raise ValueError( + f"{', '.join(unsupported)} set for {fork.name()}, whose block " + "header has no such field. Remove the field from the test, " + "build the environment with Environment.for_fork(fork, ...) " + "to keep only the fields the fork has, or, to check that " + "clients reject the field, set it on the block through " + "Block(rlp_modifier=Header(...))." + ) + def canonical_json(self) -> str: """ Return the canonical JSON encoding of this model. diff --git a/packages/testing/src/execution_testing/test_types/tests/test_environment_fork_fields.py b/packages/testing/src/execution_testing/test_types/tests/test_environment_fork_fields.py new file mode 100644 index 00000000000..7374034836d --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/tests/test_environment_fork_fields.py @@ -0,0 +1,79 @@ +""" +Test the fork gating of the environment fields some headers lack. +""" + +from typing import Any, Dict, Set + +import pytest + +from execution_testing.forks import Amsterdam, Cancun, Fork, Istanbul, Paris + +from ..block_types import Environment + +PINS: Dict[str, Any] = { + "prev_randao": 0x20000, + "base_fee_per_gas": 10, + "parent_base_fee_per_gas": 9, + "withdrawals": [], + "excess_blob_gas": 3, + "parent_excess_blob_gas": 2, + "blob_gas_used": 4, + "parent_blob_gas_used": 1, + "parent_beacon_block_root": 5, + "slot_number": 7, + "parent_slot_number": 6, +} + +DROPPED: Dict[Fork, Set[str]] = { + Istanbul: set(PINS), + Paris: { + "withdrawals", + "excess_blob_gas", + "parent_excess_blob_gas", + "blob_gas_used", + "parent_blob_gas_used", + "parent_beacon_block_root", + "slot_number", + "parent_slot_number", + }, + Cancun: {"slot_number", "parent_slot_number"}, + Amsterdam: set(), +} + +fork_cases = pytest.mark.parametrize( + "fork", list(DROPPED), ids=lambda fork: fork.name() +) + + +@fork_cases +def test_for_fork_keeps_only_the_fork_fields(fork: Fork) -> None: + """Pins the fork's header lacks are dropped, the rest kept verbatim.""" + env = Environment.for_fork(fork, **PINS) + for name, value in PINS.items(): + if name in DROPPED[fork]: + assert getattr(env, name) is None, name + else: + assert getattr(env, name) == value, name + + +@fork_cases +@pytest.mark.parametrize("name", list(PINS)) +def test_check_fork_fields(fork: Fork, name: str) -> None: + """ + A pin the fork's header lacks raises, naming the field and both + ways out. + """ + env = Environment(**{name: PINS[name]}) + if name in DROPPED[fork]: + with pytest.raises(ValueError, match=name) as excinfo: + env.check_fork_fields(fork) + assert "Environment.for_fork" in str(excinfo.value) + assert "rlp_modifier" in str(excinfo.value) + else: + env.check_fork_fields(fork) + + +@fork_cases +def test_fork_requirements_pass_the_check(fork: Fork) -> None: + """The fields a fork requires never trip its own check.""" + Environment().set_fork_requirements(fork).check_fork_fields(fork) diff --git a/tests/ported_static/stTransactionTest/test_opcodes_transaction_init.py b/tests/ported_static/stTransactionTest/test_opcodes_transaction_init.py index b427924dca7..a9be258552b 100644 --- a/tests/ported_static/stTransactionTest/test_opcodes_transaction_init.py +++ b/tests/ported_static/stTransactionTest/test_opcodes_transaction_init.py @@ -123,17 +123,6 @@ def _jump_over_revert(conditional: bool) -> Bytecode: return code -ENV = Environment( - fee_recipient=COINBASE, - number=BLOCK_NUMBER, - timestamp=BLOCK_TIMESTAMP, - prev_randao=PREV_RANDAO, - base_fee_per_gas=BASE_FEE_PER_GAS, - excess_blob_gas=EXCESS_BLOB_GAS, - slot_number=SLOT_NUMBER, -) - - @dataclass(frozen=True) class Targets: """What an init-code body may need beyond the opcode itself.""" @@ -154,6 +143,7 @@ class Context: sender: Address init_code: Bytecode fork: Fork + env: Environment @dataclass(frozen=True) @@ -405,7 +395,7 @@ def _address_word(address: Address) -> int: Op.TIMESTAMP: Case(Op.TIMESTAMP, BLOCK_TIMESTAMP), Op.PREVRANDAO: Case(Op.PREVRANDAO, PREV_RANDAO), Op.BASEFEE: Case(Op.BASEFEE, BASE_FEE_PER_GAS), - Op.GASLIMIT: Case(Op.GASLIMIT, int(ENV.gas_limit)), + Op.GASLIMIT: Case(Op.GASLIMIT, lambda c: int(c.env.gas_limit)), Op.SLOTNUM: Case(Op.SLOTNUM, SLOT_NUMBER), Op.BLOBBASEFEE: Case( Op.BLOBBASEFEE, @@ -547,8 +537,28 @@ def test_opcodes_transaction_init( + Op.RETURN(offset=0x0, size=WORD) ) + env = Environment.for_fork( + fork, + fee_recipient=COINBASE, + number=BLOCK_NUMBER, + timestamp=BLOCK_TIMESTAMP, + prev_randao=PREV_RANDAO, + base_fee_per_gas=BASE_FEE_PER_GAS, + excess_blob_gas=EXCESS_BLOB_GAS, + slot_number=SLOT_NUMBER, + # Pre-merge, opcode 0x44 reads the difficulty instead. + **( + {} + if fork.header_prev_randao_required() + else {"difficulty": PREV_RANDAO} + ), + ) context = Context( - created=created, sender=sender, init_code=init_code, fork=fork + created=created, + sender=sender, + init_code=init_code, + fork=fork, + env=env, ) deployed_code = b"" if case.expected is not None: @@ -581,4 +591,4 @@ def test_opcodes_transaction_init( if case.extra is not None: post.update(case.extra(context)) - state_test(env=ENV, pre=pre, post=post, tx=tx) + state_test(env=env, pre=pre, post=post, tx=tx) From ad1952495186496c24721f4da2a265b1cd9ccb1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 2 Sep 2026 17:14:42 +0200 Subject: [PATCH 48/59] feat(tests): sweep the refund merge over every call and create opcode (#3506) --- .../test_state_gas_cross_frame_refund.py | 178 +++++++++++++++--- 1 file changed, 157 insertions(+), 21 deletions(-) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py index 2e657215d26..f45be952bde 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py @@ -22,6 +22,7 @@ import pytest from execution_testing import ( Account, + Address, Alloc, AuthorizationTuple, Bytecode, @@ -35,7 +36,7 @@ from tests.prague.eip7702_set_code_tx.spec import Spec as Spec7702 -from .spec import ref_spec_8037 +from .spec import init_code_at_high_bytes, ref_spec_8037 REFERENCE_SPEC_GIT_PATH = ref_spec_8037.git_path REFERENCE_SPEC_VERSION = ref_spec_8037.version @@ -67,6 +68,47 @@ def window_cost_excess(result_sstore: Opcode = Op.SSTORE) -> Bytecode: ) +def deploy_slot_holder(pre: Alloc) -> Address: + """ + Deploy the contract owning the slot the dispatches clear. + + It stores its calldata size, so a call carrying one byte sets the + slot and a call carrying none clears it. + """ + return pre.deploy_contract(code=Op.SSTORE(SLOT_X, Op.CALLDATASIZE)) + + +def clearing_probe_code( + set_slot: Bytecode, windows: list[Bytecode] +) -> Bytecode: + """ + Return code measuring a clearing window against a no-op window. + + `windows` holds three copies of the dispatch under test. The first + warms the target and the slot while the slot is still zero, and + pre-expands the measurement memory, so the two measured windows + below are byte-identical and cost-identical. `set_slot` then sets + the slot with the reservoir empty, spilling the state charge. The + first measured window clears the slot and the second repeats as a + no-op, so the refunded state gas is what separates their cost. + """ + warm, first, second = windows + return ( + warm + + Op.MSTORE(64, 0) + + set_slot + + Op.MSTORE(0, Op.GAS) + + first + + Op.MSTORE(32, Op.GAS) + + second + + Op.MSTORE(64, Op.GAS) + + Op.SSTORE(SLOT_INCREASED, Op.GT(Op.MLOAD(32), Op.MLOAD(0))) + + window_cost_excess() + # The marker distinguishes the pinned run from a reverted one. + + Op.SSTORE(SLOT_MARKER, 1) + ) + + @pytest.mark.valid_from("EIP8037") def test_cross_frame_refund_parks_in_reservoir( state_test: StateTestFiller, @@ -82,27 +124,10 @@ def test_cross_frame_refund_parks_in_reservoir( the clearing window costs the same as a no-op window. """ clearer = pre.deploy_contract(code=Op.SSTORE(SLOT_X, 0)) - - call_window = Op.POP(Op.DELEGATECALL(address=clearer)) - code = ( - # Warm the clearer and the slot while it is still zero, and - # pre-expand the measurement memory, so the two measured - # windows below are byte-identical and cost-identical. - call_window - + Op.MSTORE(64, 0) - + Op.SSTORE(SLOT_X, 1) - + Op.MSTORE(0, Op.GAS) - + call_window - + Op.MSTORE(32, Op.GAS) - + call_window - + Op.MSTORE(64, Op.GAS) - + Op.SSTORE(SLOT_INCREASED, Op.GT(Op.MLOAD(32), Op.MLOAD(0))) - + window_cost_excess() - # Every other expected slot is zero, so the marker is what - # distinguishes the pinned run from a reverted one. - + Op.SSTORE(SLOT_MARKER, 1) + window = Op.POP(Op.DELEGATECALL(address=clearer)) + contract = pre.deploy_contract( + code=clearing_probe_code(Op.SSTORE(SLOT_X, 1), [window] * 3) ) - contract = pre.deploy_contract(code=code) tx = Transaction( to=contract, @@ -712,3 +737,114 @@ def test_cross_frame_refund_with_reservoir_grant( ) } state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.with_all_call_opcodes( + # A static child cannot write, so it can never refund. + selector=lambda call_opcode: call_opcode != Op.STATICCALL +) +@pytest.mark.valid_from("EIP8037") +def test_cross_frame_refund_parks_in_reservoir_at_a_call( + state_test: StateTestFiller, + pre: Alloc, + call_opcode: Opcode, +) -> None: + """ + Test a call merging a refund parks it in the reservoir. + + A holder contract owns the cleared slot, so every dispatch reaches + it the same way. The clearing window and the no-op window cost the + same: the refund stays in the reservoir across the merge. + """ + holder = deploy_slot_holder(pre) + set_slot = Op.POP(Op.CALL(address=holder, args_size=1)) + clearer = pre.deploy_contract(code=Op.CALL(address=holder)) + window = Op.POP(call_opcode(address=clearer)) + contract = pre.deploy_contract( + code=clearing_probe_code(set_slot, [window] * 3) + ) + + tx = Transaction( + to=contract, + state_gas_reservoir=0, + sender=pre.fund_eoa(), + ) + + # Under the merge-time repayment of ethereum/EIPs#12265 the + # clearing window repays the spill and the excess wraps to minus + # the slot's state cost. + post = { + holder: Account(storage={SLOT_X: 0}), + contract: Account( + storage={SLOT_MARKER: 1, SLOT_INCREASED: 0, SLOT_RESULT: 0} + ), + } + state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.with_all_create_opcodes +@pytest.mark.valid_from("EIP8037") +def test_cross_frame_refund_parks_in_reservoir_at_a_create( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + create_opcode: Opcode, +) -> None: + """ + Test a create merging a refund parks it in the reservoir. + + The initcode reaches a holder contract that clears its own slot. + The refund stays in the reservoir across the merge, where the next + window's account creation charge draws on it, so the clearing + window costs one slot's state gas more than the no-op window. + """ + holder = deploy_slot_holder(pre) + set_slot = Op.POP(Op.CALL(address=holder, args_size=1)) + # Pushing the zero arguments with PUSH0 keeps the initcode inside a + # single memory word. + initcode = Op.CALL( + Op.GAS, holder, Op.PUSH0, Op.PUSH0, Op.PUSH0, Op.PUSH0, Op.PUSH0 + ) + mstore_value, size = init_code_at_high_bytes(initcode) + + # The probe measures with memory below 96, so the initcode sits + # above it. + code_offset = 96 + + def window(salt: int) -> Bytecode: + # A repeated CREATE2 salt would collide with the account the + # previous window created. + if create_opcode == Op.CREATE2: + return Op.POP(Op.CREATE2(0, code_offset, size, salt)) + return Op.POP(Op.CREATE(0, code_offset, size)) + + contract = pre.deploy_contract( + code=Op.MSTORE(code_offset, mstore_value) + + clearing_probe_code(set_slot, [window(salt) for salt in range(3)]) + ) + + tx = Transaction( + to=contract, + state_gas_reservoir=0, + sender=pre.fund_eoa(), + ) + + cleared = Op.SSTORE.with_metadata( + key_warm=True, + original_value=0, + current_value=1, + new_value=0, + ) + # Under the merge-time repayment of ethereum/EIPs#12265 the refund + # reaches `gas_left` instead, so the excess flips sign. + post = { + holder: Account(storage={SLOT_X: 0}), + contract: Account( + storage={ + SLOT_MARKER: 1, + SLOT_INCREASED: 0, + SLOT_RESULT: cleared.state_refund(fork), + } + ), + } + state_test(pre=pre, post=post, tx=tx) From 0d9d91946f41d6a82850705204a22aa66185f576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20Wahrst=C3=A4tter?= <51536394+nerolation@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:32:05 +0200 Subject: [PATCH 49/59] feat(tests,test-specs): EIP-7928 - reject non-minimally encoded BAL scalars (#3486) * feat(tests,test-specs): EIP-7928 - reject non-minimally encoded BAL scalars * feat(tests,test-specs): EIP-7928 - cover every non-minimally encoded BAL scalar * fix(clis): map besu's block access list decode error With besu-eth/besu#11216, besu answers a block access list that fails RLP decoding with {status: INVALID} and the validationError "Failed to decode block access list payload parameter (...)", as execution-apis#869 requires for engine_newPayloadV5. No pattern matched that message, so a besu that correctly rejects the payload failed with an undefined exception instead. Geth's and Nethermind's equivalents were already mapped. * fix(test-specs): make BAL scalar leaf lookup explicit and unit-test encoding modifiers * feat(tests,test-specs): cover non-minimal block_access_index and nonce encodings * feat(tests,test-specs): cover the header committing to non-minimal BAL bytes * fix(test-specs): refuse RLP blockchain fixtures for payload-only BAL re-encodings --------- Co-authored-by: fselmo --- .../client_clis/clis/besu.py | 3 +- .../src/execution_testing/specs/blockchain.py | 24 +++- .../specs/tests/test_types.py | 25 ++++- .../block_access_list/expectations.py | 43 +++++++- .../test_types/block_access_list/modifiers.py | 102 ++++++++++++++++- .../test_types/block_access_list/t8n.py | 18 ++- .../test_block_access_list_expectation.py | 54 ++++++++- .../tests/test_block_access_list_modifiers.py | 98 ++++++++++++++++- .../test_block_access_list_serialization.py | 21 ++++ .../test_block_access_lists_invalid.py | 104 ++++++++++++++++++ .../test_cases.md | 1 + 11 files changed, 480 insertions(+), 13 deletions(-) diff --git a/packages/testing/src/execution_testing/client_clis/clis/besu.py b/packages/testing/src/execution_testing/client_clis/clis/besu.py index f4d5114579a..391a24349c8 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/besu.py +++ b/packages/testing/src/execution_testing/client_clis/clis/besu.py @@ -476,7 +476,8 @@ class BesuExceptionMapper(ExceptionMapper): BlockException.INVALID_BLOCK_ACCESS_LIST: ( r"Block access list hash mismatch, " r"calculated:\s*(0x[a-f0-9]+)\s+header:\s*(0x[a-f0-9]+)|" - r"Block access list validation failed for block 0x[a-f0-9]+" + r"Block access list validation failed for block 0x[a-f0-9]+|" + r"Failed to decode block access list payload parameter" ), BlockException.INCORRECT_BLOCK_FORMAT: ( r"Block access list hash mismatch, " diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index d8982912dd3..2ac7042dc21 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -388,6 +388,11 @@ def engine_payload_only_overrides(self) -> List[str]: overrides.append("engine_new_payload_block_access_list") if self.engine_new_payload_slot_number is not None: overrides.append("engine_new_payload_slot_number") + if ( + self.expected_block_access_list is not None + and self.expected_block_access_list.has_rlp_modifier + ): + overrides.append("expected_block_access_list.modify_rlp") return overrides def set_environment(self, env: Environment) -> Environment: @@ -1057,6 +1062,7 @@ def generate_block_data( # Process block access list - apply transformer if present for invalid # tests bal = t8n_bal + bal_rlp_override: Bytes | None = None # Always validate BAL structural integrity (ordering, duplicates) # if present @@ -1073,10 +1079,13 @@ def generate_block_data( bal = block.expected_block_access_list.modify_if_invalid_test( t8n_bal ) - if bal != t8n_bal: - # If the BAL was modified and the fork requires it, update the - # header hash + if bal.rlp != t8n_bal.rlp: + # Compare bytes, not contents: an encoding override also + # moves the header commitment. header.block_access_list_hash = Hash(bal.rlp.keccak256()) + bal_rlp_override = block.expected_block_access_list.modified_rlp( + bal + ) built_block_kwargs: Dict[str, Any] = dict( header=header, @@ -1095,6 +1104,8 @@ def generate_block_data( block_access_list=bal, engine_new_payload_block_access_list=( block.engine_new_payload_block_access_list + if block.engine_new_payload_block_access_list is not None + else bal_rlp_override ), engine_new_payload_slot_number=( block.engine_new_payload_slot_number @@ -1122,7 +1133,7 @@ def generate_block_data( and block.engine_new_payload_slot_number is None and not ( block.expected_block_access_list is not None - and block.expected_block_access_list._modifier is not None + and block.expected_block_access_list.has_modifier ) ): # Only verify block level exception if: - No transaction @@ -1133,8 +1144,9 @@ def generate_block_data( # what normally produces the block exception. - No engine # payload BAL override was specified, because it corrupts only # the engine payload after the transition tool has run. - No - # BAL modifier was specified, because modified BAL also - # produces block exceptions. + # BAL modifier was specified, because a rewritten BAL, whether + # in contents or in encoding, is applied after the transition + # tool has run and is what produces the block exception. built_block.verify_block_exception( transition_tool_exceptions_reliable=t8n.exception_mapper.reliable, ) diff --git a/packages/testing/src/execution_testing/specs/tests/test_types.py b/packages/testing/src/execution_testing/specs/tests/test_types.py index 186f2428c5f..381c6e23b23 100644 --- a/packages/testing/src/execution_testing/specs/tests/test_types.py +++ b/packages/testing/src/execution_testing/specs/tests/test_types.py @@ -19,7 +19,10 @@ ) from execution_testing.forks import Amsterdam from execution_testing.test_types import Alloc, Environment -from execution_testing.test_types.block_access_list import BlockAccessList +from execution_testing.test_types.block_access_list import ( + BlockAccessList, + BlockAccessListExpectation, +) from ..blockchain import Block, BlockchainTest, BuiltBlock, Header @@ -280,6 +283,26 @@ class TestEnginePayloadOnlyOverrides: ["engine_new_payload_slot_number"], id="payload_slot_number", ), + pytest.param( + Block( + expected_block_access_list=( + BlockAccessListExpectation().modify_rlp( + lambda bal: bal.rlp + ) + ), + ), + ["expected_block_access_list.modify_rlp"], + id="payload_bal_encoding", + ), + pytest.param( + Block( + expected_block_access_list=( + BlockAccessListExpectation().modify(lambda bal: bal) + ), + ), + [], + id="bal_contents_header_follows", + ), ], ) def test_overrides_are_named( diff --git a/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py b/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py index 531e2453756..194ad452fb9 100644 --- a/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py +++ b/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py @@ -9,7 +9,12 @@ from pydantic import Field, PrivateAttr -from execution_testing.base_types import Address, CamelModel, StorageKey +from execution_testing.base_types import ( + Address, + Bytes, + CamelModel, + StorageKey, +) from .account_absent_values import BalAccountAbsentValues from .account_changes import ( @@ -128,6 +133,9 @@ class BlockAccessListExpectation(CamelModel): _modifier: Callable[["BlockAccessList"], "BlockAccessList"] | None = ( PrivateAttr(default=None) ) + _rlp_modifier: Callable[["BlockAccessList"], Bytes] | None = PrivateAttr( + default=None + ) def modify( self, *modifiers: Callable[["BlockAccessList"], "BlockAccessList"] @@ -173,6 +181,39 @@ def modify_if_invalid_test( return self._modifier(t8n_bal) return t8n_bal + def modify_rlp( + self, modifier: Callable[["BlockAccessList"], Bytes] + ) -> "BlockAccessListExpectation": + """ + Create a new expectation that re-encodes the BAL carried by the + engine payload, leaving the header commitment canonical. + + Use this for encoding-level invalid cases; `modify` changes the + BAL's contents and so also moves the header hash. + + Only the engine payload can carry the re-encoding, so the test must + be marked `blockchain_test_engine_only`. + """ + new_instance = self.model_copy(deep=True) + new_instance._rlp_modifier = modifier + return new_instance + + def modified_rlp(self, bal: "BlockAccessList") -> Bytes | None: + """Return the re-encoded payload BAL, or None if unmodified.""" + if self._rlp_modifier: + return self._rlp_modifier(bal) + return None + + @property + def has_modifier(self) -> bool: + """Return whether this expectation rewrites the BAL.""" + return self._modifier is not None or self._rlp_modifier is not None + + @property + def has_rlp_modifier(self) -> bool: + """Return whether this expectation re-encodes the payload BAL.""" + return self._rlp_modifier is not None + def verify_against(self, actual_bal: "BlockAccessList") -> None: """ Verify that the actual BAL from the client matches this expected BAL. diff --git a/packages/testing/src/execution_testing/test_types/block_access_list/modifiers.py b/packages/testing/src/execution_testing/test_types/block_access_list/modifiers.py index 35a03d7f40b..a67db0f0346 100644 --- a/packages/testing/src/execution_testing/test_types/block_access_list/modifiers.py +++ b/packages/testing/src/execution_testing/test_types/block_access_list/modifiers.py @@ -6,10 +6,13 @@ and can be combined to create complex modifications. """ -from typing import Any, Callable, List, Optional +from typing import Any, Callable, List, Literal, Optional + +import ethereum_rlp as eth_rlp from execution_testing.base_types import ( Address, + Bytes, ZeroPaddedHexNumber, ) @@ -19,9 +22,37 @@ BalBalanceChange, BalNonceChange, BalStorageChange, + BalStorageSlot, BlockAccessList, ) +BalScalarField = Literal[ + "storage_slot", + "storage_value", + "storage_read", + "balance", + "block_access_index", + "nonce", +] +""" +EIP-7928 integer fields, each RLP-encoded as a minimal scalar. + +``block_access_index`` is read from the account's first balance change. +""" + +_STORAGE_CHANGES_INDEX = BalAccountChange.rlp_fields.index("storage_changes") +_STORAGE_READS_INDEX = BalAccountChange.rlp_fields.index("storage_reads") +_BALANCE_CHANGES_INDEX = BalAccountChange.rlp_fields.index("balance_changes") +_NONCE_CHANGES_INDEX = BalAccountChange.rlp_fields.index("nonce_changes") +_SLOT_INDEX = BalStorageSlot.rlp_fields.index("slot") +_SLOT_CHANGES_INDEX = BalStorageSlot.rlp_fields.index("slot_changes") +_POST_VALUE_INDEX = BalStorageChange.rlp_fields.index("post_value") +_POST_BALANCE_INDEX = BalBalanceChange.rlp_fields.index("post_balance") +_BLOCK_ACCESS_INDEX_INDEX = BalBalanceChange.rlp_fields.index( + "block_access_index" +) +_POST_NONCE_INDEX = BalNonceChange.rlp_fields.index("post_nonce") + def _remove_field_from_accounts( addresses: tuple[Address, ...], field_name: str @@ -870,6 +901,71 @@ def transform(bal: BlockAccessList) -> BlockAccessList: return transform +def _scalar_leaf( + element: List[Any], field: BalScalarField +) -> tuple[List[Any], int]: + """Return the container and index of the scalar named by ``field``.""" + if field == "storage_slot": + return element[_STORAGE_CHANGES_INDEX][0], _SLOT_INDEX + elif field == "storage_value": + slot = element[_STORAGE_CHANGES_INDEX][0] + return slot[_SLOT_CHANGES_INDEX][0], _POST_VALUE_INDEX + elif field == "storage_read": + return element[_STORAGE_READS_INDEX], 0 + elif field == "balance": + return element[_BALANCE_CHANGES_INDEX][0], _POST_BALANCE_INDEX + elif field == "block_access_index": + return element[_BALANCE_CHANGES_INDEX][0], _BLOCK_ACCESS_INDEX_INDEX + elif field == "nonce": + return element[_NONCE_CHANGES_INDEX][0], _POST_NONCE_INDEX + else: + raise ValueError(f"Unknown BAL scalar field: {field}") + + +def encode_scalar_non_minimally( + address: Address, field: BalScalarField +) -> Callable[[BlockAccessList], Bytes]: + """ + Re-encode the BAL with the account's first ``field`` scalar carrying a + leading zero byte, leaving every other field canonically encoded. + + ``eth_rlp.encode`` emits an integer minimally but a ``bytes`` verbatim, + so substituting the leaf recomputes every enclosing length prefix. + """ + + def transform(bal: BlockAccessList) -> Bytes: + elements = bal.to_list() + for account_change, element in zip(bal.root, elements, strict=True): + if account_change.address != address: + continue + try: + container, index = _scalar_leaf(element, field) + scalar = container[index] + except IndexError: + raise ValueError( + f"No {field} entry for {address} in the BAL" + ) from None + container[index] = b"\x00" + scalar.to_be_bytes() + return Bytes(eth_rlp.encode(elements)) + raise ValueError(f"Address {address} was not found in the BAL") + + return transform + + +def override_rlp( + encoder: Callable[[BlockAccessList], Bytes], +) -> Callable[[BlockAccessList], BlockAccessList]: + """ + Lift an encoding modifier into a content modifier, so the header commits + to the re-encoded bytes instead of the canonical encoding. + """ + + def transform(bal: BlockAccessList) -> BlockAccessList: + return bal.with_rlp_override(encoder(bal)) + + return transform + + __all__ = [ # Account-level modifiers "remove_accounts", @@ -902,4 +998,8 @@ def transform(bal: BlockAccessList) -> BlockAccessList: "duplicate_storage_read", "duplicate_slot_change", "insert_storage_read", + # Encoding modifiers + "BalScalarField", + "encode_scalar_non_minimally", + "override_rlp", ] diff --git a/packages/testing/src/execution_testing/test_types/block_access_list/t8n.py b/packages/testing/src/execution_testing/test_types/block_access_list/t8n.py index 20b3b1010c3..7008d25512f 100644 --- a/packages/testing/src/execution_testing/test_types/block_access_list/t8n.py +++ b/packages/testing/src/execution_testing/test_types/block_access_list/t8n.py @@ -5,7 +5,7 @@ import ethereum_rlp as eth_rlp from ethereum_rlp import Simple -from pydantic import Field +from pydantic import Field, PrivateAttr from execution_testing.base_types import ( Address, @@ -99,6 +99,8 @@ class BlockAccessList(EthereumTestRootModel[List[BalAccountChange]]): root: List[BalAccountChange] = Field(default_factory=list) + _rlp_override: Bytes | None = PrivateAttr(default=None) + @classmethod def from_rlp(cls, data: Bytes) -> "BlockAccessList": """ @@ -158,9 +160,23 @@ def to_list(self) -> List[Any]: """Return the list for RLP encoding per EIP-7928.""" return to_serializable_element(self.root) + def with_rlp_override(self, rlp: Bytes) -> "BlockAccessList": + """ + Return a BAL with the same contents whose serialization is ``rlp``, + mirroring ``RLPSerializable.rlp_override``. + + A fresh instance is built rather than a copy so that no cached + canonical encoding is carried over. + """ + new_instance = BlockAccessList(root=self.root) + new_instance._rlp_override = rlp + return new_instance + @cached_property def rlp(self) -> Bytes: """Return the RLP encoded block access list for hash verification.""" + if self._rlp_override is not None: + return self._rlp_override return Bytes(eth_rlp.encode(self.to_list())) @cached_property diff --git a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py index 313bc1eb9ea..2556892a048 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py @@ -4,7 +4,7 @@ import pytest -from execution_testing.base_types import Address, StorageKey +from execution_testing.base_types import Address, Bytes, StorageKey from execution_testing.test_types.block_access_list import ( BalAccountAbsentValues, BalAccountChange, @@ -1174,6 +1174,58 @@ def test_validate_any_change_fails_with_empty_actual() -> None: expectation.verify_against(actual_bal) +def test_modify_rlp_rewrites_encoding_only() -> None: + """`modify_rlp` re-encodes the BAL without touching its contents.""" + alice = Address(0xA) + actual_bal = BlockAccessList( + [ + BalAccountChange( + address=alice, + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + ] + ) + expectation = BlockAccessListExpectation() + rlp_expectation = expectation.modify_rlp(lambda _: Bytes(b"\xc0")) + + assert not expectation.has_modifier + assert not expectation.has_rlp_modifier + assert expectation.modified_rlp(actual_bal) is None + assert rlp_expectation.has_modifier + assert rlp_expectation.has_rlp_modifier + assert rlp_expectation.modified_rlp(actual_bal) == b"\xc0" + assert rlp_expectation.modify_if_invalid_test(actual_bal) == actual_bal + + +def test_modify_rlp_chains_with_modify() -> None: + """A content modifier survives a later `modify_rlp`.""" + alice = Address(0xA) + actual_bal = BlockAccessList( + [ + BalAccountChange( + address=alice, + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + ] + ) + content_only = BlockAccessListExpectation().modify( + lambda _: BlockAccessList([]) + ) + both = content_only.modify_rlp(lambda bal: bal.rlp) + + assert content_only.has_modifier + assert not content_only.has_rlp_modifier + assert content_only.modified_rlp(actual_bal) is None + assert both.has_modifier + assert both.has_rlp_modifier + assert both.modify_if_invalid_test(actual_bal) == BlockAccessList([]) + assert both.modified_rlp(actual_bal) == actual_bal.rlp + + def test_validate_any_change_mutual_exclusion_with_slot_changes() -> None: """ validate_any_change=True and non-empty slot_changes raises ValueError. diff --git a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_modifiers.py b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_modifiers.py index 4ac837be51d..796be754fa4 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_modifiers.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_modifiers.py @@ -1,7 +1,8 @@ """Unit tests for BAL modifier functions.""" -from typing import Callable +from typing import Any, Callable +import ethereum_rlp as eth_rlp import pytest from execution_testing.base_types import Address @@ -15,6 +16,7 @@ BlockAccessList, ) from execution_testing.test_types.block_access_list.modifiers import ( + BalScalarField, append_change, append_storage, duplicate_account, @@ -24,11 +26,13 @@ duplicate_slot_change, duplicate_storage_read, duplicate_storage_slot, + encode_scalar_non_minimally, insert_storage_read, modify_balance, modify_code, modify_nonce, modify_storage, + override_rlp, remove_nonces, reorder_accounts, swap_bal_indices, @@ -368,3 +372,95 @@ def test_reused_callable_does_not_carry_found_state( modifier(sample_bal) with pytest.raises(ValueError, match="not found"): modifier(missing_bal) + + +@pytest.mark.parametrize( + "field, address, leaf_path, canonical_leaf", + [ + pytest.param( + "storage_slot", CONTRACT, (1, 1, 0, 0), b"\x01", id="storage_slot" + ), + pytest.param( + "storage_value", + CONTRACT, + (1, 1, 0, 1, 0, 1), + b"\x42", + id="storage_value", + ), + pytest.param( + "storage_read", CONTRACT, (1, 2, 0), b"\x02", id="storage_read" + ), + pytest.param("balance", ALICE, (0, 3, 0, 1), b"\x64", id="balance"), + pytest.param( + "block_access_index", + ALICE, + (0, 3, 0, 0), + b"\x01", + id="block_access_index", + ), + pytest.param("nonce", ALICE, (0, 4, 0, 1), b"\x01", id="nonce"), + ], +) +def test_encode_scalar_non_minimally( + sample_bal: BlockAccessList, + field: BalScalarField, + address: Address, + leaf_path: tuple[int, ...], + canonical_leaf: bytes, +) -> None: + """Only the targeted leaf changes, and only in its encoding.""" + encoded = encode_scalar_non_minimally(address, field)(sample_bal) + + assert encoded != sample_bal.rlp + assert BlockAccessList.from_rlp(encoded).rlp == sample_bal.rlp + + leaf: Any = eth_rlp.decode(encoded) + for index in leaf_path: + leaf = leaf[index] + assert leaf == b"\x00" + canonical_leaf + + +@pytest.mark.parametrize( + "field", + [ + "storage_slot", + "storage_value", + "storage_read", + "balance", + "block_access_index", + "nonce", + ], +) +def test_encode_scalar_non_minimally_missing_entry_raises( + field: BalScalarField, +) -> None: + """Raise when the account has no entry for the targeted field.""" + with pytest.raises(ValueError, match=f"No {field} entry"): + encode_scalar_non_minimally(ALICE, field)(_ALICE_ONLY_BAL) + + +def test_encode_scalar_non_minimally_missing_address_raises() -> None: + """Raise when the address is absent.""" + with pytest.raises(ValueError, match="not found"): + encode_scalar_non_minimally(ALICE, "balance")(_EMPTY_BAL) + + +def test_encode_scalar_non_minimally_unknown_field_raises( + sample_bal: BlockAccessList, +) -> None: + """Raise when the field is not a BAL scalar.""" + unknown_field: Any = "code" + with pytest.raises(ValueError, match="Unknown BAL scalar field"): + encode_scalar_non_minimally(ALICE, unknown_field)(sample_bal) + + +def test_override_rlp_commits_encoder_output( + sample_bal: BlockAccessList, +) -> None: + """`override_rlp` makes the encoder's bytes the BAL's serialization.""" + encoder = encode_scalar_non_minimally(ALICE, "balance") + overridden = override_rlp(encoder)(sample_bal) + + assert overridden.rlp == encoder(sample_bal) + assert overridden.rlp != sample_bal.rlp + assert overridden.root == sample_bal.root diff --git a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_serialization.py b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_serialization.py index d454a097ce1..d21ca34ad54 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_serialization.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_serialization.py @@ -84,3 +84,24 @@ def test_bal_serialization_roundtrip_zero_padded_hex() -> None: # Round-trip: deserialize and verify equality restored = BlockAccessList.model_validate(json_data) assert restored == original + + +def test_bal_rlp_override_replaces_serialization_only() -> None: + """`with_rlp_override` swaps the bytes but keeps the contents.""" + original = BlockAccessList( + [ + BalAccountChange( + address=Address(0xA), + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ) + ] + ) + canonical = original.rlp + overridden = original.with_rlp_override(Bytes(b"\xc0")) + + assert overridden.rlp == b"\xc0" + assert overridden.rlp_hash == Bytes(b"\xc0").keccak256() + assert overridden.to_list() == original.to_list() + assert original.rlp == canonical diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py index 5e317c462b6..a7ec00f87c0 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py @@ -9,6 +9,7 @@ import pytest from execution_testing import ( Account, + Address, Alloc, BalAccountChange, BalAccountExpectation, @@ -36,6 +37,7 @@ compute_create_address, ) from execution_testing.test_types.block_access_list.modifiers import ( + BalScalarField, append_account, append_change, append_empty_slot, @@ -47,11 +49,13 @@ duplicate_slot_change, duplicate_storage_read, duplicate_storage_slot, + encode_scalar_non_minimally, insert_storage_read, modify_balance, modify_code, modify_nonce, modify_storage, + override_rlp, remove_accounts, remove_balances, remove_code, @@ -1734,6 +1738,106 @@ def test_bal_invalid_engine_payload_encoding( ) +@pytest.mark.valid_from("Amsterdam") +@pytest.mark.blockchain_test_engine_only +@pytest.mark.exception_test +@pytest.mark.parametrize( + "field", + [ + "storage_slot", + "storage_value", + "storage_read", + "balance", + "block_access_index", + "nonce", + ], +) +@pytest.mark.parametrize("header_commits_to", ["canonical_rlp", "payload_rlp"]) +def test_bal_invalid_non_minimal_scalar_encoding( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + field: BalScalarField, + header_commits_to: str, +) -> None: + """ + Reject a `newPayload` whose BAL encodes one of its integer scalars with + a leading zero byte. + + The field is present but not a valid encoding, so the payload is + invalid rather than the request being malformed. + + With the header committing to the canonical RLP, a client that + decodes leniently and hashes a re-encoding of the decoded BAL computes + a matching hash and accepts. With the header committing to the + payload RLP, a client that decodes leniently and hashes the bytes as + received accepts instead. + """ + alice = pre.fund_eoa() + oracle = pre.deploy_contract(code=Op.SSTORE(1, 1) + Op.SLOAD(2)) + + tx = Transaction(sender=alice, to=oracle, value=10**15) + + target: Address + if field == "nonce": + target = alice + elif field in ( + "storage_slot", + "storage_value", + "storage_read", + "balance", + "block_access_index", + ): + target = oracle + else: + raise ValueError(f"Unhandled field: {field}") + + encoder = encode_scalar_non_minimally(target, field) + expectation = BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + oracle: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=1, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=1 + ) + ], + ) + ], + storage_reads=[2], + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=10**15) + ], + ), + } + ) + if header_commits_to == "canonical_rlp": + expectation = expectation.modify_rlp(encoder) + elif header_commits_to == "payload_rlp": + expectation = expectation.modify(override_rlp(encoder)) + else: + raise ValueError(f"Unhandled header commitment: {header_commits_to}") + + blockchain_test( + pre=pre, + # The block is rejected and the post state remains unchanged. + post=pre, + blocks=[ + Block( + txs=[tx], + exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + expected_block_access_list=expectation, + ) + ], + ) + + @pytest.mark.valid_from("Amsterdam") @pytest.mark.exception_test def test_bal_invalid_noop_storage_change( diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 22cfe27b2a3..108aee435a6 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -179,6 +179,7 @@ | `test_bal_invalid_missing_created_code` | Verify clients reject a BAL that omits the deployed code of a contract created via a contract-creation transaction | Alice sends a top-level `CREATE` transaction (`to=None`) whose init code deploys a small runtime. BAL modifier removes the created contract's `code_changes` entirely. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** detect a newly created contract whose deployed code has no corresponding BAL entry. | ✅ Completed | | `test_bal_invalid_omitted_slot_change_at_index` | Verify clients reject a BAL that drops a slot's earlier change while keeping its later one, misattributing the slot's first recorded change to a later transaction | Two transactions each write storage slot 0 of the same contract via the transaction's call value (slot 0: 0→1 at tx1, 1→2 at tx2). BAL modifier removes only tx1's `slot_changes` entry for slot 0 (new `remove_slot_change` modifier), leaving tx2's entry as the slot's only recorded change. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate that a slot's first BAL-recorded change matches the transaction that actually performed it, not merely that some change with the correct final value exists. | ✅ Completed | | `test_bal_invalid_phantom_read_on_selfdestruct` | Verify clients reject a BAL with a phantom storage read for an account created and destroyed within the same transaction | A contract-creation transaction's init code immediately `SELFDESTRUCT`s, sending its endowment to beneficiary, without ever returning runtime code. Per EIP-6780 the created account has zero net BAL changes (`BalAccountExpectation.empty()`) but still legitimately appears in the BAL as an entry with empty changes. BAL modifier injects a phantom `storage_reads` entry for a slot the account never touched. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** reject a storage read recorded against an account that performed no storage access at all, even when the account otherwise legitimately appears in the BAL. | ✅ Completed | +| `test_bal_invalid_non_minimal_scalar_encoding` | Verify clients reject a BAL whose RLP encodes an integer scalar non-minimally | Alice calls a contract that does `SSTORE(1, 1)` and `SLOAD(2)`, sending value so the account also has a balance change. The BAL contents are left untouched; the BAL delivered in the engine payload is re-encoded with one scalar carrying a leading zero byte. Parametrized over each integer field: `storage_slot`, `storage_value`, `storage_read`, `balance`, `block_access_index` (from the contract's balance change), `nonce` (from Alice's nonce change); and over what the header commits to: the canonical RLP (`modify_rlp`, payload only) or the payload RLP (`override_rlp`, so the block hash also follows). | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. RLP integers **MUST** be minimally encoded. With the header committing to the canonical RLP, catches clients that decode these fields as raw byte strings and hash a re-encoding of the decoded BAL. With the header committing to the payload RLP, catches clients that hash the bytes as received without a minimal-encoding check. The uint32 index and uint64 nonce take different decoder paths from the uint256 fields, so each width is covered. | ✅ Completed | | `test_bal_2935_simple` | Ensure BAL captures EIP-2935 history storage writes during pre-execution system call alongside normal transactions | Block with 2 normal user transactions: Alice sends 10 wei to Charlie, Bob sends 10 wei to Charlie. At block start (pre-execution), `SYSTEM_ADDRESS` calls `HISTORY_STORAGE_ADDRESS` to store parent block hash. | BAL **MUST** include `HISTORY_STORAGE_ADDRESS` with `storage_changes` (ring buffer slot 0, empty `slot_changes` since parent hash is framework-computed); `SYSTEM_ADDRESS` **MUST NOT** be included in BAL. At `block_access_index=1`: Alice with `nonce_changes`, Charlie with `balance_changes` (10 wei). At `block_access_index=2`: Bob with `nonce_changes`, Charlie with `balance_changes` (20 wei total). | ✅ Completed | | `test_bal_2935_empty_block` | Ensure BAL captures EIP-2935 history storage writes in empty block | Block with no transactions. At block start (pre-execution), `SYSTEM_ADDRESS` calls `HISTORY_STORAGE_ADDRESS` to store parent block hash. | BAL **MUST** include `HISTORY_STORAGE_ADDRESS` with `storage_changes` (ring buffer slot 0, empty `slot_changes`); `SYSTEM_ADDRESS` **MUST NOT** be included in BAL. No transaction-related BAL entries. | ✅ Completed | | `test_bal_2935_query` | Ensure BAL captures storage reads when querying EIP-2935 historical block hashes (valid and invalid queries) with optional value transfer | Parameterized test: Block 1 (empty, stores genesis hash via system call). Block 2: Oracle contract queries `HISTORY_STORAGE_ADDRESS` with block number. Two block number scenarios (valid=0 genesis hash, invalid=1042 out of range) and value (0 or 100 wei). Valid query (block_number=0): reads genesis hash slot, oracle writes returned value. If value > 0, history storage contract receives balance. Invalid query (block_number=1042, out of range): reverts before storage access, oracle has implicit SLOAD recorded, value stays in oracle (not transferred to history storage). | Block 2 BAL **MUST** include: Valid case at `block_access_index=1`: `HISTORY_STORAGE_ADDRESS` with `storage_reads` [slot 0] and `balance_changes` if value > 0, oracle with `storage_changes` (empty `slot_changes`). Invalid case at `block_access_index=1`: `HISTORY_STORAGE_ADDRESS` with NO `storage_reads` (reverts before access) and NO `balance_changes`, oracle with `storage_reads` [0], NO `storage_changes`, and `balance_changes` if value > 0 (value stays in oracle). Alice with `nonce_changes` at `block_access_index=1`. | ✅ Completed | From fc701a463b2b15f9e2791af291c47ebe0efa37f2 Mon Sep 17 00:00:00 2001 From: felipe Date: Wed, 2 Sep 2026 16:01:32 -0600 Subject: [PATCH 50/59] chore(test-specs): avoid silent mistakes when writing tests (#3510) * fix(test-specs): refuse BAL modifiers on blocks that declare no failure * fix(test-specs): refuse an explicit payload BAL alongside modify_rlp * fix(test-specs): refuse undeliverable BAL re-encodings after the block is built * fix(test-specs): refuse BAL modifiers that leave the list unchanged * test(test-specs): pin what a filled engine payload's block hash commits to * fix(test-specs): refuse a second modify or modify_rlp on the same expectation * fix(test-specs): name the misuse when a BAL modifier returns the wrong kind * fix(test-specs): name blocks consistently in the BAL guards and accept hex overrides * fix(test-specs): refuse modify_rlp after override_rlp on the same block * test(test-specs): tighten the BAL guard tests after review * chore: fixes from comments on PR #3510 --- .../filler/tests/test_bal_modifier_guards.py | 351 ++++++++++++++++++ .../src/execution_testing/specs/blockchain.py | 71 +++- .../specs/tests/test_types.py | 139 +++++++ .../block_access_list/expectations.py | 38 +- .../test_types/block_access_list/t8n.py | 14 +- .../test_block_access_list_expectation.py | 58 +++ .../test_block_access_list_serialization.py | 27 ++ 7 files changed, 687 insertions(+), 11 deletions(-) create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_bal_modifier_guards.py diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_bal_modifier_guards.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_bal_modifier_guards.py new file mode 100644 index 00000000000..85c06b62b6f --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_bal_modifier_guards.py @@ -0,0 +1,351 @@ +""" +Test the fill-time checks on block access list modifiers that can only run +once the transition tool has produced the list. + +`override_rlp` commits the header to re-encoded bytes that only the engine +payload can carry. `fill` therefore refuses the RLP blockchain fixture +format for such a block, and refuses an explicit payload override that would +replace the committed bytes. The same block fills as an engine fixture. + +A modifier that leaves both the list and its payload encoding unchanged is +refused as well, since it would label a valid block invalid. + +The commitment tests pin what the block hash of a filled engine payload +commits to. +""" + +import json +import textwrap +from pathlib import Path +from typing import Any + +import pytest + +from execution_testing.base_types import Bytes, EmptyTrieRoot +from execution_testing.fixtures.blockchain import FixtureHeader +from execution_testing.test_types.block_access_list import BlockAccessList + +# BALs exist from Amsterdam; the fill needs a fork that emits one. +FORK = "Amsterdam" + +TEST_MODULE_DIR = "tests/amsterdam/dummy_test_module" + +ENGINE_ONLY_MARKER = "@pytest.mark.blockchain_test_engine_only" + +MODULE_TEMPLATE = textwrap.dedent( + """\ + import pytest + + from execution_testing import ( + Address, + Alloc, + Block, + BlockAccessList, + BlockAccessListExpectation, + BlockchainTestFiller, + BlockException, + Bytes, + Transaction, + ) + from execution_testing.test_types.block_access_list.modifiers import ( + encode_scalar_non_minimally, + override_rlp, + ) + + EMPTY_LIST = Bytes(b"\\xc0") + # EIP-2935: written by the pre-execution system call of every block, so + # even an empty block's list has a storage value to re-encode. + HISTORY_STORAGE_ADDRESS = Address( + 0x0000F90827F1C53A10CB7A02335B175320002935 + ) + RE_ENCODE = encode_scalar_non_minimally( + HISTORY_STORAGE_ADDRESS, "storage_value" + ) + + {markers} + @pytest.mark.valid_at("{fork}") + @pytest.mark.exception_test + def test_case(blockchain_test: BlockchainTestFiller, pre: Alloc) -> None: + tx = Transaction(to=pre.fund_eoa(amount=0), sender=pre.fund_eoa()) + blockchain_test( + pre=pre, + post={{}}, + blocks=[ + Block( + txs={txs}, + exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + expected_block_access_list=( + BlockAccessListExpectation(){modifier} + ), + {block_kwargs} + ) + ], + ) + """ +) + +OVERRIDE_RLP_WITH_EMPTY_LIST = ".modify(override_rlp(lambda _: EMPTY_LIST))" + + +def write_test_module( + pytester: pytest.Pytester, + *, + modifier: str, + markers: str = ENGINE_ONLY_MARKER, + block_kwargs: str = "", + txs: str = "[tx]", +) -> str: + """ + Write a single-test module with the given BAL modifier chain and return + its path relative to the pytester directory. + """ + module_dir = pytester.path / TEST_MODULE_DIR + module_dir.mkdir(parents=True) + module = module_dir / "test_dummy.py" + module.write_text( + MODULE_TEMPLATE.format( + markers=markers, + fork=FORK, + modifier=modifier, + block_kwargs=block_kwargs, + txs=txs, + ) + ) + pytester.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + return str(module.relative_to(pytester.path)) + + +def run_fill( + pytester: pytest.Pytester, module_path: str, fixture_format: str +) -> pytest.RunResult: + """Fill the given module, generating only ``fixture_format``.""" + return pytester.runpytest( + "-c", + "pytest-fill.ini", + "--fork", + FORK, + "-m", + fixture_format, + "--no-html", + "--output", + "fixtures", + module_path, + ) + + +def output_of(result: pytest.RunResult) -> str: + """Return the combined output of a fill run.""" + return "\n".join(result.outlines + result.errlines) + + +def only_fixture(fixtures_dir: Path) -> dict[str, Any]: + """Return the single fixture a fill of the dummy module produced.""" + files = [p for p in fixtures_dir.rglob("*.json") if ".meta" not in p.parts] + assert len(files) == 1, files + fixtures = json.loads(files[0].read_text()) + assert len(fixtures) == 1, list(fixtures) + return next(iter(fixtures.values())) + + +def rebuilt_block_hash(fixture: dict[str, Any], bal_hash: Bytes) -> str: + """ + Recompute the block hash of the fixture's only payload with the header's + BAL hash replaced by ``bal_hash``. + + The block is empty, so its transaction and withdrawal roots are the empty + trie root and its requests hash is the genesis one. + """ + payload = fixture["engineNewPayloads"][0] + execution_payload = payload["params"][0] + genesis = FixtureHeader.model_validate(fixture["genesisBlockHeader"]) + header = genesis.copy( + parent_hash=execution_payload["parentHash"], + fee_recipient=execution_payload["feeRecipient"], + state_root=execution_payload["stateRoot"], + transactions_trie=EmptyTrieRoot, + receipts_root=execution_payload["receiptsRoot"], + logs_bloom=execution_payload["logsBloom"], + number=execution_payload["blockNumber"], + gas_limit=execution_payload["gasLimit"], + gas_used=execution_payload["gasUsed"], + timestamp=execution_payload["timestamp"], + extra_data=execution_payload["extraData"], + prev_randao=execution_payload["prevRandao"], + base_fee_per_gas=execution_payload["baseFeePerGas"], + withdrawals_root=EmptyTrieRoot, + blob_gas_used=execution_payload["blobGasUsed"], + excess_blob_gas=execution_payload["excessBlobGas"], + parent_beacon_block_root=payload["params"][2], + slot_number=execution_payload["slotNumber"], + block_access_list_hash=bal_hash, + ) + return str(header.block_hash) + + +def test_engine_format_fills_override_rlp(pytester: pytest.Pytester) -> None: + """Positive control: the engine payload can carry the re-encoding.""" + module_path = write_test_module( + pytester, modifier=OVERRIDE_RLP_WITH_EMPTY_LIST + ) + + result = run_fill(pytester, module_path, "blockchain_test_engine") + + result.assert_outcomes(passed=1, failed=0) + + +def test_rlp_format_refuses_override_rlp(pytester: pytest.Pytester) -> None: + """Block RLP never carries the list, so the fixture cannot deliver it.""" + module_path = write_test_module( + pytester, modifier=OVERRIDE_RLP_WITH_EMPTY_LIST, markers="" + ) + + result = run_fill(pytester, module_path, "blockchain_test") + + result.assert_outcomes(passed=0, failed=1) + output = output_of(result) + assert "cannot deliver the re-encoded bytes" in output, output + assert "Mark the test `blockchain_test_engine_only`" in output, output + + +@pytest.mark.parametrize( + "modifier,block_kwargs", + [ + pytest.param( + OVERRIDE_RLP_WITH_EMPTY_LIST, + 'engine_new_payload_block_access_list=Bytes(b"\\x80"),', + id="explicit_payload_bal", + ), + pytest.param( + OVERRIDE_RLP_WITH_EMPTY_LIST + + ".modify_rlp(lambda _: Bytes(b'\\x80'))", + "", + id="modify_rlp", + ), + ], +) +def test_second_payload_writer_after_override_rlp_is_refused( + pytester: pytest.Pytester, modifier: str, block_kwargs: str +) -> None: + """A second writer of the payload bytes would break the commitment.""" + module_path = write_test_module( + pytester, modifier=modifier, block_kwargs=block_kwargs + ) + + result = run_fill(pytester, module_path, "blockchain_test_engine") + + result.assert_outcomes(passed=0, failed=1) + output = output_of(result) + assert "would not carry what the header commits to" in output, output + assert "Keep one" in output, output + + +@pytest.mark.parametrize( + "modifier", + [ + pytest.param(".modify(lambda bal: bal)", id="identity_contents"), + pytest.param( + ".modify_rlp(lambda bal: bal.rlp)", id="identity_encoding" + ), + pytest.param( + ".modify(override_rlp(lambda bal: bal.rlp))", + id="identity_override", + ), + ], +) +def test_unchanged_bal_is_refused( + pytester: pytest.Pytester, modifier: str +) -> None: + """An unchanged list or encoding is caught once the t8n has run.""" + module_path = write_test_module(pytester, modifier=modifier) + + result = run_fill(pytester, module_path, "blockchain_test_engine") + + result.assert_outcomes(passed=0, failed=1) + output = output_of(result) + assert "left the list unchanged" in output, output + assert "drop it along with the exception" in output, output + + +@pytest.mark.parametrize( + "modifier", + [ + pytest.param( + ".modify(lambda _: BlockAccessList([]))", id="contents_change" + ), + pytest.param( + ".modify_rlp(lambda _: EMPTY_LIST)", id="encoding_change" + ), + ], +) +def test_changed_bal_fills(pytester: pytest.Pytester, modifier: str) -> None: + """A modifier that changes the list or its encoding is accepted.""" + module_path = write_test_module(pytester, modifier=modifier) + + result = run_fill(pytester, module_path, "blockchain_test_engine") + + result.assert_outcomes(passed=1, failed=0) + + +@pytest.mark.parametrize( + "modifier,header_commits_to,payload_encoding", + [ + pytest.param( + ".modify_rlp(RE_ENCODE)", + "canonical_rlp", + "re_encoded", + id="modify_rlp", + ), + pytest.param( + ".modify(override_rlp(RE_ENCODE))", + "payload_rlp", + "re_encoded", + id="override_rlp", + ), + pytest.param( + ".modify(lambda _: BlockAccessList([]))", + "payload_rlp", + "empty_list", + id="contents", + ), + ], +) +def test_header_commitment( + pytester: pytest.Pytester, + modifier: str, + header_commits_to: str, + payload_encoding: str, +) -> None: + """ + The payload RLP is checked against what the block hash commits to, + rebuilt from the fixture itself. + """ + module_path = write_test_module(pytester, modifier=modifier, txs="[]") + + result = run_fill(pytester, module_path, "blockchain_test_engine") + + result.assert_outcomes(passed=1, failed=0) + fixture = only_fixture(pytester.path / "fixtures") + execution_payload = fixture["engineNewPayloads"][0]["params"][0] + payload_rlp = Bytes(execution_payload["blockAccessList"]) + canonical_rlp = BlockAccessList.from_rlp(payload_rlp).rlp + if payload_encoding == "re_encoded": + assert payload_rlp != canonical_rlp, ( + "re-encoding did not reach the payload" + ) + elif payload_encoding == "empty_list": + assert payload_rlp == Bytes(b"\xc0") + else: + raise ValueError(f"Unhandled payload encoding: {payload_encoding}") + if header_commits_to == "canonical_rlp": + committed, other = canonical_rlp, payload_rlp + elif header_commits_to == "payload_rlp": + committed, other = payload_rlp, canonical_rlp + else: + raise ValueError(f"Unhandled commitment: {header_commits_to}") + + block_hash = execution_payload["blockHash"] + assert rebuilt_block_hash(fixture, committed.keccak256()) == block_hash + if other != committed: + assert rebuilt_block_hash(fixture, other.keccak256()) != block_hash diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 2ac7042dc21..f2d26f8ec40 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -830,6 +830,36 @@ def model_post_init(self, __context: Any, /) -> None: "last transaction of the last block, but block " f"{i} contains an invalid transaction elsewhere" ) + for i, block in enumerate(self.blocks): + expectation = block.expected_block_access_list + if ( + expectation is not None + and expectation.has_modifier + and not block.exception + and block.engine_api_error_code is None + ): + raise Exception( + f"test correctness: block {i} modifies its block access " + "list or its encoding but declares no `exception` or " + "`engine_api_error_code`, so the corrupted block access " + "list would be filled as valid. Declare the exception " + "the modified block access list must cause, or drop the " + "modifier." + ) + if ( + block.engine_new_payload_block_access_list is not None + and expectation is not None + and expectation.has_rlp_modifier + ): + raise Exception( + f"test correctness: block {i} sets " + "`engine_new_payload_block_access_list` and re-encodes " + "the block access list with `modify_rlp`; the explicit " + "payload override would discard the re-encoding. Keep " + "one: `engine_new_payload_block_access_list` delivers " + "arbitrary bytes, `modify_rlp` re-encodes the list the " + "transition tool produced." + ) def get_genesis_environment(self) -> Environment: """Get the genesis environment for pre-allocation groups.""" @@ -1086,6 +1116,34 @@ def generate_block_data( bal_rlp_override = block.expected_block_access_list.modified_rlp( bal ) + if ( + block.expected_block_access_list.has_modifier + and bal.rlp == t8n_bal.rlp + and ( + bal_rlp_override is None or bal_rlp_override == t8n_bal.rlp + ) + ): + raise Exception( + f"test correctness: block number {int(env.number)}'s " + "block access list modifier left the list unchanged, so " + "the block would be labelled invalid for no reason. Make " + "the modifier change the list, or drop it along with the " + "exception." + ) + if bal.has_rlp_override and ( + block.engine_new_payload_block_access_list is not None + or bal_rlp_override is not None + ): + raise Exception( + f"test correctness: block number {int(env.number)} " + "re-encodes the block access list with `override_rlp` " + "and also replaces the payload bytes, so the payload " + "would not carry what the header commits to. Keep one: " + "`override_rlp` commits the header to its re-encoding, " + "`modify_rlp` re-encodes the payload only, " + "`engine_new_payload_block_access_list` delivers " + "arbitrary bytes." + ) built_block_kwargs: Dict[str, Any] = dict( header=header, @@ -1213,7 +1271,7 @@ def make_fixture( benchmark_gas_used: int | None = None benchmark_block_gas_used: int | None = None benchmark_opcode_count: OpcodeCount | None = None - for block in self.blocks: + for i, block in enumerate(self.blocks): # This is the most common case, the RLP needs to be constructed # based on the transactions to be included in the block. # Set the environment according to the block to execute. @@ -1224,6 +1282,17 @@ def make_fixture( previous_alloc=alloc, ) block_number = int(built_block.header.number) + if ( + built_block.block_access_list is not None + and built_block.block_access_list.has_rlp_override + ): + raise Exception( + f"test correctness: block {i}'s block access list " + "modifier re-encodes the list, but block RLP does not " + "carry the block access list, so an RLP blockchain " + "fixture cannot deliver the re-encoded bytes. Mark the " + "test `blockchain_test_engine_only`." + ) is_last_block = block is self.blocks[-1] if is_last_block and self.operation_mode == OpMode.BENCHMARKING: benchmark_gas_used = built_block.cumulative_gas_used() diff --git a/packages/testing/src/execution_testing/specs/tests/test_types.py b/packages/testing/src/execution_testing/specs/tests/test_types.py index 381c6e23b23..1816716fae7 100644 --- a/packages/testing/src/execution_testing/specs/tests/test_types.py +++ b/packages/testing/src/execution_testing/specs/tests/test_types.py @@ -13,6 +13,7 @@ ) from execution_testing.client_clis import Result from execution_testing.client_clis.cli_types import LazyAllocStr +from execution_testing.exceptions import BlockException, EngineAPIError from execution_testing.fixtures.blockchain import ( FixtureExecutionPayloadModifier, FixtureHeader, @@ -321,3 +322,141 @@ def test_make_fixture_refuses_payload_only_override(self) -> None: ) with pytest.raises(Exception, match="blockchain_test_engine_only"): test.make_fixture(sentinel.t8n) + + +class TestBalModifierRequiresException: + """A block that rewrites its BAL must declare how the block fails.""" + + @pytest.mark.parametrize( + "block", + [ + pytest.param( + Block( + expected_block_access_list=( + BlockAccessListExpectation().modify(lambda bal: bal) + ), + ), + id="contents", + ), + pytest.param( + Block( + expected_block_access_list=( + BlockAccessListExpectation().modify_rlp( + lambda bal: bal.rlp + ) + ), + ), + id="encoding", + ), + pytest.param( + Block( + expected_block_access_list=( + BlockAccessListExpectation().modify(lambda bal: bal) + ), + exception=[], + ), + id="empty_exception_list", + ), + ], + ) + def test_modifier_without_declared_failure_is_refused( + self, block: Block + ) -> None: + """The check runs at construction, before any t8n call.""" + with pytest.raises(Exception, match="declares no `exception`"): + BlockchainTest( + fork=Amsterdam, pre=Alloc(), post=Alloc(), blocks=[block] + ) + + @pytest.mark.parametrize( + "block", + [ + pytest.param( + Block(expected_block_access_list=BlockAccessListExpectation()), + id="no_modifier", + ), + pytest.param( + Block( + expected_block_access_list=( + BlockAccessListExpectation().modify(lambda bal: bal) + ), + exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + ), + id="exception", + ), + pytest.param( + Block( + expected_block_access_list=( + BlockAccessListExpectation().modify(lambda bal: bal) + ), + engine_api_error_code=EngineAPIError.InvalidParams, + ), + id="engine_api_error", + ), + ], + ) + def test_declared_failure_is_accepted(self, block: Block) -> None: + """Modifiers paired with a declared failure construct normally.""" + BlockchainTest( + fork=Amsterdam, pre=Alloc(), post=Alloc(), blocks=[block] + ) + + +class TestConflictingPayloadOverrides: + """ + An explicit engine payload BAL wins over `modify_rlp`, so setting both + would silently drop the re-encoding. + """ + + def test_explicit_payload_bal_with_modify_rlp_is_refused(self) -> None: + """Both payload-only settings on one block fail at construction.""" + block = Block( + engine_new_payload_block_access_list=Bytes(b"\xc0"), + expected_block_access_list=( + BlockAccessListExpectation().modify_rlp(lambda bal: bal.rlp) + ), + exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + ) + with pytest.raises(Exception, match="discard the re-encoding"): + BlockchainTest( + fork=Amsterdam, pre=Alloc(), post=Alloc(), blocks=[block] + ) + + @pytest.mark.parametrize( + "block", + [ + pytest.param( + Block( + engine_new_payload_block_access_list=Bytes(b"\xc0"), + exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + ), + id="explicit_payload_bal_only", + ), + pytest.param( + Block( + expected_block_access_list=( + BlockAccessListExpectation().modify_rlp( + lambda bal: bal.rlp + ) + ), + exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + ), + id="modify_rlp_only", + ), + pytest.param( + Block( + engine_new_payload_block_access_list=Bytes(b"\xc0"), + expected_block_access_list=( + BlockAccessListExpectation().modify(lambda bal: bal) + ), + exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + ), + id="explicit_payload_bal_with_content_modifier", + ), + ], + ) + def test_single_payload_path_is_accepted(self, block: Block) -> None: + """One payload path at a time, or a content modifier, is fine.""" + BlockchainTest( + fork=Amsterdam, pre=Alloc(), post=Alloc(), blocks=[block] + ) diff --git a/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py b/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py index 194ad452fb9..417f59e9439 100644 --- a/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py +++ b/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py @@ -160,6 +160,12 @@ def modify( ).modify(remove_nonces(alice)) """ + if self._modifier is not None: + raise ValueError( + "This expectation already has a content modifier; a second " + "`modify` call would replace it. Pass every modifier to a " + "single `modify` call instead." + ) new_instance = self.model_copy(deep=True) new_instance._modifier = compose(*modifiers) return new_instance @@ -177,9 +183,16 @@ def modify_if_invalid_test( The potentially transformed BlockAccessList for the fixture """ - if self._modifier: - return self._modifier(t8n_bal) - return t8n_bal + if self._modifier is None: + return t8n_bal + modified = self._modifier(t8n_bal) + if not isinstance(modified, BlockAccessList): + raise TypeError( + "`modify` expects a content modifier returning a " + f"BlockAccessList, got {type(modified).__name__}. Use " + "`modify_rlp` or `override_rlp` for encoders." + ) + return modified def modify_rlp( self, modifier: Callable[["BlockAccessList"], Bytes] @@ -194,15 +207,28 @@ def modify_rlp( Only the engine payload can carry the re-encoding, so the test must be marked `blockchain_test_engine_only`. """ + if self._rlp_modifier is not None: + raise ValueError( + "This expectation already re-encodes the block access list; " + "a second `modify_rlp` call would replace the first. Keep a " + "single `modify_rlp` call." + ) new_instance = self.model_copy(deep=True) new_instance._rlp_modifier = modifier return new_instance def modified_rlp(self, bal: "BlockAccessList") -> Bytes | None: """Return the re-encoded payload BAL, or None if unmodified.""" - if self._rlp_modifier: - return self._rlp_modifier(bal) - return None + if self._rlp_modifier is None: + return None + encoded = self._rlp_modifier(bal) + if not isinstance(encoded, bytes): + raise TypeError( + "`modify_rlp` expects an encoder returning bytes, got " + f"{type(encoded).__name__}. Use `modify` for content " + "modifiers." + ) + return Bytes(encoded) @property def has_modifier(self) -> bool: diff --git a/packages/testing/src/execution_testing/test_types/block_access_list/t8n.py b/packages/testing/src/execution_testing/test_types/block_access_list/t8n.py index 7008d25512f..df807f3f6d4 100644 --- a/packages/testing/src/execution_testing/test_types/block_access_list/t8n.py +++ b/packages/testing/src/execution_testing/test_types/block_access_list/t8n.py @@ -1,11 +1,11 @@ """Block Access List (BAL) for t8n tool communication and fixtures.""" from functools import cached_property -from typing import Any, Callable, List, Sequence, Union +from typing import Any, Callable, List, Self, Sequence, Union import ethereum_rlp as eth_rlp from ethereum_rlp import Simple -from pydantic import Field, PrivateAttr +from pydantic import Field, PrivateAttr, validate_call from execution_testing.base_types import ( Address, @@ -160,7 +160,8 @@ def to_list(self) -> List[Any]: """Return the list for RLP encoding per EIP-7928.""" return to_serializable_element(self.root) - def with_rlp_override(self, rlp: Bytes) -> "BlockAccessList": + @validate_call + def with_rlp_override(self, rlp: Bytes) -> Self: """ Return a BAL with the same contents whose serialization is ``rlp``, mirroring ``RLPSerializable.rlp_override``. @@ -168,10 +169,15 @@ def with_rlp_override(self, rlp: Bytes) -> "BlockAccessList": A fresh instance is built rather than a copy so that no cached canonical encoding is carried over. """ - new_instance = BlockAccessList(root=self.root) + new_instance = type(self)(root=self.root) new_instance._rlp_override = rlp return new_instance + @property + def has_rlp_override(self) -> bool: + """Return whether ``rlp`` is an override rather than the encoding.""" + return self._rlp_override is not None + @cached_property def rlp(self) -> Bytes: """Return the RLP encoded block access list for hash verification.""" diff --git a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py index 2556892a048..4f56cdf96ce 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py @@ -1226,6 +1226,64 @@ def test_modify_rlp_chains_with_modify() -> None: assert both.modified_rlp(actual_bal) == actual_bal.rlp +def test_modify_chains_with_modify_rlp() -> None: + """An encoding modifier survives a later `modify`.""" + alice = Address(0xA) + actual_bal = BlockAccessList( + [ + BalAccountChange( + address=alice, + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + ] + ) + both = ( + BlockAccessListExpectation() + .modify_rlp(lambda bal: bal.rlp) + .modify(lambda _: BlockAccessList([])) + ) + + assert both.has_rlp_modifier + assert both.modify_if_invalid_test(actual_bal) == BlockAccessList([]) + assert both.modified_rlp(actual_bal) == actual_bal.rlp + + +def test_second_modify_is_refused() -> None: + """A second `modify` would silently replace the first.""" + expectation = BlockAccessListExpectation().modify(lambda bal: bal) + + with pytest.raises(ValueError, match="already has a content modifier"): + expectation.modify(lambda bal: bal) + + +def test_second_modify_rlp_is_refused() -> None: + """A second `modify_rlp` would silently replace the first.""" + expectation = BlockAccessListExpectation().modify_rlp(lambda bal: bal.rlp) + + with pytest.raises(ValueError, match="already re-encodes"): + expectation.modify_rlp(lambda bal: bal.rlp) + + +def test_modify_rlp_with_content_modifier_is_refused() -> None: + """An encoder must return bytes, not a rewritten list.""" + content_modifier: Any = lambda bal: bal # noqa: E731 + expectation = BlockAccessListExpectation().modify_rlp(content_modifier) + + with pytest.raises(TypeError, match="returning bytes"): + expectation.modified_rlp(BlockAccessList([])) + + +def test_modify_with_encoder_is_refused() -> None: + """A content modifier must return a list, not bytes.""" + encoder: Any = lambda bal: bal.rlp # noqa: E731 + expectation = BlockAccessListExpectation().modify(encoder) + + with pytest.raises(TypeError, match="returning a BlockAccessList"): + expectation.modify_if_invalid_test(BlockAccessList([])) + + def test_validate_any_change_mutual_exclusion_with_slot_changes() -> None: """ validate_any_change=True and non-empty slot_changes raises ValueError. diff --git a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_serialization.py b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_serialization.py index d21ca34ad54..d29eb3d117e 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_serialization.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_serialization.py @@ -5,6 +5,11 @@ format, particularly zero-padded hex strings. """ +from typing import Any + +import pytest +from pydantic import ValidationError + from execution_testing.base_types import Address, Bytes from execution_testing.test_types.block_access_list import ( BalAccountChange, @@ -101,7 +106,29 @@ def test_bal_rlp_override_replaces_serialization_only() -> None: canonical = original.rlp overridden = original.with_rlp_override(Bytes(b"\xc0")) + assert overridden.has_rlp_override + assert not original.has_rlp_override assert overridden.rlp == b"\xc0" assert overridden.rlp_hash == Bytes(b"\xc0").keccak256() assert overridden.to_list() == original.to_list() assert original.rlp == canonical + + +def test_bal_rlp_override_rejects_unconvertible_input() -> None: + """``validate_call`` rejects what ``Bytes`` cannot coerce.""" + unconvertible: Any = None + + with pytest.raises(ValidationError): + BlockAccessList([]).with_rlp_override(unconvertible) + + +@pytest.mark.parametrize( + "rlp", + [pytest.param(b"\xc0", id="bytes"), pytest.param("0xc0", id="hex_str")], +) +def test_bal_rlp_override_coerces_to_bytes(rlp: Any) -> None: + """Plain bytes and hex strings are coerced so ``rlp_hash`` works.""" + overridden = BlockAccessList([]).with_rlp_override(rlp) + + assert overridden.rlp == Bytes(b"\xc0") + assert overridden.rlp_hash == Bytes(b"\xc0").keccak256() From 1855bb169fdf8b29ff7fb1eb6396e855549c9d7e Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Wed, 2 Sep 2026 18:35:37 -0600 Subject: [PATCH 51/59] feat(tests): EIP-6110 maximum CL deposits test (#3483) * feat(tests): EIP-6110 maximum CL deposits test * Update tests/prague/eip6110_deposits/test_deposits.py Co-authored-by: spencer * fix: Review comments Co-authored-by: spencer --------- Co-authored-by: spencer --- tests/prague/eip6110_deposits/helpers.py | 224 +++++++++++++++++- tests/prague/eip6110_deposits/spec.py | 7 + .../prague/eip6110_deposits/test_deposits.py | 132 ++++++++++- 3 files changed, 359 insertions(+), 4 deletions(-) diff --git a/tests/prague/eip6110_deposits/helpers.py b/tests/prague/eip6110_deposits/helpers.py index 476e8b21ba2..86bb568cb4b 100644 --- a/tests/prague/eip6110_deposits/helpers.py +++ b/tests/prague/eip6110_deposits/helpers.py @@ -2,9 +2,17 @@ from functools import cached_property from hashlib import sha256 as sha256_hashlib -from typing import ClassVar, Self +from typing import ClassVar, Dict, Self, Tuple -from execution_testing import Address, Hash, SystemContractRequest +from execution_testing import ( + Address, + Bytecode, + Fork, + Hash, + Op, + Opcode, + SystemContractRequest, +) from execution_testing import DepositRequest as DepositRequestBase from .spec import Spec @@ -208,3 +216,215 @@ def from_index(cls, index: int) -> Self: signature=(index * 3) + 2, index=index, ) + + +# The deposit contract is a compiled predeploy, so the gas it charges cannot +# be read off its source. The tables below are the opcodes it executes for one +# deposit, counted from an EVM trace, so that the fork's own gas schedule +# prices them and the estimate follows repricings. Regenerate them by filling +# any single deposit test with `--traces --evm-dump-dir ` and counting +# the `opName` of the depth-2 steps of one call frame. + +_DEPOSIT_CALL_OPCODES: Dict[Opcode, int] = { + Op.PUSH1: 270, + Op.ADD: 177, + Op.SWAP1: 175, + Op.POP: 164, + Op.DUP2: 142, + Op.PUSH2: 123, + Op.DUP1: 117, + Op.SWAP2: 110, + Op.JUMPDEST: 104, + Op.MLOAD: 104, + Op.DUP3: 97, + Op.DUP4: 91, + Op.JUMPI: 86, + Op.MSTORE: 66, + Op.SWAP3: 61, + Op.LT: 54, + Op.ISZERO: 46, + Op.AND: 43, + Op.SUB: 40, + Op.DUP5: 37, + Op.BYTE: 32, + Op.NOT: 28, + Op.JUMP: 27, + Op.PUSH32: 27, + Op.SWAP4: 26, + Op.SHL: 19, + Op.GT: 18, + Op.DUP6: 17, + Op.MSTORE8: 16, + Op.PUSH31: 16, + Op.SWAP5: 13, + Op.OR: 11, + Op.DUP10: 10, + Op.CALLDATALOAD: 8, + Op.DUP7: 8, + Op.EQ: 7, + Op.GAS: 7, + Op.RETURNDATASIZE: 7, + Op.DUP13: 6, + Op.PUSH5: 6, + Op.DUP11: 5, + Op.DUP9: 5, + Op.PUSH4: 5, + Op.CALLDATASIZE: 4, + Op.PUSH8: 4, + Op.CALLVALUE: 3, + Op.DUP15: 3, + Op.MUL: 3, + Op.DUP16: 2, + Op.DUP8: 2, + Op.SWAP6: 2, + Op.DIV: 1, + Op.DUP12: 1, + Op.DUP14: 1, + Op.MOD: 1, + Op.SHR: 1, + Op.STOP: 1, + Op.SWAP14: 1, +} +""" +Fixed-cost opcodes a deposit call executes, excluding the Merkle branch loop +(`_BRANCH_UPDATE_OPCODES`) and the opcodes whose cost depends on their +operands, which are added with the metadata seen in the trace. +""" + +_BRANCH_UPDATE_OPCODES: Dict[Opcode, int] = { + Op.PUSH1: 26, + Op.ADD: 14, + Op.PUSH2: 12, + Op.POP: 12, + Op.MLOAD: 11, + Op.DUP2: 10, + Op.SWAP2: 10, + Op.DUP1: 9, + Op.JUMPDEST: 9, + Op.JUMPI: 8, + Op.SWAP1: 8, + Op.DUP3: 8, + Op.DUP4: 8, + Op.MSTORE: 6, + Op.LT: 6, + Op.SWAP3: 6, + Op.ISZERO: 5, + Op.SUB: 4, + Op.JUMP: 3, + Op.AND: 3, + Op.PUSH32: 3, + Op.DUP5: 2, + Op.SWAP4: 2, + Op.EQ: 1, + Op.OR: 1, + Op.DIV: 1, + Op.NOT: 1, + Op.DUP6: 1, + Op.SWAP5: 1, + Op.GAS: 1, + Op.RETURNDATASIZE: 1, +} +""" +Fixed-cost opcodes added by one iteration of the deposit contract's Merkle +branch loop, which hashes a sibling node into the accumulated deposit root. +""" + +_DEPOSIT_CALL_CALLDATACOPY_SIZES: Tuple[int, ...] = ( + 8, + 8, + 48, + 32, + 96, + 48, + 64, + 32, + 32, +) +"""Bytes copied by each `CALLDATACOPY` of a deposit call.""" + +_DEPOSIT_CALL_EXP_COUNT = 10 +"""`EXP` operations of a deposit call, all with a single-byte exponent.""" + +_DEPOSIT_CALL_SLOAD_COUNT = 3 +"""Storage slots a deposit call reads, all warm after the first deposit.""" + +_DEPOSIT_CALL_SSTORE_COUNT = 2 +"""Storage slots a deposit call writes: the deposit count and a branch node.""" + +_DEPOSIT_CALL_SHA256_COUNT = 7 +"""`sha256` calls a deposit call makes outside the Merkle branch loop.""" + +_SHA256_INPUT_WORDS = 2 +"""Words of input of every `sha256` call the deposit contract makes.""" + +_DEPOSIT_LOG_DATA_SIZE = 576 +"""Bytes of log data the deposit event carries.""" + +_DEPOSIT_CALL_MEMORY_SIZE = 1024 +"""Bytes of memory a deposit call expands to.""" + +_BRANCH_UPDATE_MEMORY_SIZE = 1120 +"""Bytes of memory a deposit call expands to for each branch loop iteration.""" + +_DIRTIED_SSTORE = Op.SSTORE.with_metadata( + key_warm=True, original_value=1, current_value=2, new_value=3 +) +""" +An `SSTORE` to a slot already written earlier in the same transaction, which +is what every deposit but the first of a transaction pays. +""" + + +def _counted(opcode_counts: Dict[Opcode, int]) -> Bytecode: + """Return the opcodes of a count table concatenated into one bytecode.""" + code = Bytecode() + for opcode, count in opcode_counts.items(): + code += opcode * count + return code + + +def _sha256_call(fork: Fork) -> Bytecode: + """ + Return the `STATICCALL` the deposit contract makes to the `sha256` + precompile, charged with the precompile's own gas. + """ + gas_costs = fork.gas_costs() + return Op.STATICCALL.with_metadata( + address_warm=True, + inner_call_cost=( + gas_costs.PRECOMPILE_SHA256_BASE + + gas_costs.PRECOMPILE_SHA256_PER_WORD * _SHA256_INPUT_WORDS + ), + ) + + +def deposit_contract_execution_gas(fork: Fork, *, branch_updates: int) -> int: + """ + Return the gas the deposit contract consumes to process one deposit. + + `branch_updates` is the number of Merkle branch loop iterations to + account for; the loop runs once per trailing zero bit of the new deposit + count. + """ + deposit_call = ( + _counted(_DEPOSIT_CALL_OPCODES) + + Op.EXP.with_metadata(exponent=0xFF) * _DEPOSIT_CALL_EXP_COUNT + + Op.SLOAD.with_metadata(key_warm=True) * _DEPOSIT_CALL_SLOAD_COUNT + + _DIRTIED_SSTORE * _DEPOSIT_CALL_SSTORE_COUNT + + Op.LOG1.with_metadata(data_size=_DEPOSIT_LOG_DATA_SIZE) + + _sha256_call(fork) * _DEPOSIT_CALL_SHA256_COUNT + + Op.MSTORE.with_metadata(new_memory_size=_DEPOSIT_CALL_MEMORY_SIZE) + ) + for size in _DEPOSIT_CALL_CALLDATACOPY_SIZES: + deposit_call += Op.CALLDATACOPY.with_metadata(data_size=size) + branch_update = ( + _counted(_BRANCH_UPDATE_OPCODES) + + Op.EXP.with_metadata(exponent=0xFF) + + Op.SLOAD.with_metadata(key_warm=True) + + _sha256_call(fork) + + Op.MSTORE.with_metadata( + old_memory_size=_DEPOSIT_CALL_MEMORY_SIZE, + new_memory_size=_BRANCH_UPDATE_MEMORY_SIZE, + ) + ) + return (deposit_call + branch_update * branch_updates).gas_cost(fork) diff --git a/tests/prague/eip6110_deposits/spec.py b/tests/prague/eip6110_deposits/spec.py index d36a66d8a2c..0e5c1caab0b 100644 --- a/tests/prague/eip6110_deposits/spec.py +++ b/tests/prague/eip6110_deposits/spec.py @@ -28,3 +28,10 @@ class Spec: DEPOSIT_EVENT_SIGNATURE_HASH = ( 0x649BBC62D0E31342AFEA4E5CD82D4049E7E1EE912FC0889AA790803BE39038C5 ) + MIN_DEPOSIT_AMOUNT = 1_000_000_000 + MIN_DEPOSIT_VALUE = MIN_DEPOSIT_AMOUNT * 10**9 + MAX_DEPOSIT_REQUESTS_PER_PAYLOAD = 8192 + """ + Maximum deposit requests a consensus layer payload can carry: + https://github.com/ethereum/consensus-specs/blob/721cc37193d0321fef6519119c9dc9d34a79dd57/presets/mainnet/electra.yaml#L36 + """ diff --git a/tests/prague/eip6110_deposits/test_deposits.py b/tests/prague/eip6110_deposits/test_deposits.py index 6b5508bfbe3..11f15ac51f3 100644 --- a/tests/prague/eip6110_deposits/test_deposits.py +++ b/tests/prague/eip6110_deposits/test_deposits.py @@ -9,18 +9,26 @@ import pytest from execution_testing import ( + Account, Alloc, Block, BlockchainTestFiller, BlockException, + Environment, + Fork, + Hash, + Header, Macros, Op, + Requests, SystemContractInteractionContract, SystemContractInteractionTransaction, + Transaction, + While, ) -from .helpers import DepositRequest -from .spec import ref_spec_6110 +from .helpers import DepositRequest, deposit_contract_execution_gas +from .spec import Spec, ref_spec_6110 REFERENCE_SPEC_GIT_PATH = ref_spec_6110.git_path REFERENCE_SPEC_VERSION = ref_spec_6110.version @@ -1058,3 +1066,123 @@ def test_deposit_negative( post={}, blocks=blocks, ) + + +@pytest.mark.parametrize( + "deposit_count", + [ + pytest.param( + Spec.MAX_DEPOSIT_REQUESTS_PER_PAYLOAD + 1, + id="over_consensus_layer_payload_maximum", + ), + ], +) +@pytest.mark.slow() +def test_deposit_high_count( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + deposit_count: int, +) -> None: + """ + Test a single block carrying more deposits than a consensus layer payload + is allowed to contain, since EIP-6110 leaves the execution layer request + list unbounded. + + The deposits are driven by a relay contract that loops over one deposit + call, so they only differ in the index the deposit contract assigns them. + """ + deposit = DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=Spec.MIN_DEPOSIT_AMOUNT, + signature=0x03, + index=0x0, + ) + sender = pre.fund_eoa() + + relay_setup_code = Op.CALLDATACOPY( + 0, 32, len(deposit.calldata) + ) + Op.CALLDATALOAD(0) + relay_loop_code = While( + body=Op.POP( + Op.CALL( + address=deposit.interaction_contract_address, + value=deposit.value, + args_offset=0, + args_size=len(deposit.calldata), + value_transfer=True, + address_warm=True, + ) + ), + condition=Op.PUSH1(1) + Op.SWAP1 + Op.SUB + Op.DUP1, + ) + + deposit_relay_code = relay_setup_code + relay_loop_code + deposit_relay_iteration_gas = relay_loop_code.gas_cost(fork) + + relay_contract = pre.deploy_contract( + code=deposit_relay_code, + balance=deposit.value * deposit_count, + ) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + # The deposit contract's Merkle branch loop runs once per trailing zero + # bit of the new deposit count, so no deposit iterates it more often than + # the count's bit length. Budgeting that depth for every deposit, rather + # than the single iteration they average, leaves room for the storage + # slots each transaction writes for the first time. + gas_per_deposit = ( + deposit_relay_iteration_gas + + deposit_contract_execution_gas( + fork, branch_updates=deposit_count.bit_length() + ) + ) + + deposits_per_transaction = deposit_count + gas_limit_cap = fork.transaction_gas_limit_cap() + if gas_limit_cap is not None: + deposits_per_transaction = ( + gas_limit_cap + - intrinsic_gas_calculator( + calldata=Hash(deposit_count) + deposit.calldata + ) + - relay_setup_code.gas_cost(fork) + ) // gas_per_deposit + + txs: List[Transaction] = [] + for start in range(0, deposit_count, deposits_per_transaction): + count = min(deposits_per_transaction, deposit_count - start) + data = Hash(count) + deposit.calldata + txs.append( + Transaction( + sender=sender, + to=relay_contract, + data=data, + gas_limit=intrinsic_gas_calculator(calldata=data) + + (count * gas_per_deposit), + ) + ) + + blockchain_test( + genesis_environment=Environment( + gas_limit=sum(int(tx.gas_limit) for tx in txs) + ), + pre=pre, + post={relay_contract: Account(balance=0)}, + blocks=[ + Block( + txs=txs, + header_verify=Header( + requests_hash=Requests( + *[ + deposit.copy(index=index) + for index in range(deposit_count) + ] + ), + ), + # Constrain fixture size. + include_receipts_in_output=False, + ) + ], + ) From 947ef52d8230215a42c0a3347458e3249e2c804c Mon Sep 17 00:00:00 2001 From: spencer Date: Thu, 3 Sep 2026 11:11:35 +0200 Subject: [PATCH 52/59] fix(tests,test-fill): fix enginex fills for BAL forks and improve the enginex consistency check (#3265) Co-authored-by: danceratopz --- .../pytest_commands/plugins/filler/filler.py | 69 +- .../fixtures/engine_x_checks.py | 660 +++++++++++++++--- .../fixtures/tests/test_engine_x_checks.py | 648 ++++++++++++++--- .../test_block_access_lists_eip2935.py | 12 +- .../test_gas_cost_return.py | 6 + 5 files changed, 1148 insertions(+), 247 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py index 6d2fe4fae86..bdfd69c4855 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py @@ -54,6 +54,8 @@ ) from execution_testing.fixtures.engine_x_checks import ( ENGINE_X_FIXTURES_DIR, + EngineXCheckError, + EngineXExecutionDriftError, verify_engine_x_execution, ) from execution_testing.fixtures.pre_alloc_groups import ( @@ -964,6 +966,16 @@ def pytest_terminal_summary( ) terminalreporter.write_line(engine_x_warning, yellow=True) + engine_x_error = getattr(config, "engine_x_check_error", None) + if engine_x_error is not None: + title = ( + " ERROR: Engine X execution drift " + if isinstance(engine_x_error, EngineXExecutionDriftError) + else " ERROR: Engine X execution consistency check failed " + ) + terminalreporter.write_sep("=", title, bold=True, red=True) + terminalreporter.write_line(str(engine_x_error), red=True) + def _aggregate_cache_stats(node: Any) -> None: """Aggregate t8n cache stats from an xdist worker.""" @@ -2033,6 +2045,7 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: - Generate index file for all produced fixtures. - Create tarball of the output directory if the output is a tarball. """ + del exitstatus logger = logging.getLogger("fill.sessionfinish") is_worker = xdist.is_xdist_worker(session) @@ -2050,8 +2063,6 @@ def _log_timing(msg: str) -> None: # Log immediately when hook is entered (before any early returns) _log_timing(f"pytest_sessionfinish ENTERED (worker={is_worker})") - del exitstatus - # Save pre-allocation groups after phase 1 fixture_output: FixtureOutput = session.config.fixture_output # type: ignore[attr-defined] session_instance: FillingSession = session.config.filling_session # type: ignore[attr-defined] @@ -2151,40 +2162,44 @@ def _log_timing(msg: str) -> None: file.unlink() _log_timing(f"Lock files removed in {time.time() - t0:.1f}s") + # Loudly fail the fill if pre-alloc group packing changed any Engine X + # test's execution. The check reports through the terminal summary + # instead of raising: an exception from this hook would abort the + # terminal reporter before the FAILURES section prints (hiding any + # test failures, which the drift report may be the explanation for) + # and would skip the index merge and tarball below. Drift still fails + # the fill via the session exit status. The check runs on unclean + # sessions too: a leaked account that breaks a test's post-state is + # exactly the failure the drift report diagnoses. if not session.config.getoption("optimistic_pre_alloc_grouping_disabled"): - # Loudly fail the fill if pre-alloc group packing changed any Engine X - # test's execution (raises on drift, like a pre-alloc collision). _log_timing("verify_engine_x_execution: starting...") t0 = time.time() - engine_x_check = verify_engine_x_execution(fixture_output.directory) - engine_x_warning: str | None = None - if engine_x_check is not None: + try: + engine_x_check = verify_engine_x_execution( + fixture_output.directory + ) + except EngineXCheckError as check_error: + logger.error(str(check_error)) + session.config.engine_x_check_error = check_error # type: ignore[attr-defined] # noqa: E501 + session.exitstatus = pytest.ExitCode.TESTS_FAILED + else: if engine_x_check.compared > 0: logger.info(engine_x_check.summary) - elif engine_x_check.skipped > 0: - engine_x_warning = ( - "Engine X execution consistency check skipped: none of " - f"the {engine_x_check.skipped} Engine X fixtures have a " - "blockchain_tests_engine sibling fixture to compare " - "against. Leaks from pre-alloc group packing are not " - "verified for this output." + if engine_x_check.skip_reason is not None: + logger.warning(engine_x_check.skip_reason) + # Repeated in the terminal summary; a log line alone is + # easy to miss. + session.config.engine_x_check_warning = ( # type: ignore[attr-defined] # noqa: E501 + engine_x_check.skip_reason ) - elif (fixture_output.directory / ENGINE_X_FIXTURES_DIR).is_dir(): - engine_x_warning = ( - "Engine X execution consistency check skipped: this fill " - "generated no blockchain_tests_engine fixtures to compare " - "against (e.g. filling with `-m blockchain_test_engine_x`). " - "Leaks from pre-alloc group packing are not verified for this " - "output." - ) - if engine_x_warning is not None: - logger.warning(engine_x_warning) - # Repeated in the terminal summary; a log line alone is easy to - # miss. - session.config.engine_x_check_warning = engine_x_warning # type: ignore[attr-defined] # noqa: E501 _log_timing( f"verify_engine_x_execution: done in {time.time() - t0:.1f}s" ) + elif (fixture_output.directory / ENGINE_X_FIXTURES_DIR).is_dir(): + logger.info( + "Engine X execution consistency check skipped: optimistic " + "pre-alloc grouping is disabled." + ) # Verify fixtures after merge if verification is enabled if session.config.getoption("verify_fixtures"): diff --git a/packages/testing/src/execution_testing/fixtures/engine_x_checks.py b/packages/testing/src/execution_testing/fixtures/engine_x_checks.py index b81eaf38363..9f7070ee572 100644 --- a/packages/testing/src/execution_testing/fixtures/engine_x_checks.py +++ b/packages/testing/src/execution_testing/fixtures/engine_x_checks.py @@ -1,59 +1,139 @@ -"""Fill-time execution-consistency check for Engine X fixtures.""" +""" +Fill-time execution-consistency check for Engine X fixtures. + +An Engine X fixture is filled against its packed pre-allocation group's +merged genesis, while the test's `blockchain_test_engine` sibling is +filled against the test's own pre-allocation. Their per-payload +execution outputs must be identical; `verify_engine_x_execution` +compares the two fixture trees after a fill and raises a classified, +per-cause report when they are not. + +The check runs post-fill on the fixture files because the two formats of +a test are separate pytest items that may fill on different xdist +workers; the output directory is the only place both reliably exist. It +only needs that directory, so it can also be re-run standalone against a +failed fill's artifacts without re-filling. +""" import json +from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, List, NamedTuple, Optional, Tuple +from typing import Any, Dict, List, Tuple, Type, TypeVar + +from pydantic import ValidationError + +from execution_testing.base_types import Address, Bytes, Hash +from execution_testing.forks.forks.eips.prague.eip_2935 import ( + HISTORY_STORAGE_ADDRESS, +) +from execution_testing.test_types import AllocGroupHash +from execution_testing.test_types.block_access_list import BlockAccessList + +from .blockchain import ( + BlockchainEngineFixture, + BlockchainEngineXFixture, + FixtureEngineNewPayload, + FixtureExecutionPayload, +) +from .pre_alloc_groups import PreAllocGroup, PreAllocGroups + +ENGINE_X_FIXTURES_DIR = BlockchainEngineXFixture.output_base_dir_name() +SIBLING_FIXTURES_DIR = BlockchainEngineFixture.output_base_dir_name() + +# Path parts under a fixture format tree that do not contain fixture +# files (mirrors INDEX_EXCLUDED_PATH_PARTS in `cli/gen_index.py`). +_NON_FIXTURE_PATH_PARTS = frozenset({".meta", "pre_alloc"}) + +# Execution payload fields whose value is a function of the genesis +# state root and so legitimately differs between a test's own genesis +# and its packed group's genesis. Python field names of +# `FixtureExecutionPayload`; a unit test asserts they stay valid when +# the model changes. +STATE_ROOT_DERIVED_FIELDS = frozenset( + {"state_root", "block_hash", "parent_hash"} +) + +# Placeholder for the masked EIP-2935 parent-hash write in a BAL. +_PARENT_HASH_PLACEHOLDER = "" + +_VALUE_DISPLAY_LIMIT = 96 + +_FixtureT = TypeVar( + "_FixtureT", BlockchainEngineFixture, BlockchainEngineXFixture +) -ENGINE_X_FIXTURES_DIR = "blockchain_tests_engine_x" -SIBLING_FIXTURES_DIR = "blockchain_tests_engine" -# Every state-root-derived field of an execution payload. These are the only -# fields a packed (merged) genesis is allowed to change; everything else in a -# payload is a pure function of the test's execution. -_STATE_ROOT_DERIVED_FIELDS = ("stateRoot", "blockHash", "parentHash") +class EngineXCheckError(Exception): + """The Engine X execution check failed to run on the fixture output.""" -class EngineXExecutionDriftError(Exception): +@dataclass(frozen=True) +class ExecutionDrift: + """A fixture whose packed execution differs from its sibling's.""" + + test_id: str + signature: str + """One-line cause classification; identical causes share it.""" + detail: str + """Multi-line explanation rendered for the first example only.""" + + +class EngineXExecutionDriftError(EngineXCheckError): """ A packed pre-allocation group changed a test's execution. - An Engine X fixture is filled against its group's merged genesis, while - the test's `blockchain_test_engine` sibling is filled against the test's - own pre-allocation. Their per-payload execution outputs (gas used, - receipts root, logs bloom, ...) must be identical; a difference means - either an account introduced by pre-alloc group packing leaked into the - test's execution (see `pack_pre_alloc_groups`), or the test observes the - genesis hash itself (e.g. via `BLOCKHASH(0)`), which depends on every - account in the genesis and so cannot survive any grouping. + Either an account introduced by pre-alloc group packing leaked into + the test's execution (see `pack_pre_alloc_groups`), or the test + observes a block hash (e.g. via `BLOCKHASH`), which depends on every + account in the genesis and so cannot survive any grouping. Drifts + are grouped by cause signature so a single systemic cause reads as + one diagnosis, not one failure per fixture. """ - def __init__(self, mismatches: List[Tuple[str, str]], compared: int): - """Initialize with the mismatched test ids and the compared count.""" - self.mismatches = mismatches + MAX_SIGNATURES = 10 + MAX_EXAMPLE_IDS = 3 + + def __init__(self, drifts: List[ExecutionDrift], compared: int): + """Initialize with per-fixture drifts, grouped by signature.""" + self.drifts = drifts self.compared = compared - details = "\n".join( - f" {test_id}: {what}" for test_id, what in mismatches[:10] - ) - if len(mismatches) > 10: - details += f"\n ... and {len(mismatches) - 10} more" + grouped: Dict[str, List[ExecutionDrift]] = {} + for drift in drifts: + grouped.setdefault(drift.signature, []).append(drift) + sections = [] + for signature, group in list(grouped.items())[: self.MAX_SIGNATURES]: + ids = ", ".join( + drift.test_id for drift in group[: self.MAX_EXAMPLE_IDS] + ) + if len(group) > self.MAX_EXAMPLE_IDS: + ids += f", ... and {len(group) - self.MAX_EXAMPLE_IDS} more" + section = f"[{len(group)}x] {signature}\n tests: {ids}" + if group[0].detail: + section += "\n" + "\n".join( + f" {line}" for line in group[0].detail.splitlines() + ) + sections.append(section) + if len(grouped) > self.MAX_SIGNATURES: + sections.append( + f"... and {len(grouped) - self.MAX_SIGNATURES} more " + "distinct causes" + ) super().__init__( - f"{len(mismatches)} of {compared} Engine X fixtures execute " - "differently against their packed pre-allocation group's genesis " - "than against their own pre-allocation:\n" - f"{details}\n" - "Sharing a genesis changed these tests' execution: either an " - "account introduced by pre-alloc group packing leaked into " - "their execution, or they observe the genesis hash itself " - "(e.g. via BLOCKHASH(0)). Isolate the affected tests with " - '@pytest.mark.pre_alloc_group("separate") and re-fill.' + f"{len(drifts)} of {compared} Engine X fixtures execute " + "differently against their packed pre-allocation group's " + "genesis than against their own pre-allocation " + f"({len(grouped)} distinct cause(s)):\n\n" + "\n\n".join(sections) ) -class EngineXCheckResult(NamedTuple): - """Comparison counts from a completed Engine X execution check.""" +@dataclass(frozen=True) +class EngineXCheckResult: + """Outcome of a completed Engine X execution check.""" - compared: int - skipped: int + compared: int = 0 + skipped: int = 0 + skip_reason: str | None = None + """Set when Engine X fixtures exist but nothing could be compared.""" @property def summary(self) -> str: @@ -63,104 +143,466 @@ def summary(self) -> str: "against their packed group's genesis" ) if self.skipped: - summary += f" ({self.skipped} skipped: no sibling engine fixture)" + summary += f" ({self.skipped} skipped: no sibling fixture)" return summary -def _scrubbed_payloads(fixture: Dict[str, Any]) -> List[Any]: - """Return the fixture's payload entries minus state-root-derived fields.""" - payloads = [] - for entry in fixture.get("engineNewPayloads", []): - entry = json.loads(json.dumps(entry)) - params = entry.get("params") - if params and isinstance(params[0], dict): - for field in _STATE_ROOT_DERIVED_FIELDS: - params[0].pop(field, None) - payloads.append(entry) - return payloads - - -def _describe_mismatch(base: List[Any], packed: List[Any]) -> str: - """Return a short description of the first difference between payloads.""" - if len(base) != len(packed): - return f"payload count: {len(base)} != {len(packed)}" - for i, (base_entry, packed_entry) in enumerate( - zip(base, packed, strict=False) +def _sibling_test_id(engine_x_test_id: str) -> str: + """Return the sibling-format id of an Engine X test id.""" + return engine_x_test_id.replace( + BlockchainEngineXFixture.format_name, + BlockchainEngineFixture.format_name, + ) + + +def _sibling_file( + engine_x_file: Path, engine_x_dir: Path, sibling_dir: Path +) -> Path: + """ + Return the sibling fixture file for an Engine X fixture file. + + A `--single-fixture-per-file` fill embeds the fixture format name in + every file name, so the sibling's basename can differ. + """ + sibling = sibling_dir / engine_x_file.relative_to(engine_x_dir) + if not sibling.exists(): + sibling = sibling.with_name( + sibling.name.replace( + BlockchainEngineXFixture.format_name, + BlockchainEngineFixture.format_name, + ) + ) + return sibling + + +def _load_fixture_file( + file: Path, fixture_cls: Type[_FixtureT] +) -> Dict[str, _FixtureT]: + """Parse every fixture in a fixture file with its typed model.""" + try: + raw = json.loads(file.read_text()) + except json.JSONDecodeError as e: + raise EngineXCheckError(f"unreadable fixture file {file}: {e}") from e + fixtures: Dict[str, _FixtureT] = {} + for test_id, data in raw.items(): + try: + fixtures[test_id] = fixture_cls.model_validate(data) + except ValidationError as e: + raise EngineXCheckError( + f"cannot parse {fixture_cls.format_name!r} fixture " + f"{test_id!r} in {file}: {e}" + ) from e + return fixtures + + +def _comparable_payload(payload: FixtureEngineNewPayload) -> Dict[str, Any]: + """ + Return the payload as a dict without state-root-derived values. + + The block access list is replaced by its decoded, parent-hash-masked + form, see `_comparable_bal`. + """ + entry = payload.model_dump( + mode="json", + by_alias=True, + exclude={ + "params": { + 0: set(STATE_ROOT_DERIVED_FIELDS) | {"block_access_list"} + } + }, + ) + execution_payload = payload.params[0] + if execution_payload.block_access_list is not None: + entry["params"][0]["blockAccessList"] = _comparable_bal( + execution_payload.block_access_list, + execution_payload.parent_hash, + ) + return entry + + +def _comparable_bal(bal: Bytes, parent_hash: Hash) -> Any: + """ + Return the decoded BAL with the EIP-2935 history write masked. + + The EIP-2935 system call writes the block's parent hash into the + history contract on every block; at payload 0 that value is the + genesis hash itself, so it is the one BAL entry that legitimately + differs between a test's own genesis and its packed group's genesis. + Only that write is masked: a storage change of the history contract + whose written value equals the payload's own parent hash. A + parent-hash-valued write to any other account is genuine drift (the + test observes block hashes and cannot survive grouping). An + undecodable BAL (an intentionally malformed one from a negative + test) is compared verbatim. + + Known limitation: a header parent hash injected with + `Block.rlp_modifier=Header(parent_hash=...)` defeats the mask. The + override is applied after t8n has executed the block, so the BAL + keeps the real parent hash while the payload field holds the + injected value; the mask matches neither, and the check fails + loudly on a difference that packing did not cause. No test does + this today. If one appears, isolate it with + `@pytest.mark.pre_alloc_group("separate")`, or make the mask derive + the expected value from the chain itself (the genesis hash at + payload 0, the previous payload's block hash after that) instead of + the payload's own `parent_hash` field. + """ + try: + accounts = BlockAccessList.from_rlp(bal) + except Exception: + return str(bal) + parent_hash_value = int.from_bytes(parent_hash, "big") + dumped = accounts.model_dump(mode="json") + for account in dumped: + if int(account["address"], 16) != HISTORY_STORAGE_ADDRESS: + continue + for slot in account["storage_changes"]: + for change in slot["slot_changes"]: + if int(change["post_value"], 16) == parent_hash_value: + change["post_value"] = _PARENT_HASH_PLACEHOLDER + return dumped + + +class _GroupLookup: + """Lazily load packed pre-allocation groups for drift attribution.""" + + def __init__(self, engine_x_dir: Path): + """Initialize with the Engine X fixture tree to look under.""" + self._folder = engine_x_dir / "pre_alloc" + self._groups: PreAllocGroups | None = None + + def get(self, pre_hash: AllocGroupHash) -> PreAllocGroup | None: + """Return the group for a hash, or None if unavailable.""" + if self._groups is None: + if not self._folder.is_dir(): + return None + try: + self._groups = PreAllocGroups.from_folder( + self._folder, lazy_load=True + ) + except Exception: + return None + try: + return self._groups[pre_hash] + except Exception: + return None + + +def _short(value: Any) -> str: + """Render a value for an error message, truncated if long.""" + text = value if isinstance(value, str) else json.dumps(value) + if len(text) > _VALUE_DISPLAY_LIMIT: + text = f"{text[:_VALUE_DISPLAY_LIMIT]}... ({len(text)} chars)" + return text + + +def _hex_int(value: Any) -> int | None: + """Parse a hex string to an int, or return None.""" + try: + return int(value, 16) + except (TypeError, ValueError): + return None + + +def _diff_fields( + base_entry: Dict[str, Any], packed_entry: Dict[str, Any] +) -> List[Tuple[str, Any, Any]]: + """Return the (field, own, packed) diffs of two comparable payloads.""" + diffs: List[Tuple[str, Any, Any]] = [] + for key in sorted(set(base_entry) | set(packed_entry)): + base_value = base_entry.get(key) + packed_value = packed_entry.get(key) + if base_value == packed_value: + continue + if ( + key != "params" + or not isinstance(base_value, list) + or not isinstance(packed_value, list) + or len(base_value) != len(packed_value) + ): + diffs.append((key, base_value, packed_value)) + continue + for i, (base_param, packed_param) in enumerate( + zip(base_value, packed_value, strict=True) + ): + if base_param == packed_param: + continue + if ( + i == 0 + and isinstance(base_param, dict) + and isinstance(packed_param, dict) + ): + for field in sorted(set(base_param) | set(packed_param)): + if base_param.get(field) != packed_param.get(field): + diffs.append( + ( + field, + base_param.get(field), + packed_param.get(field), + ) + ) + else: + diffs.append((f"params[{i}]", base_param, packed_param)) + return diffs + + +def _parent_hash_write_slot( + base_account: Dict[str, Any], + packed_account: Dict[str, Any], + base_parent_hash: int, + packed_parent_hash: int, +) -> str | None: + """Return the slot where each side stored its own parent hash.""" + packed_slots = { + slot["slot"]: slot for slot in packed_account["storage_changes"] + } + for base_slot in base_account["storage_changes"]: + packed_slot = packed_slots.get(base_slot["slot"]) + if packed_slot is None: + continue + packed_changes = { + change["block_access_index"]: change + for change in packed_slot["slot_changes"] + } + for base_change in base_slot["slot_changes"]: + packed_change = packed_changes.get( + base_change["block_access_index"] + ) + if packed_change is None or base_change == packed_change: + continue + if ( + _hex_int(base_change["post_value"]) == base_parent_hash + and _hex_int(packed_change["post_value"]) == packed_parent_hash + ): + return str(base_slot["slot"]) + return None + + +def _diff_bal( + payload_index: int, + base_bal: Any, + packed_bal: Any, + base_payload: FixtureExecutionPayload, + packed_payload: FixtureExecutionPayload, + sibling: BlockchainEngineFixture, + engine_x: BlockchainEngineXFixture, + groups: _GroupLookup, +) -> Tuple[str, str]: + """Return (signature, detail) for a block-access-list difference.""" + prefix = f"payload {payload_index}" + if isinstance(base_bal, str) or isinstance(packed_bal, str): + return ( + f"{prefix}: blockAccessList differs (undecodable BAL " + "compared verbatim)", + f"own: {_short(base_bal)}\npacked: {_short(packed_bal)}", + ) + base_accounts = {account["address"]: account for account in base_bal} + packed_accounts = {account["address"]: account for account in packed_bal} + extra = sorted(set(packed_accounts) - set(base_accounts)) + missing = sorted(set(base_accounts) - set(packed_accounts)) + if extra: + address = extra[0] + signature = ( + f"{prefix}: account {address} appears in the packed " + "fixture's BAL only" + ) + if Address(address) in sibling.pre.root: + return signature, ( + "the account is declared in the test's own " + "pre-allocation but only touched under the packed " + "genesis: packing changed the execution path" + ) + group = groups.get(engine_x.pre_hash) + if group is not None and Address(address) in group.pre: + others = ", ".join(group.test_ids[:3]) + return signature, ( + "the account is absent from the test's own " + "pre-allocation but present in pre-alloc group " + f"{engine_x.pre_hash} ({group.test_count} tests, e.g. " + f"{others}): an account introduced by packing leaked " + "into this test's execution. Isolate the test with " + '@pytest.mark.pre_alloc_group("separate") or declare ' + "the account in its pre-allocation" + ) + return signature, ( + "the account is absent from the test's own pre-allocation " + "and from its packed pre-alloc group: packing changed the " + "execution path" + ) + if missing: + return ( + f"{prefix}: account {missing[0]} is missing from the " + "packed fixture's BAL", + "the test touches the account only under its own genesis: " + "packing changed the execution path", + ) + base_parent_hash = int.from_bytes(base_payload.parent_hash, "big") + packed_parent_hash = int.from_bytes(packed_payload.parent_hash, "big") + for address, base_account in base_accounts.items(): + packed_account = packed_accounts[address] + if base_account == packed_account: + continue + slot = _parent_hash_write_slot( + base_account, + packed_account, + base_parent_hash, + packed_parent_hash, + ) + if slot is not None: + return ( + f"{prefix}: account {address} writes its block's " + f"parent hash to storage (slot {slot})", + "each fixture's BAL stores its own parent hash: the " + "test observes block hashes (e.g. via BLOCKHASH), " + "which cannot survive pre-alloc grouping. Isolate the " + 'test with @pytest.mark.pre_alloc_group("separate")', + ) + return ( + f"{prefix}: BAL differs for account {address}", + f"own: {_short(base_account)}\n" + f"packed: {_short(packed_account)}", + ) + return f"{prefix}: blockAccessList differs", "" + + +def _diagnose( + test_id: str, + engine_x: BlockchainEngineXFixture, + sibling: BlockchainEngineFixture, + base_payloads: List[Dict[str, Any]], + packed_payloads: List[Dict[str, Any]], + groups: _GroupLookup, +) -> ExecutionDrift: + """Classify the first difference between two payload sequences.""" + if len(base_payloads) != len(packed_payloads): + return ExecutionDrift( + test_id, + signature="payload count differs", + detail=( + f"sibling has {len(base_payloads)} payloads, packed " + f"fixture has {len(packed_payloads)}: packing changed " + "block-level outcomes" + ), + ) + for index, (base_entry, packed_entry) in enumerate( + zip(base_payloads, packed_payloads, strict=True) ): if base_entry == packed_entry: continue - base_payload = base_entry.get("params", [{}])[0] - packed_payload = packed_entry.get("params", [{}])[0] - if isinstance(base_payload, dict) and isinstance(packed_payload, dict): - fields = sorted( - field - for field in set(base_payload) | set(packed_payload) - if base_payload.get(field) != packed_payload.get(field) + diffs = _diff_fields(base_entry, packed_entry) + fields = sorted({field for field, _, _ in diffs}) + if "blockAccessList" in fields: + base_bal, packed_bal = next( + (base_value, packed_value) + for field, base_value, packed_value in diffs + if field == "blockAccessList" + ) + signature, detail = _diff_bal( + index, + base_bal, + packed_bal, + sibling.payloads[index].params[0], + engine_x.payloads[index].params[0], + sibling, + engine_x, + groups, ) - if fields: - return f"payload {i} differs in: {', '.join(fields)}" - return f"payload {i} differs" - return "payloads differ" + other_fields = [f for f in fields if f != "blockAccessList"] + if other_fields: + detail += ( + f"\nthe payload also differs in: {', '.join(other_fields)}" + ) + return ExecutionDrift(test_id, signature, detail) + detail_lines = [] + for field, base_value, packed_value in diffs[:5]: + detail_lines.append(f"{field}:") + detail_lines.append(f" own: {_short(base_value)}") + detail_lines.append(f" packed: {_short(packed_value)}") + return ExecutionDrift( + test_id, + signature=f"payload {index} differs in: {', '.join(fields)}", + detail="\n".join(detail_lines), + ) + return ExecutionDrift(test_id, "payloads differ", "") -def verify_engine_x_execution( - output_dir: Path, -) -> Optional[EngineXCheckResult]: +def verify_engine_x_execution(output_dir: Path) -> EngineXCheckResult: """ - Verify that pre-alloc group packing did not change any test's execution. + Verify that pre-alloc group packing did not change any test's + execution. For every Engine X fixture (filled against its packed group's merged - genesis), compare its `engineNewPayloads` against the test's - `blockchain_test_engine` sibling fixture (filled against the test's own - pre-allocation in the same session, with an independent `t8n` execution: - Engine X fixtures never share the transition tool output cache). All - payload fields except the state-root-derived ones must match exactly. + genesis), compare its payloads against the test's + `blockchain_test_engine` sibling fixture (filled against the test's + own pre-allocation in the same session, with an independent `t8n` + execution: Engine X fixtures never share the transition tool output + cache). All payload fields except the state-root-derived ones must + match exactly; each payload's block access list is compared with the + EIP-2935 history write of its own parent hash masked out, see + `_comparable_bal`. - Return the comparison counts, or ``None`` when one of the two fixture - format trees was not generated at all (e.g. when filling with - ``-m blockchain_test_engine_x``, which produces no siblings). + Return the comparison counts; `skip_reason` is set when Engine X + fixtures exist but nothing could be compared (e.g. when filling with + `-m blockchain_test_engine_x`, which produces no siblings). - Raise `EngineXExecutionDriftError` if any test executed differently. + Raise `EngineXExecutionDriftError` if any test executed differently, + or `EngineXCheckError` if a fixture file cannot be parsed. """ engine_x_dir = output_dir / ENGINE_X_FIXTURES_DIR sibling_dir = output_dir / SIBLING_FIXTURES_DIR - if not engine_x_dir.is_dir() or not sibling_dir.is_dir(): - return None + if not engine_x_dir.is_dir(): + return EngineXCheckResult() + if not sibling_dir.is_dir(): + return EngineXCheckResult( + skip_reason=( + "Engine X execution consistency check skipped: this " + f"fill generated no {SIBLING_FIXTURES_DIR} fixtures to " + "compare against (e.g. filling with `-m " + f"{BlockchainEngineXFixture.format_name}`). Leaks from " + "pre-alloc group packing are not verified for this " + "output." + ) + ) + groups = _GroupLookup(engine_x_dir) compared = 0 skipped = 0 - mismatches: List[Tuple[str, str]] = [] - for engine_x_file in engine_x_dir.rglob("*.json"): - if "pre_alloc" in engine_x_file.parts: + drifts: List[ExecutionDrift] = [] + for engine_x_file in sorted(engine_x_dir.rglob("*.json")): + relative_parts = engine_x_file.relative_to(engine_x_dir).parts + if _NON_FIXTURE_PATH_PARTS.intersection(relative_parts): continue - sibling_file = sibling_dir / engine_x_file.relative_to(engine_x_dir) - if not sibling_file.exists(): - # A --single-fixture-per-file fill embeds the fixture format - # name in every file name, so the sibling's basename differs. - sibling_file = sibling_file.with_name( - sibling_file.name.replace( - "blockchain_test_engine_x", "blockchain_test_engine" - ) - ) - sibling_fixtures = ( - json.loads(sibling_file.read_text()) + sibling_file = _sibling_file(engine_x_file, engine_x_dir, sibling_dir) + siblings = ( + _load_fixture_file(sibling_file, BlockchainEngineFixture) if sibling_file.exists() else {} ) - for test_id, fixture in json.loads(engine_x_file.read_text()).items(): - sibling_id = test_id.replace( - "blockchain_test_engine_x", "blockchain_test_engine" - ) - sibling = sibling_fixtures.get(sibling_id) + for test_id, fixture in _load_fixture_file( + engine_x_file, BlockchainEngineXFixture + ).items(): + sibling = siblings.get(_sibling_test_id(test_id)) if sibling is None: skipped += 1 continue compared += 1 - base = _scrubbed_payloads(sibling) - packed = _scrubbed_payloads(fixture) + base = [_comparable_payload(p) for p in sibling.payloads] + packed = [_comparable_payload(p) for p in fixture.payloads] if base != packed: - mismatches.append((test_id, _describe_mismatch(base, packed))) + drifts.append( + _diagnose(test_id, fixture, sibling, base, packed, groups) + ) - if mismatches: - raise EngineXExecutionDriftError(mismatches, compared) - return EngineXCheckResult(compared=compared, skipped=skipped) + if drifts: + raise EngineXExecutionDriftError(drifts, compared) + skip_reason = None + if compared == 0 and skipped > 0: + skip_reason = ( + "Engine X execution consistency check skipped: none of the " + f"{skipped} Engine X fixtures have a {SIBLING_FIXTURES_DIR} " + "sibling fixture to compare against. Leaks from pre-alloc " + "group packing are not verified for this output." + ) + return EngineXCheckResult( + compared=compared, skipped=skipped, skip_reason=skip_reason + ) diff --git a/packages/testing/src/execution_testing/fixtures/tests/test_engine_x_checks.py b/packages/testing/src/execution_testing/fixtures/tests/test_engine_x_checks.py index 7e44afee03c..1f74afa3686 100644 --- a/packages/testing/src/execution_testing/fixtures/tests/test_engine_x_checks.py +++ b/packages/testing/src/execution_testing/fixtures/tests/test_engine_x_checks.py @@ -6,12 +6,36 @@ import pytest +from execution_testing.base_types import Account, Address, Bytes, Hash +from execution_testing.fixtures.blockchain import ( + BlockchainEngineFixture, + BlockchainEngineXFixture, + FixtureConfig, + FixtureEngineNewPayload, + FixtureExecutionPayload, + FixtureHeader, +) from execution_testing.fixtures.engine_x_checks import ( ENGINE_X_FIXTURES_DIR, SIBLING_FIXTURES_DIR, + STATE_ROOT_DERIVED_FIELDS, + EngineXCheckError, EngineXExecutionDriftError, verify_engine_x_execution, ) +from execution_testing.fixtures.pre_alloc_groups import PreAllocGroupBuilder +from execution_testing.forks import Prague +from execution_testing.forks.forks.eips.prague.eip_2935 import ( + HISTORY_STORAGE_ADDRESS, +) +from execution_testing.test_types import Alloc, AllocGroupHash, Environment +from execution_testing.test_types.block_access_list import ( + BalAccountChange, + BalNonceChange, + BalStorageChange, + BalStorageSlot, + BlockAccessList, +) ENGINE_X_ID = ( "tests/a.py::test_a[fork_Prague-blockchain_test_engine_x_from_state_test]" @@ -19,124 +43,489 @@ SIBLING_ID = ( "tests/a.py::test_a[fork_Prague-blockchain_test_engine_from_state_test]" ) +ENGINE_X_ID_B = ENGINE_X_ID.replace("test_a[", "test_b[") +SIBLING_ID_B = SIBLING_ID.replace("test_a[", "test_b[") + +PRE_HASH = "0xf00df00df00df00d" + +# One parent hash with no leading zero bytes and one with 31 of them: +# masking must be insensitive to the RLP canonical trimming of either. +SIBLING_PARENT_HASH = Hash(bytes.fromhex("aa" * 32)) +ENGINE_X_PARENT_HASH = Hash(0xBB) + +SENDER = Address(0xA) +UNDECLARED_ACCOUNT = Address(0x1000) +TEST_CONTRACT = Address(0xC0DE) + + +def _genesis_header() -> FixtureHeader: + """Build a minimal valid Prague genesis header.""" + return FixtureHeader( + fork=Prague, + fee_recipient=Address(0), + state_root=Hash(0), + number=0, + gas_limit=30_000_000, + gas_used=0, + timestamp=0, + extra_data=b"\x00", + base_fee_per_gas=7, + withdrawals_root=Hash(0), + blob_gas_used=0, + excess_blob_gas=0, + parent_beacon_block_root=Hash(0), + requests_hash=Hash(0), + ) def _payload( - *, gas_used: str, state_root: str, block_hash: str -) -> Dict[str, Any]: - """Build a single newPayload entry.""" - return { - "newPayloadVersion": "4", - "forkchoiceUpdatedVersion": "3", - "params": [ - { - "parentHash": f"0x{'00' * 31}aa", - "stateRoot": state_root, - "blockHash": block_hash, - "gasUsed": gas_used, - "receiptsRoot": f"0x{'11' * 32}", - "logsBloom": f"0x{'00' * 256}", - "transactions": ["0xf86b..."], - }, - [], - f"0x{'00' * 32}", - ], - } + *, + parent_hash: Hash, + state_root: Hash, + block_hash: Hash, + gas_used: int, + block_access_list: Bytes | None, +) -> FixtureEngineNewPayload: + """Build a payload whose execution outputs are deterministic.""" + execution_payload = FixtureExecutionPayload( + parent_hash=parent_hash, + fee_recipient=Address(0), + state_root=state_root, + receipts_root=Hash(0x11), + logs_bloom=b"\x00" * 256, + number=1, + gas_limit=30_000_000, + gas_used=gas_used, + timestamp=12, + extra_data=b"", + prev_randao=Hash(0), + base_fee_per_gas=7, + block_hash=block_hash, + transactions=[Bytes(b"\x01")], + block_access_list=block_access_list, + ) + return FixtureEngineNewPayload( + params=(execution_payload,), + new_payload_version=1, + forkchoice_updated_version=1, + ) + +def _sibling_payload( + *, + gas_used: int = 21_000, + block_access_list: Bytes | None = None, +) -> FixtureEngineNewPayload: + """Build a payload as filled against the test's own genesis.""" + return _payload( + parent_hash=SIBLING_PARENT_HASH, + state_root=Hash(1), + block_hash=Hash(2), + gas_used=gas_used, + block_access_list=block_access_list, + ) -def _write_fixture( + +def _engine_x_payload( + *, + gas_used: int = 21_000, + block_access_list: Bytes | None = None, +) -> FixtureEngineNewPayload: + """Build the same payload as filled against the packed genesis.""" + return _payload( + parent_hash=ENGINE_X_PARENT_HASH, + state_root=Hash(3), + block_hash=Hash(4), + gas_used=gas_used, + block_access_list=block_access_list, + ) + + +def _write_fixture_file(file: Path, fixtures: Dict[str, Any]) -> None: + """Write (or extend) a fixture file with serialized fixtures.""" + file.parent.mkdir(parents=True, exist_ok=True) + existing: Dict[str, Any] = {} + if file.exists(): + existing = json.loads(file.read_text()) + existing.update( + { + test_id: fixture.json_dict_with_info() + for test_id, fixture in fixtures.items() + } + ) + file.write_text(json.dumps(existing)) + + +def _write_sibling( folder: Path, - fixture_dir: str, - test_id: str, - payloads: List[Dict[str, Any]], + payloads: List[FixtureEngineNewPayload], + *, + test_id: str = SIBLING_ID, + file_name: str = "test_a.json", + pre: Alloc | None = None, ) -> None: - """Write a single-fixture file into a format tree.""" - file = folder / fixture_dir / "prague" / "module" / "test_a.json" - file.parent.mkdir(parents=True, exist_ok=True) - file.write_text(json.dumps({test_id: {"engineNewPayloads": payloads}})) + """Write a sibling engine fixture into its format tree.""" + fixture = BlockchainEngineFixture( + fork=Prague, + last_block_hash=Hash(0), + config=FixtureConfig(fork=Prague), + pre=pre if pre is not None else Alloc({SENDER: Account(balance=1)}), + post_state=Alloc({SENDER: Account(balance=1)}), + genesis=_genesis_header(), + payloads=payloads, + ) + _write_fixture_file( + folder / SIBLING_FIXTURES_DIR / "prague" / "module" / file_name, + {test_id: fixture}, + ) + + +def _write_engine_x( + folder: Path, + payloads: List[FixtureEngineNewPayload], + *, + test_id: str = ENGINE_X_ID, + file_name: str = "test_a.json", + pre_hash: str = PRE_HASH, +) -> None: + """Write an Engine X fixture into its format tree.""" + fixture = BlockchainEngineXFixture( + fork=Prague, + last_block_hash=Hash(0), + config=FixtureConfig(fork=Prague), + pre_hash=pre_hash, + post_state_diff=Alloc({}), + payloads=payloads, + ) + _write_fixture_file( + folder / ENGINE_X_FIXTURES_DIR / "prague" / "module" / file_name, + {test_id: fixture}, + ) + + +def _write_group( + folder: Path, + accounts: Dict[Address, Account | None], + test_ids: List[str], + *, + pre_hash: str = PRE_HASH, +) -> None: + """Write a packed pre-alloc group file, as phase 1 would.""" + group_folder = folder / ENGINE_X_FIXTURES_DIR / "pre_alloc" + group_folder.mkdir(parents=True, exist_ok=True) + builder = PreAllocGroupBuilder( + test_ids=test_ids, + environment=Environment( + base_fee_per_gas=7, + excess_blob_gas=0, + blob_gas_used=0, + withdrawals=[], + parent_beacon_block_root=Hash(0), + ), + fork=Prague, + pre=Alloc(accounts), + group_hash=AllocGroupHash(pre_hash), + ) + (group_folder / f"{pre_hash}.json").write_text( + builder.build().model_dump_json(by_alias=True, exclude_none=True) + ) + + +def _bal(*accounts: BalAccountChange) -> Bytes: + """RLP-encode a BAL from account changes.""" + return BlockAccessList(list(accounts)).rlp + + +def _history_write(parent_hash: Hash) -> BalAccountChange: + """Build the EIP-2935 system write of the block's parent hash.""" + return _storage_write( + Address(HISTORY_STORAGE_ADDRESS), + slot=0, + value=int.from_bytes(parent_hash, "big"), + ) + + +def _storage_write( + address: Address, *, slot: int, value: int +) -> BalAccountChange: + """Build a single storage write of an account in a BAL.""" + return BalAccountChange( + address=address, + storage_changes=[ + BalStorageSlot( + slot=slot, + slot_changes=[ + BalStorageChange(block_access_index=0, post_value=value) + ], + ) + ], + ) + + +def _nonce_touch(address: Address) -> BalAccountChange: + """Build a minimal appearance of an account in a BAL.""" + return BalAccountChange( + address=address, + nonce_changes=[BalNonceChange(block_access_index=0, post_nonce=1)], + ) def test_identical_execution_passes(tmp_path: Path) -> None: """State-root-derived differences alone do not trip the check.""" - _write_fixture( + _write_sibling(tmp_path, [_sibling_payload()]) + _write_engine_x(tmp_path, [_engine_x_payload()]) + + result = verify_engine_x_execution(tmp_path) + + assert result.compared == 1 + assert result.skipped == 0 + assert result.skip_reason is None + assert "1 Engine X fixtures execute identically" in result.summary + + +def test_bal_embedded_parent_hash_passes(tmp_path: Path) -> None: + """ + The EIP-2935 write embeds each side's own parent hash in its BAL; a + BAL differing only by that history-contract write does not trip the + check, whatever the leading-zero shape of either hash. + """ + _write_sibling( tmp_path, - SIBLING_FIXTURES_DIR, - SIBLING_ID, - [_payload(gas_used="0x5208", state_root="0x01", block_hash="0x02")], + [ + _sibling_payload( + block_access_list=_bal(_history_write(SIBLING_PARENT_HASH)), + ) + ], ) - _write_fixture( + _write_engine_x( tmp_path, - ENGINE_X_FIXTURES_DIR, - ENGINE_X_ID, - [_payload(gas_used="0x5208", state_root="0xaa", block_hash="0xbb")], + [ + _engine_x_payload( + block_access_list=_bal(_history_write(ENGINE_X_PARENT_HASH)), + ) + ], ) result = verify_engine_x_execution(tmp_path) - assert result is not None assert result.compared == 1 - assert "1 Engine X fixtures execute identically" in result.summary -def test_execution_drift_raises(tmp_path: Path) -> None: - """A gas difference (a leaked account changed execution) fails loudly.""" - _write_fixture( +def test_bal_leaked_account_raises(tmp_path: Path) -> None: + """An account appearing only in the packed BAL fails loudly.""" + _write_sibling( + tmp_path, + [ + _sibling_payload( + block_access_list=_bal(_history_write(SIBLING_PARENT_HASH)), + ) + ], + ) + _write_engine_x( + tmp_path, + [ + _engine_x_payload( + block_access_list=_bal( + _nonce_touch(UNDECLARED_ACCOUNT), + _history_write(ENGINE_X_PARENT_HASH), + ), + ) + ], + ) + + with pytest.raises(EngineXExecutionDriftError) as exc_info: + verify_engine_x_execution(tmp_path) + + message = str(exc_info.value).lower() + assert str(UNDECLARED_ACCOUNT).lower() in message + assert "packed fixture's bal only" in message + + +def test_bal_leak_is_attributed_to_the_group(tmp_path: Path) -> None: + """A leaked account is traced to its packed pre-alloc group.""" + _write_sibling( + tmp_path, + [ + _sibling_payload( + block_access_list=_bal(_history_write(SIBLING_PARENT_HASH)), + ) + ], + ) + _write_engine_x( + tmp_path, + [ + _engine_x_payload( + block_access_list=_bal( + _nonce_touch(UNDECLARED_ACCOUNT), + _history_write(ENGINE_X_PARENT_HASH), + ), + ) + ], + ) + _write_group( + tmp_path, + accounts={ + SENDER: Account(balance=1), + UNDECLARED_ACCOUNT: Account(balance=1), + }, + test_ids=["tests/b.py::test_leaker[fork_Prague-foo]"], + ) + + with pytest.raises(EngineXExecutionDriftError) as exc_info: + verify_engine_x_execution(tmp_path) + + message = str(exc_info.value) + assert PRE_HASH in message + assert "test_leaker" in message + assert 'pre_alloc_group("separate")' in message + + +def test_parent_hash_write_outside_history_contract_raises( + tmp_path: Path, +) -> None: + """ + A test storing its block's parent hash in its own contract is drift: + only the EIP-2935 history-contract write is masked. + """ + _write_sibling( tmp_path, - SIBLING_FIXTURES_DIR, - SIBLING_ID, - [_payload(gas_used="0x5208", state_root="0x01", block_hash="0x02")], + [ + _sibling_payload( + block_access_list=_bal( + _storage_write( + TEST_CONTRACT, + slot=1, + value=int.from_bytes(SIBLING_PARENT_HASH, "big"), + ), + ), + ) + ], ) - _write_fixture( + _write_engine_x( tmp_path, - ENGINE_X_FIXTURES_DIR, - ENGINE_X_ID, - [_payload(gas_used="0xbeef", state_root="0xaa", block_hash="0xbb")], + [ + _engine_x_payload( + block_access_list=_bal( + _storage_write( + TEST_CONTRACT, + slot=1, + value=int.from_bytes(ENGINE_X_PARENT_HASH, "big"), + ), + ), + ) + ], ) + with pytest.raises(EngineXExecutionDriftError) as exc_info: + verify_engine_x_execution(tmp_path) + + message = str(exc_info.value) + assert "writes its block's parent hash" in message + assert "BLOCKHASH" in message + + +def test_malformed_bal_compared_verbatim(tmp_path: Path) -> None: + """An undecodable BAL (negative test) is compared verbatim.""" + garbage = Bytes(b"\xde\xad\xbe\xef") + _write_sibling(tmp_path, [_sibling_payload(block_access_list=garbage)]) + _write_engine_x(tmp_path, [_engine_x_payload(block_access_list=garbage)]) + + result = verify_engine_x_execution(tmp_path) + + assert result.compared == 1 + + +def test_malformed_bal_drift_raises(tmp_path: Path) -> None: + """Differing undecodable BALs fail loudly.""" + _write_sibling( + tmp_path, + [_sibling_payload(block_access_list=Bytes(b"\xde\xad\xbe\xef"))], + ) + _write_engine_x( + tmp_path, + [_engine_x_payload(block_access_list=Bytes(b"\xde\xad\xbe\xee"))], + ) + + with pytest.raises(EngineXExecutionDriftError) as exc_info: + verify_engine_x_execution(tmp_path) + + assert "undecodable" in str(exc_info.value) + + +def test_execution_drift_raises(tmp_path: Path) -> None: + """A gas difference fails loudly and shows both values.""" + _write_sibling(tmp_path, [_sibling_payload()]) + _write_engine_x(tmp_path, [_engine_x_payload(gas_used=0xBEEF)]) + with pytest.raises(EngineXExecutionDriftError) as exc_info: verify_engine_x_execution(tmp_path) message = str(exc_info.value) assert ENGINE_X_ID in message assert "gasUsed" in message + assert "0x5208" in message + assert "0xbeef" in message def test_payload_count_drift_raises(tmp_path: Path) -> None: """A different number of payloads fails loudly.""" - payload = _payload(gas_used="0x5208", state_root="0x01", block_hash="0x02") - _write_fixture(tmp_path, SIBLING_FIXTURES_DIR, SIBLING_ID, [payload]) - _write_fixture( - tmp_path, ENGINE_X_FIXTURES_DIR, ENGINE_X_ID, [payload, payload] - ) + _write_sibling(tmp_path, [_sibling_payload()]) + _write_engine_x(tmp_path, [_engine_x_payload(), _engine_x_payload()]) with pytest.raises(EngineXExecutionDriftError) as exc_info: verify_engine_x_execution(tmp_path) - assert "payload count" in str(exc_info.value) + assert "payload count differs" in str(exc_info.value) -def test_no_sibling_fixtures_skips_check(tmp_path: Path) -> None: - """An Engine X only fill (no sibling format tree) skips the check.""" - _write_fixture( +def test_same_cause_drifts_aggregate(tmp_path: Path) -> None: + """Drifts with the same cause collapse into one diagnosis.""" + _write_sibling(tmp_path, [_sibling_payload()]) + _write_sibling( + tmp_path, + [_sibling_payload()], + test_id=SIBLING_ID_B, + file_name="test_b.json", + ) + _write_engine_x(tmp_path, [_engine_x_payload(gas_used=0xBEEF)]) + _write_engine_x( tmp_path, - ENGINE_X_FIXTURES_DIR, - ENGINE_X_ID, - [_payload(gas_used="0x5208", state_root="0x01", block_hash="0x02")], + [_engine_x_payload(gas_used=0xBEEF)], + test_id=ENGINE_X_ID_B, + file_name="test_b.json", ) - assert verify_engine_x_execution(tmp_path) is None + with pytest.raises(EngineXExecutionDriftError) as exc_info: + verify_engine_x_execution(tmp_path) + message = str(exc_info.value) + assert "2 of 2" in message + assert "1 distinct cause" in message + assert "[2x]" in message + assert ENGINE_X_ID in message + assert ENGINE_X_ID_B in message -def test_no_engine_x_fixtures_skips_check(tmp_path: Path) -> None: - """A fill without Engine X fixtures skips the check.""" - _write_fixture( - tmp_path, - SIBLING_FIXTURES_DIR, - SIBLING_ID, - [_payload(gas_used="0x5208", state_root="0x01", block_hash="0x02")], - ) - assert verify_engine_x_execution(tmp_path) is None +def test_no_sibling_tree_sets_skip_reason(tmp_path: Path) -> None: + """An Engine X only fill (no sibling format tree) skips the check.""" + _write_engine_x(tmp_path, [_engine_x_payload()]) + + result = verify_engine_x_execution(tmp_path) + + assert result.compared == 0 + assert result.skip_reason is not None + assert f"generated no {SIBLING_FIXTURES_DIR}" in result.skip_reason + + +def test_no_engine_x_fixtures_is_silent(tmp_path: Path) -> None: + """A fill without Engine X fixtures has nothing to check or warn.""" + _write_sibling(tmp_path, [_sibling_payload()]) + + result = verify_engine_x_execution(tmp_path) + + assert result.compared == 0 + assert result.skipped == 0 + assert result.skip_reason is None def test_single_fixture_per_file_sibling_lookup(tmp_path: Path) -> None: @@ -144,70 +533,109 @@ def test_single_fixture_per_file_sibling_lookup(tmp_path: Path) -> None: A `--single-fixture-per-file` fill embeds the fixture format name in every file name; the sibling is still found under its own basename. """ - payload = _payload(gas_used="0x5208", state_root="0x01", block_hash="0x02") - sibling_file = ( - tmp_path - / SIBLING_FIXTURES_DIR - / "prague" - / "module" - / "a__fork_Prague_blockchain_test_engine_from_state_test.json" - ) - sibling_file.parent.mkdir(parents=True, exist_ok=True) - sibling_file.write_text( - json.dumps({SIBLING_ID: {"engineNewPayloads": [payload]}}) - ) - engine_x_file = ( - tmp_path - / ENGINE_X_FIXTURES_DIR - / "prague" - / "module" - / "a__fork_Prague_blockchain_test_engine_x_from_state_test.json" - ) - engine_x_file.parent.mkdir(parents=True, exist_ok=True) - engine_x_file.write_text( - json.dumps({ENGINE_X_ID: {"engineNewPayloads": [payload]}}) + _write_sibling( + tmp_path, + [_sibling_payload()], + file_name=( + "a__fork_Prague_blockchain_test_engine_from_state_test.json" + ), + ) + _write_engine_x( + tmp_path, + [_engine_x_payload()], + file_name=( + "a__fork_Prague_blockchain_test_engine_x_from_state_test.json" + ), ) result = verify_engine_x_execution(tmp_path) - assert result is not None assert result.compared == 1 assert result.skipped == 0 def test_missing_sibling_fixture_is_skipped(tmp_path: Path) -> None: """A test filtered from the sibling format is skipped, not failed.""" - payload = _payload(gas_used="0x5208", state_root="0x01", block_hash="0x02") - _write_fixture(tmp_path, SIBLING_FIXTURES_DIR, SIBLING_ID, [payload]) - other_engine_x_id = ENGINE_X_ID.replace("test_a[", "test_b[") - _write_fixture(tmp_path, ENGINE_X_FIXTURES_DIR, ENGINE_X_ID, [payload]) - file = ( - tmp_path / ENGINE_X_FIXTURES_DIR / "prague" / "module" / "test_b.json" - ) - file.write_text( - json.dumps({other_engine_x_id: {"engineNewPayloads": [payload]}}) + _write_sibling(tmp_path, [_sibling_payload()]) + _write_engine_x(tmp_path, [_engine_x_payload()]) + _write_engine_x( + tmp_path, + [_engine_x_payload()], + test_id=ENGINE_X_ID_B, + file_name="test_b.json", ) result = verify_engine_x_execution(tmp_path) - assert result is not None assert result.compared == 1 assert result.skipped == 1 assert "1 skipped" in result.summary -def test_no_matching_siblings_reports_skip_count(tmp_path: Path) -> None: +def test_no_matching_siblings_sets_skip_reason(tmp_path: Path) -> None: """ - Sibling fixtures exist but none match: The check reports the skip + Sibling fixtures exist but none match: the check reports the skip count instead of pretending no siblings were generated. """ - payload = _payload(gas_used="0x5208", state_root="0x01", block_hash="0x02") - other_sibling_id = SIBLING_ID.replace("test_a[", "test_b[") - _write_fixture(tmp_path, SIBLING_FIXTURES_DIR, other_sibling_id, [payload]) - _write_fixture(tmp_path, ENGINE_X_FIXTURES_DIR, ENGINE_X_ID, [payload]) + _write_sibling( + tmp_path, + [_sibling_payload()], + test_id=SIBLING_ID_B, + file_name="test_b.json", + ) + _write_engine_x(tmp_path, [_engine_x_payload()]) result = verify_engine_x_execution(tmp_path) - assert result is not None assert result.compared == 0 assert result.skipped == 1 + assert result.skip_reason is not None + assert "none of the 1" in result.skip_reason + + +def test_unparseable_fixture_raises(tmp_path: Path) -> None: + """A fixture that fails typed validation is a loud error.""" + _write_sibling(tmp_path, [_sibling_payload()]) + file = ( + tmp_path / ENGINE_X_FIXTURES_DIR / "prague" / "module" / "test_a.json" + ) + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(json.dumps({ENGINE_X_ID: {"engineNewPayloads": []}})) + + with pytest.raises(EngineXCheckError, match="cannot parse"): + verify_engine_x_execution(tmp_path) + + +def test_non_fixture_files_are_ignored(tmp_path: Path) -> None: + """`pre_alloc` group files and `.meta` files are not fixtures.""" + _write_sibling(tmp_path, [_sibling_payload()]) + _write_engine_x(tmp_path, [_engine_x_payload()]) + _write_group( + tmp_path, + accounts={SENDER: Account(balance=1)}, + test_ids=[ENGINE_X_ID], + ) + meta = tmp_path / ENGINE_X_FIXTURES_DIR / ".meta" + meta.mkdir(parents=True) + (meta / "index.json").write_text('{"not": "a fixture"}') + + result = verify_engine_x_execution(tmp_path) + + assert result.compared == 1 + assert result.skipped == 0 + + +def test_state_root_derived_fields_exist_on_the_payload_model() -> None: + """The exclusion set must track `FixtureExecutionPayload` renames.""" + model_fields = set(FixtureExecutionPayload.model_fields) + assert STATE_ROOT_DERIVED_FIELDS <= model_fields + assert "block_access_list" in model_fields + + +def test_bal_dump_keys_match_the_masking_walk() -> None: + """The BAL mask walks these keys; they must track the BAL models.""" + account = _history_write(SIBLING_PARENT_HASH).model_dump(mode="json") + assert {"address", "storage_changes"} <= set(account) + slot = account["storage_changes"][0] + assert {"slot", "slot_changes"} <= set(slot) + assert {"block_access_index", "post_value"} <= set(slot["slot_changes"][0]) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip2935.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip2935.py index 64c7d7225ee..0ed2c038f80 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip2935.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip2935.py @@ -136,7 +136,17 @@ def test_bal_2935_empty_block( @pytest.mark.parametrize( "query_block_number,is_valid", [ - pytest.param(0, True, id="valid_block_number"), + pytest.param( + 0, + True, + id="valid_block_number", + marks=pytest.mark.pre_alloc_group( + "separate", + reason="Queries the genesis hash from the history " + "contract and stores it, so the BAL contains the genesis " + "hash itself, which changes under any shared genesis.", + ), + ), pytest.param(1042, False, id="block_number_out_of_range"), ], ) diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_return.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_return.py index cebe282fc29..9b8e0db1733 100644 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_return.py +++ b/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_return.py @@ -26,6 +26,12 @@ ) @pytest.mark.valid_from("Cancun") @pytest.mark.pre_alloc_mutable +@pytest.mark.pre_alloc_group( + "separate", + reason="Calls hardcoded addresses 0x1000 and 0x2000 without declaring " + "them, so gas usage depends on them staying empty; sharing a genesis " + "with a test that allocates either address changes the execution.", +) def test_gas_cost_return( state_test: StateTestFiller, pre: Alloc, From b462ba26e94f743511ec749754352d374838769b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Thu, 3 Sep 2026 11:19:05 +0200 Subject: [PATCH 53/59] refactor(tests): separate the gas window and receipt tests (#3512) --- .../test_state_gas_cross_frame_refund.py | 591 +++++++++++------- 1 file changed, 356 insertions(+), 235 deletions(-) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py index f45be952bde..e2d94a55250 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py @@ -49,17 +49,25 @@ SLOT_PROBE = 6 SLOT_PROBE_RESULT = 7 +# A cold set of a slot that was zero when the transaction began, and +# the warm clear that undoes it. +FRESH_SET = Op.SSTORE.with_metadata( + key_warm=False, original_value=0, current_value=0, new_value=1 +) +WARM_CLEAR = Op.SSTORE.with_metadata( + key_warm=True, original_value=0, current_value=1, new_value=0 +) + -def window_cost_excess(result_sstore: Opcode = Op.SSTORE) -> Bytecode: +def window_cost_excess() -> Bytecode: """ Return code storing the first window's cost over the second's. Memory holds `g0`, `g1` and `g2` at 0, 32 and 64. The stored value is `(g0 - g1) - (g1 - g2)`, the first window's cost minus - the second's, computed modulo 2**256. `result_sstore` lets - gas-settlement tests carry metadata on the storing opcode. + the second's, computed modulo 2**256. """ - return result_sstore( + return FRESH_SET( SLOT_RESULT, Op.SUB( Op.ADD(Op.MLOAD(0), Op.MLOAD(64)), @@ -78,6 +86,58 @@ def deploy_slot_holder(pre: Alloc) -> Address: return pre.deploy_contract(code=Op.SSTORE(SLOT_X, Op.CALLDATASIZE)) +def budget_above_sstore_stipend(fork: Fork, code: Bytecode) -> int: + """ + Return a call budget leaving the child more than the stipend. + + SSTORE refuses to run with only the call stipend left, so a child + that stores needs that much on top of what its code costs. + """ + return fork.call_value_stipend() + 1 + code.gas_cost(fork) + + +def delegation_to( + pre: Alloc, contract: Address +) -> tuple[Address, list[AuthorizationTuple]]: + """Return a signer and the authorization delegating it to `contract`.""" + signer = pre.fund_eoa() + return signer, [ + AuthorizationTuple( + address=contract, + nonce=0, + signer=signer, + creates_account=False, + writes_delegation=True, + ) + ] + + +def clearing_child_code(child_ending: str) -> Bytecode: + """ + Return child code spilling a set, clearing both parent slots, + then ending as `child_ending` says. + """ + body = ( + FRESH_SET(SLOT_MARKER, 1) + + WARM_CLEAR(SLOT_X, 0) + + WARM_CLEAR(SLOT_Y, 0) + ) + if child_ending == "stop": + return body + Op.STOP + if child_ending == "revert": + return body + Op.REVERT(0, 0) + if child_ending == "invalid": + return body + Op.INVALID + raise ValueError(f"unhandled child ending: {child_ending}") + + +def clearing_child_storage(child_ending: str) -> dict[int, int]: + """Return the parent storage a child with this ending leaves behind.""" + if child_ending == "stop": + return {SLOT_X: 0, SLOT_Y: 0, SLOT_MARKER: 1} + return {SLOT_X: 1, SLOT_Y: 1, SLOT_MARKER: 0} + + def clearing_probe_code( set_slot: Bytecode, windows: list[Bytecode] ) -> Bytecode: @@ -169,19 +229,12 @@ def test_parked_credit_returns_at_settlement( intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - clearer_code = Op.SSTORE.with_metadata( - key_warm=True, - original_value=0, - current_value=1, - new_value=0, - )(SLOT_X, 0) + clearer_code = WARM_CLEAR(SLOT_X, 0) clearer = pre.deploy_contract(code=clearer_code) # A budget covering the child's SSTORE stipend sentry through the # clear, so the child succeeds and returns the sentry unspent. - child_budget = ( - fork.call_value_stipend() + 1 + clearer_code.execution_cost(fork) - ) + child_budget = budget_above_sstore_stipend(fork, clearer_code) code = Op.SSTORE( SLOT_X, 1, @@ -231,35 +284,79 @@ def test_parked_credit_funds_state_at_full_price( fork: Fork, ) -> None: """ - Test the parked credit funds a later creation at full price. - - After the cross-frame clear parks the credit, a fresh set draws - its state charge from the reservoir: `gas_left` drops by only the - execution premium across the set window. The receipt still bills - both surviving slots at the full state price, so routing a refund - through another frame buys no discount on state that persists. + Test the sender pays full price for a set the parked refund funded. + + A fresh set spills, a delegated child clears it and the refund + parks in the reservoir, then a second fresh set draws on it. The + surviving slot is billed at the full state price, so routing a + refund through another frame buys no discount on state that + persists. What the set costs `gas_left` is measured in + `test_parked_refund_covers_a_later_set`. """ intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - clearer_code = Op.SSTORE.with_metadata( - key_warm=True, - original_value=0, - current_value=1, - new_value=0, - )(SLOT_X, 0) + clearer_code = WARM_CLEAR(SLOT_X, 0) clearer = pre.deploy_contract(code=clearer_code) - child_budget = ( - fork.call_value_stipend() + 1 + clearer_code.execution_cost(fork) + child_budget = budget_above_sstore_stipend(fork, clearer_code) + + code = ( + FRESH_SET(SLOT_X, 1) + + Op.POP( + Op.DELEGATECALL( + gas=child_budget, address=clearer, address_warm=False + ) + ) + + FRESH_SET(SLOT_Y, 1) ) + contract = pre.deploy_contract(code=code) - fresh_set = Op.SSTORE.with_metadata( - key_warm=False, - original_value=0, - current_value=0, - new_value=1, + # Slot Y survives, fully priced. The cleared slot cancels out of + # the settlement sum. + before_refund = ( + intrinsic_cost + + code.execution_cost(fork) + + clearer_code.execution_cost(fork) + + sstore_state_gas + ) + restore_refund = clearer_code.refund(fork) - sstore_state_gas + expected_gas_used = before_refund - min( + before_refund // fork.max_refund_quotient(), restore_refund ) - window_1 = fresh_set(SLOT_Y, 1) + + tx = Transaction( + to=contract, + state_gas_reservoir=0, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used + ), + ) + + post = {contract: Account(storage={SLOT_X: 0, SLOT_Y: 1})} + state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.valid_from("EIP8037") +def test_parked_refund_covers_a_later_set( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test a set funded by the parked refund costs `gas_left` nothing. + + After the cross-frame clear parks the refund, a fresh set draws + its state charge from the reservoir, so `gas_left` drops by only + the set's execution premium over a warm re-set of the same slot. + What the sender pays for it is checked in + `test_parked_credit_funds_state_at_full_price`. + """ + clearer_code = WARM_CLEAR(SLOT_X, 0) + clearer = pre.deploy_contract(code=clearer_code) + child_budget = budget_above_sstore_stipend(fork, clearer_code) + + window_1 = FRESH_SET(SLOT_Y, 1) window_2 = Op.SSTORE.with_metadata( key_warm=True, original_value=0, @@ -278,7 +375,7 @@ def test_parked_credit_funds_state_at_full_price( code = ( Op.MSTORE(64, 0, new_memory_size=96, old_memory_size=0) - + fresh_set(SLOT_X, 1) + + FRESH_SET(SLOT_X, 1) + Op.POP( Op.DELEGATECALL( gas=child_budget, address=clearer, address_warm=False @@ -289,30 +386,14 @@ def test_parked_credit_funds_state_at_full_price( + Op.MSTORE(32, Op.GAS) + window_2 + Op.MSTORE(64, Op.GAS) - + window_cost_excess(result_sstore=fresh_set) + + window_cost_excess() ) contract = pre.deploy_contract(code=code) - # Slot Y and the result slot survive, each fully priced. The - # cleared slot cancels out of the settlement sum. - before_refund = ( - intrinsic_cost - + code.execution_cost(fork) - + clearer_code.execution_cost(fork) - + 2 * sstore_state_gas - ) - restore_refund = clearer_code.refund(fork) - sstore_state_gas - expected_gas_used = before_refund - min( - before_refund // fork.max_refund_quotient(), restore_refund - ) - tx = Transaction( to=contract, state_gas_reservoir=0, sender=pre.fund_eoa(), - expected_receipt=TransactionReceipt( - cumulative_gas_used=expected_gas_used - ), ) post = { @@ -346,23 +427,12 @@ def test_parked_credit_cannot_fund_execution( intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - clearer_code = Op.SSTORE.with_metadata( - key_warm=True, - original_value=0, - current_value=1, - new_value=0, - )(SLOT_X, 0) + clearer_code = WARM_CLEAR(SLOT_X, 0) clearer = pre.deploy_contract(code=clearer_code) - fresh_set = Op.SSTORE.with_metadata( - key_warm=False, - original_value=0, - current_value=0, - new_value=1, - ) head = ( - fresh_set(SLOT_MARKER, 1) - + fresh_set(SLOT_X, 1) + FRESH_SET(SLOT_MARKER, 1) + + FRESH_SET(SLOT_X, 1) + Op.POP( Op.DELEGATECALL(gas=Op.GAS, address=clearer, address_warm=False) ) @@ -379,9 +449,7 @@ def test_parked_credit_cannot_fund_execution( # A sliver covering the child's SSTORE stipend sentry through the # one-in-64 withholding. It survives the merge unspent. - sliver = ( - fork.call_value_stipend() + 1 + clearer_code.execution_cost(fork) - ) * 64 // 63 + 1 + sliver = budget_above_sstore_stipend(fork, clearer_code) * 64 // 63 + 1 tail_cost = tail.gas_cost(fork) # The tail must overrun the sliver yet fit inside the parked # credit, or the halt stops demonstrating the credit cannot buy @@ -420,85 +488,40 @@ def test_child_clear_repays_own_spill_first( The parent spills two fresh sets; a delegated child spills a set of its own, then clears both parent slots. The first credit repays the child's borrow, the second parks in the reservoir, and a - failing child discards the parked credit with its rollback. + failing child discards the parked refund with its rollback. What + the call costs `gas_left` is measured in + `test_child_clear_window_cost`. """ intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - fresh_set = Op.SSTORE.with_metadata( - key_warm=False, - original_value=0, - current_value=0, - new_value=1, - ) - warm_clear = Op.SSTORE.with_metadata( - key_warm=True, - original_value=0, - current_value=1, - new_value=0, - ) - - child_body = ( - fresh_set(SLOT_MARKER, 1) - + warm_clear(SLOT_X, 0) - + warm_clear(SLOT_Y, 0) - ) - if child_ending == "stop": - child_code = child_body + Op.STOP - elif child_ending == "revert": - child_code = child_body + Op.REVERT(0, 0) - elif child_ending == "invalid": - child_code = child_body + Op.INVALID - else: - raise ValueError(f"unhandled child ending: {child_ending}") + child_code = clearing_child_code(child_ending) child = pre.deploy_contract(code=child_code) + child_budget = budget_above_sstore_stipend(fork, child_code) - # A budget covering the child's SSTORE stipend sentry through its - # own spilled set and both clears. - child_budget = fork.call_value_stipend() + 1 + child_code.gas_cost(fork) - - call_window = Op.POP( - Op.DELEGATECALL(gas=child_budget, address=child, address_warm=False) - ) code = ( - fresh_set(SLOT_X, 1) - + fresh_set(SLOT_Y, 1) - + Op.MSTORE(32, 0, new_memory_size=64, old_memory_size=0) - + Op.MSTORE(0, Op.GAS) - + call_window - + Op.MSTORE(32, Op.GAS) - + fresh_set(SLOT_RESULT, Op.SUB(Op.MLOAD(0), Op.MLOAD(32))) + FRESH_SET(SLOT_X, 1) + + FRESH_SET(SLOT_Y, 1) + + Op.POP( + Op.DELEGATECALL( + gas=child_budget, address=child, address_warm=False + ) + ) ) contract = pre.deploy_contract(code=code) - if child_ending == "stop": - child_consumed = child_code.execution_cost(fork) - elif child_ending == "revert": - child_consumed = child_code.execution_cost(fork) - elif child_ending == "invalid": - child_consumed = child_budget - else: - raise ValueError(f"unhandled child ending: {child_ending}") - # Gas measured between the two reads: the first stamp's store, the - # call window, the child's consumption, and the second read itself. - window_cost = ( - Op.MSTORE(0, Op.GAS).gas_cost(fork) - + call_window.execution_cost(fork) - + child_consumed - ) - parent_exec = code.execution_cost(fork) if child_ending == "stop": - # The child's slot and the result slot survive; the child's - # borrow was repaid by the first clear's credit, so only the - # parked second credit cancels a parent spill at settlement. + # Only the child's own slot survives. Its borrow was repaid by + # the first clear's refund, so the parked second refund cancels + # a parent spill at settlement. before_refund = ( intrinsic_cost + parent_exec + child_code.execution_cost(fork) - + 2 * sstore_state_gas + + sstore_state_gas ) - restore_refund = 2 * (warm_clear.refund(fork) - sstore_state_gas) + restore_refund = 2 * (WARM_CLEAR.refund(fork) - sstore_state_gas) expected_gas_used = before_refund - min( before_refund // fork.max_refund_quotient(), restore_refund ) @@ -507,14 +530,12 @@ def test_child_clear_repays_own_spill_first( intrinsic_cost + parent_exec + child_code.execution_cost(fork) - + 3 * sstore_state_gas + + 2 * sstore_state_gas ) - elif child_ending == "invalid": + else: expected_gas_used = ( - intrinsic_cost + parent_exec + child_budget + 3 * sstore_state_gas + intrinsic_cost + parent_exec + child_budget + 2 * sstore_state_gas ) - else: - raise ValueError(f"unhandled child ending: {child_ending}") tx = Transaction( to=contract, @@ -525,92 +546,103 @@ def test_child_clear_repays_own_spill_first( ), ) - if child_ending == "stop": - storage = { - SLOT_X: 0, - SLOT_Y: 0, - SLOT_MARKER: 1, - SLOT_RESULT: window_cost, - } - elif child_ending in ("revert", "invalid"): - storage = { - SLOT_X: 1, - SLOT_Y: 1, - SLOT_MARKER: 0, - SLOT_RESULT: window_cost, - } - else: - raise ValueError(f"unhandled child ending: {child_ending}") - - post = {contract: Account(storage=storage)} + post = {contract: Account(storage=clearing_child_storage(child_ending))} state_test(pre=pre, post=post, tx=tx) +@pytest.mark.parametrize("child_ending", ["stop", "revert", "invalid"]) @pytest.mark.valid_from("EIP8037") -def test_cross_frame_refund_after_delegation_spill( +def test_child_clear_window_cost( state_test: StateTestFiller, pre: Alloc, fork: Fork, + child_ending: str, ) -> None: """ - Test a cross-frame refund after the sender's delegation spilled. + Test the clearing call costs `gas_left` its execution and no more. - A set-code transaction with an empty reservoir pays its delegation - from `gas_left` and commits that spill before the code runs. The - code spills a fresh set and a delegated child clears it. The call - costs the same as without the delegation and the delegation stays - billed. + The same shape as `test_child_clear_repays_own_spill_first`, with + the call bracketed by two `GAS` reads. Neither the refund the + child parks nor the spill it repays reaches `gas_left`, so the + window costs the call and the child's execution alone. What the + sender pays is checked there. """ - fresh_set = Op.SSTORE.with_metadata( - key_warm=False, - original_value=0, - current_value=0, - new_value=1, - ) - warm_clear = Op.SSTORE.with_metadata( - key_warm=True, - original_value=0, - current_value=1, - new_value=0, - ) - - child_code = warm_clear(SLOT_X, 0) + child_code = clearing_child_code(child_ending) child = pre.deploy_contract(code=child_code) - # SSTORE needs more than the call stipend left, so give the child - # that much on top of its cost. - child_budget = fork.call_value_stipend() + 1 + child_code.gas_cost(fork) + child_budget = budget_above_sstore_stipend(fork, child_code) - call = Op.POP( + call_window = Op.POP( Op.DELEGATECALL(gas=child_budget, address=child, address_warm=False) ) code = ( - Op.MSTORE(32, 0, new_memory_size=64, old_memory_size=0) - + fresh_set(SLOT_X, 1) + FRESH_SET(SLOT_X, 1) + + FRESH_SET(SLOT_Y, 1) + + Op.MSTORE(32, 0, new_memory_size=64, old_memory_size=0) + Op.MSTORE(0, Op.GAS) - + call + + call_window + Op.MSTORE(32, Op.GAS) - + fresh_set(SLOT_RESULT, Op.SUB(Op.MLOAD(0), Op.MLOAD(32))) + + FRESH_SET(SLOT_RESULT, Op.SUB(Op.MLOAD(0), Op.MLOAD(32))) ) contract = pre.deploy_contract(code=code) - signer = pre.fund_eoa() - authorization_list = [ - AuthorizationTuple( - address=contract, - nonce=0, - signer=signer, - creates_account=False, - writes_delegation=True, + # An invalid child burns its whole budget; the others stop at the + # end of their code. + child_consumed = ( + child_budget + if child_ending == "invalid" + else child_code.execution_cost(fork) + ) + # Gas measured between the two reads: the first stamp's store, the + # call window, the child's consumption, and the second read itself. + window_cost = ( + Op.MSTORE(0, Op.GAS).gas_cost(fork) + + call_window.execution_cost(fork) + + child_consumed + ) + + tx = Transaction( + to=contract, + state_gas_reservoir=0, + sender=pre.fund_eoa(), + ) + + post = { + contract: Account( + storage={ + **clearing_child_storage(child_ending), + SLOT_RESULT: window_cost, + } ) - ] + } + state_test(pre=pre, post=post, tx=tx) - # The window runs from one GAS read to the next: the store of the - # first read, the call and the second read. - call_cost = ( - Op.MSTORE(0, Op.GAS).gas_cost(fork) - + call.execution_cost(fork) - + child_code.execution_cost(fork) + +@pytest.mark.valid_from("EIP8037") +def test_cross_frame_refund_after_delegation_spill( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test a cross-frame refund after the sender's delegation spilled. + + A set-code transaction with an empty reservoir pays its delegation + from `gas_left` and commits that spill before the code runs. The + code spills a fresh set and a delegated child clears it. The + delegation stays billed: the refund does not reach the committed + spill. What the call costs `gas_left` is measured in + `test_delegation_spill_window_cost`. + """ + child_code = WARM_CLEAR(SLOT_X, 0) + child = pre.deploy_contract(code=child_code) + child_budget = budget_above_sstore_stipend(fork, child_code) + + code = FRESH_SET(SLOT_X, 1) + Op.POP( + Op.DELEGATECALL(gas=child_budget, address=child, address_warm=False) ) + contract = pre.deploy_contract(code=code) + + signer, authorization_list = delegation_to(pre, contract) gas_used = ( fork.transaction_intrinsic_cost_calculator()( @@ -638,6 +670,62 @@ def test_cross_frame_refund_after_delegation_spill( expected_receipt=TransactionReceipt(cumulative_gas_used=gas_used), ) + post = { + contract: Account(storage={SLOT_X: 0}), + signer: Account(code=Spec7702.delegation_designation(contract)), + } + state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.valid_from("EIP8037") +def test_delegation_spill_window_cost( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test the clearing call costs the same after a delegation spilled. + + The same shape as `test_cross_frame_refund_after_delegation_spill`, + with the call bracketed by two `GAS` reads. The refund the child + merges reaches neither `gas_left` nor the committed spill, so the + window costs the call and the child's execution alone. What the + sender pays is checked there. + """ + child_code = WARM_CLEAR(SLOT_X, 0) + child = pre.deploy_contract(code=child_code) + child_budget = budget_above_sstore_stipend(fork, child_code) + + call = Op.POP( + Op.DELEGATECALL(gas=child_budget, address=child, address_warm=False) + ) + code = ( + Op.MSTORE(32, 0, new_memory_size=64, old_memory_size=0) + + FRESH_SET(SLOT_X, 1) + + Op.MSTORE(0, Op.GAS) + + call + + Op.MSTORE(32, Op.GAS) + + FRESH_SET(SLOT_RESULT, Op.SUB(Op.MLOAD(0), Op.MLOAD(32))) + ) + contract = pre.deploy_contract(code=code) + + signer, authorization_list = delegation_to(pre, contract) + + # The window runs from one GAS read to the next: the store of the + # first read, the call and the second read. + call_cost = ( + Op.MSTORE(0, Op.GAS).gas_cost(fork) + + call.execution_cost(fork) + + child_code.execution_cost(fork) + ) + + tx = Transaction( + to=contract, + authorization_list=authorization_list, + state_gas_reservoir=0, + sender=pre.fund_eoa(), + ) + post = { contract: Account(storage={SLOT_X: 0, SLOT_RESULT: call_cost}), signer: Account(code=Spec7702.delegation_designation(contract)), @@ -654,48 +742,89 @@ def test_cross_frame_refund_with_reservoir_grant( reservoir_slots: int, ) -> None: """ - Test a cross-frame refund with a reservoir the sender paid for. - - The reservoir covers none, one or both of the parent's two sets and - the rest spill. A delegated child clears both slots. The call and a - later set cost the same in every case: the refund stays in the - reservoir and the spill is not repaid. + Test the receipt is the same however much reservoir was bought. + + The reservoir covers none, one or both of the parent's two sets + and the rest spill. A delegated child clears both slots and a + later set draws on whatever is left. The receipt is the same in + every case, so buying reservoir up front costs the sender nothing + and saves nothing. What the call and the set cost `gas_left` is + measured in `test_reservoir_grant_window_costs`. """ - sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) - fresh_set = Op.SSTORE.with_metadata( - key_warm=False, - original_value=0, - current_value=0, - new_value=1, + child_code = WARM_CLEAR(SLOT_X, 0) + WARM_CLEAR(SLOT_Y, 0) + child = pre.deploy_contract(code=child_code) + child_budget = budget_above_sstore_stipend(fork, child_code) + + code = ( + FRESH_SET(SLOT_X, 1) + + FRESH_SET(SLOT_Y, 1) + + Op.POP( + Op.DELEGATECALL( + gas=child_budget, address=child, address_warm=False + ) + ) + + FRESH_SET(SLOT_PROBE, 1) ) - warm_clear = Op.SSTORE.with_metadata( - key_warm=True, - original_value=0, - current_value=1, - new_value=0, + contract = pre.deploy_contract(code=code) + + gas_used = ( + fork.transaction_intrinsic_cost_calculator()() + + code.gas_cost(fork) + + child_code.gas_cost(fork) + - child_code.state_refund(fork) + ) + refund = child_code.refund(fork) - child_code.state_refund(fork) + gas_used -= min(gas_used // fork.max_refund_quotient(), refund) + + tx = Transaction( + to=contract, + state_gas_reservoir=( + reservoir_slots * Op.SSTORE(new_value=1).state_cost(fork) + ), + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt(cumulative_gas_used=gas_used), ) - child_code = warm_clear(SLOT_X, 0) + warm_clear(SLOT_Y, 0) + post = {contract: Account(storage={SLOT_X: 0, SLOT_Y: 0, SLOT_PROBE: 1})} + state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.parametrize("reservoir_slots", [0, 1, 2]) +@pytest.mark.valid_from("EIP8037") +def test_reservoir_grant_window_costs( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + reservoir_slots: int, +) -> None: + """ + Test a reservoir grant changes neither window's cost. + + The same shape as `test_cross_frame_refund_with_reservoir_grant`, + with the call and a later set each bracketed by `GAS` reads. Both + windows cost the same however much of the parent's two sets the + reservoir covered: the refund stays in the reservoir and the spill + is not repaid. What the sender pays is checked there. + """ + child_code = WARM_CLEAR(SLOT_X, 0) + WARM_CLEAR(SLOT_Y, 0) child = pre.deploy_contract(code=child_code) - # SSTORE needs more than the call stipend left, so give the child - # that much on top of its cost. - child_budget = fork.call_value_stipend() + 1 + child_code.gas_cost(fork) + child_budget = budget_above_sstore_stipend(fork, child_code) call = Op.POP( Op.DELEGATECALL(gas=child_budget, address=child, address_warm=False) ) - probe = fresh_set(SLOT_PROBE, 1) + probe = FRESH_SET(SLOT_PROBE, 1) code = ( Op.MSTORE(64, 0, new_memory_size=96, old_memory_size=0) - + fresh_set(SLOT_X, 1) - + fresh_set(SLOT_Y, 1) + + FRESH_SET(SLOT_X, 1) + + FRESH_SET(SLOT_Y, 1) + Op.MSTORE(0, Op.GAS) + call + Op.MSTORE(32, Op.GAS) + probe + Op.MSTORE(64, Op.GAS) - + fresh_set(SLOT_RESULT, Op.SUB(Op.MLOAD(0), Op.MLOAD(32))) - + fresh_set(SLOT_PROBE_RESULT, Op.SUB(Op.MLOAD(32), Op.MLOAD(64))) + + FRESH_SET(SLOT_RESULT, Op.SUB(Op.MLOAD(0), Op.MLOAD(32))) + + FRESH_SET(SLOT_PROBE_RESULT, Op.SUB(Op.MLOAD(32), Op.MLOAD(64))) ) contract = pre.deploy_contract(code=code) @@ -709,20 +838,12 @@ def test_cross_frame_refund_with_reservoir_grant( ) probe_cost = stamp_cost + probe.execution_cost(fork) - gas_used = ( - fork.transaction_intrinsic_cost_calculator()() - + code.gas_cost(fork) - + child_code.gas_cost(fork) - - child_code.state_refund(fork) - ) - refund = child_code.refund(fork) - child_code.state_refund(fork) - gas_used -= min(gas_used // fork.max_refund_quotient(), refund) - tx = Transaction( to=contract, - state_gas_reservoir=reservoir_slots * sstore_state_gas, + state_gas_reservoir=( + reservoir_slots * Op.SSTORE(new_value=1).state_cost(fork) + ), sender=pre.fund_eoa(), - expected_receipt=TransactionReceipt(cumulative_gas_used=gas_used), ) post = { From 1d340aa183b5902873fdbeb99b43bd3e1b3f6d3a Mon Sep 17 00:00:00 2001 From: spencer Date: Thu, 3 Sep 2026 12:31:41 +0200 Subject: [PATCH 54/59] feat(tooling): share agent skills across Codex and Claude (#3514) Co-authored-by: danceratopz --- .../skills/assess-eip/SKILL.md | 5 ++ .../skills/audit-config/SKILL.md | 20 ++++- .../skills/edit-workflow/SKILL.md | 5 ++ .../skills/eip-checklist/SKILL.md | 5 ++ .../skills/enhance-ported-test/SKILL.md | 8 +- .../skills/fill-tests/SKILL.md | 5 ++ .../skills/grammar-check/SKILL.md | 5 ++ .../skills/implement-eip/SKILL.md | 7 +- .../lint.md => .agents/skills/lint/SKILL.md | 5 ++ .../skills/pytester/SKILL.md | 5 ++ .../skills/write-docstring/SKILL.md | 5 ++ .../skills/write-test/SKILL.md | 5 ++ .claude/skills/assess-eip | 1 + .claude/skills/audit-config | 1 + .claude/skills/edit-workflow | 1 + .claude/skills/eip-checklist | 1 + .claude/skills/enhance-ported-test | 1 + .claude/skills/fill-tests | 1 + .claude/skills/grammar-check | 1 + .claude/skills/implement-eip | 1 + .claude/skills/lint | 1 + .claude/skills/pytester | 1 + .claude/skills/write-docstring | 1 + .claude/skills/write-test | 1 + AGENTS.md | 77 +++++++++++++++++++ CLAUDE.md | 71 +---------------- 26 files changed, 165 insertions(+), 75 deletions(-) rename .claude/commands/assess-eip.md => .agents/skills/assess-eip/SKILL.md (95%) rename .claude/commands/audit-config.md => .agents/skills/audit-config/SKILL.md (57%) rename .claude/commands/edit-workflow.md => .agents/skills/edit-workflow/SKILL.md (92%) rename .claude/commands/eip-checklist.md => .agents/skills/eip-checklist/SKILL.md (94%) rename .claude/commands/enhance-ported-test.md => .agents/skills/enhance-ported-test/SKILL.md (99%) rename .claude/commands/fill-tests.md => .agents/skills/fill-tests/SKILL.md (96%) rename .claude/commands/grammar-check.md => .agents/skills/grammar-check/SKILL.md (94%) rename .claude/commands/implement-eip.md => .agents/skills/implement-eip/SKILL.md (96%) rename .claude/commands/lint.md => .agents/skills/lint/SKILL.md (92%) rename .claude/commands/pytester.md => .agents/skills/pytester/SKILL.md (94%) rename .claude/commands/write-docstring.md => .agents/skills/write-docstring/SKILL.md (98%) rename .claude/commands/write-test.md => .agents/skills/write-test/SKILL.md (98%) create mode 120000 .claude/skills/assess-eip create mode 120000 .claude/skills/audit-config create mode 120000 .claude/skills/edit-workflow create mode 120000 .claude/skills/eip-checklist create mode 120000 .claude/skills/enhance-ported-test create mode 120000 .claude/skills/fill-tests create mode 120000 .claude/skills/grammar-check create mode 120000 .claude/skills/implement-eip create mode 120000 .claude/skills/lint create mode 120000 .claude/skills/pytester create mode 120000 .claude/skills/write-docstring create mode 120000 .claude/skills/write-test create mode 100644 AGENTS.md mode change 100644 => 120000 CLAUDE.md diff --git a/.claude/commands/assess-eip.md b/.agents/skills/assess-eip/SKILL.md similarity index 95% rename from .claude/commands/assess-eip.md rename to .agents/skills/assess-eip/SKILL.md index 1144a9fb93f..55a252d6653 100644 --- a/.claude/commands/assess-eip.md +++ b/.agents/skills/assess-eip/SKILL.md @@ -1,3 +1,8 @@ +--- +name: assess-eip +description: Assess an EIP's implementation complexity and scope. +--- + # Assess EIP Structured assessment of EIP implementation complexity. When invoked with an EIP number or description, perform the following analysis. diff --git a/.claude/commands/audit-config.md b/.agents/skills/audit-config/SKILL.md similarity index 57% rename from .claude/commands/audit-config.md rename to .agents/skills/audit-config/SKILL.md index 932074ac9ab..9186bc616a3 100644 --- a/.claude/commands/audit-config.md +++ b/.agents/skills/audit-config/SKILL.md @@ -1,12 +1,25 @@ +--- +name: audit-config +description: Check whether repository guidance and skills are still accurate. +--- + # Audit Config -Periodic verification skill to prevent CLAUDE.md and skills from going stale. Run this manually to check freshness (e.g., after a major refactor, before a release, or when onboarding). +Periodic verification skill to prevent `AGENTS.md` and skills from going stale. +Run this manually to check freshness (e.g., after a major refactor, before a +release, or when onboarding). ## Checks to Perform ### 1. Verify File Paths -Check that every file path or directory referenced in `CLAUDE.md` and `.claude/commands/*.md` still exists. Report any broken references. +Check that every file path or directory referenced in `AGENTS.md` and +`.agents/skills/*/SKILL.md` still exists. Report any broken references. + +Confirm that `CLAUDE.md` is a symlink to `AGENTS.md`. Confirm that every +`.agents/skills//` directory has a corresponding +`.claude/skills/` symlink that points back to it, and that there are no +orphaned Claude skill links. ### 2. Verify CLI Commands @@ -28,7 +41,8 @@ Spot-check code patterns mentioned in skills against actual code: ### 4. Verify Fork List -Check that the fork order and default branch mentioned in `CLAUDE.md` match reality by inspecting `src/ethereum/forks/` and git branch configuration. +Check that the fork order and default branch mentioned in `AGENTS.md` match +reality by inspecting `src/ethereum/forks/` and git branch configuration. ### 5. Verify Docs References diff --git a/.claude/commands/edit-workflow.md b/.agents/skills/edit-workflow/SKILL.md similarity index 92% rename from .claude/commands/edit-workflow.md rename to .agents/skills/edit-workflow/SKILL.md index 507e4efdfc2..2398337680d 100644 --- a/.claude/commands/edit-workflow.md +++ b/.agents/skills/edit-workflow/SKILL.md @@ -1,3 +1,8 @@ +--- +name: edit-workflow +description: Apply repository conventions when editing GitHub Actions workflows. +--- + # Edit Workflow GitHub Actions conventions. Run this skill before modifying workflow files in `.github/`. diff --git a/.claude/commands/eip-checklist.md b/.agents/skills/eip-checklist/SKILL.md similarity index 94% rename from .claude/commands/eip-checklist.md rename to .agents/skills/eip-checklist/SKILL.md index 68170ca545d..7cb0313e8d8 100644 --- a/.claude/commands/eip-checklist.md +++ b/.agents/skills/eip-checklist/SKILL.md @@ -1,3 +1,8 @@ +--- +name: eip-checklist +description: Track EIP test coverage with the repository checklist system. +--- + # EIP Checklist Guide for using the EIP testing checklist system to track test coverage. Run this skill when working on EIP test coverage or checklists. diff --git a/.claude/commands/enhance-ported-test.md b/.agents/skills/enhance-ported-test/SKILL.md similarity index 99% rename from .claude/commands/enhance-ported-test.md rename to .agents/skills/enhance-ported-test/SKILL.md index e14b22289db..1c02d262328 100644 --- a/.claude/commands/enhance-ported-test.md +++ b/.agents/skills/enhance-ported-test/SKILL.md @@ -1,3 +1,8 @@ +--- +name: enhance-ported-test +description: Clean up and future-proof a ported static test. +--- + # Enhance Ported Test Future-proof and clean up a test under `tests/ported_static/`. These tests were @@ -125,7 +130,8 @@ to keep it. ### 2. Remove `gas_limit` from the transaction (if gas is not the subject) This is the common case and belongs early. Omitting `gas_limit` maxes out the -gas the tx receives, so the body executes fully. See `write-test.md` "Transactions". +gas the tx receives, so the body executes fully. See the [Transactions section] +(../write-test/SKILL.md#transactions) of the `write-test` skill. - **Remove it** when the test is about *behavior* and just needs to run to completion. This also lets you delete any per-fork gas band-aids (e.g. `fork.is_eip_enabled(8037)` budget bumps) and often the `fork` param itself. diff --git a/.claude/commands/fill-tests.md b/.agents/skills/fill-tests/SKILL.md similarity index 96% rename from .claude/commands/fill-tests.md rename to .agents/skills/fill-tests/SKILL.md index 909ee26fe3a..5a2f9b77b93 100644 --- a/.claude/commands/fill-tests.md +++ b/.agents/skills/fill-tests/SKILL.md @@ -1,3 +1,8 @@ +--- +name: fill-tests +description: Fill test fixtures with the repository fill command. +--- + # Fill Tests CLI reference for the `fill` command. Run this skill before filling test fixtures. The `fill` command is pytest-based — all standard pytest flags work. diff --git a/.claude/commands/grammar-check.md b/.agents/skills/grammar-check/SKILL.md similarity index 94% rename from .claude/commands/grammar-check.md rename to .agents/skills/grammar-check/SKILL.md index 5f55a6eaa51..540a6cb6c74 100644 --- a/.claude/commands/grammar-check.md +++ b/.agents/skills/grammar-check/SKILL.md @@ -1,3 +1,8 @@ +--- +name: grammar-check +description: Audit grammar in documentation and code comments. +--- + # Grammar Check Audit grammar in documentation and code comments. diff --git a/.claude/commands/implement-eip.md b/.agents/skills/implement-eip/SKILL.md similarity index 96% rename from .claude/commands/implement-eip.md rename to .agents/skills/implement-eip/SKILL.md index f42d9361f25..bc0831b2496 100644 --- a/.claude/commands/implement-eip.md +++ b/.agents/skills/implement-eip/SKILL.md @@ -1,3 +1,8 @@ +--- +name: implement-eip +description: Implement EIP specification changes using repository conventions. +--- + # Implement EIP Patterns for implementing spec changes in `src/ethereum/forks/`. Run this skill before implementing an EIP or modifying fork code. @@ -10,7 +15,7 @@ Each fork lives at `src/ethereum/forks//`. Explore the latest fork di - `fork.py` — state transition functions - `blocks.py` — block structure and validation - `transactions.py` — transaction types and processing -- `state.py` — state trie operations +- `state_tracker.py` — fork-specific state tracking - `vm/instructions/__init__.py` — Ops enum + `op_implementation` dict - `vm/gas.py` — gas constants and calculations - `vm/precompiled_contracts/__init__.py` — precompile address constants diff --git a/.claude/commands/lint.md b/.agents/skills/lint/SKILL.md similarity index 92% rename from .claude/commands/lint.md rename to .agents/skills/lint/SKILL.md index bc5f3b12f84..786bcdac22c 100644 --- a/.claude/commands/lint.md +++ b/.agents/skills/lint/SKILL.md @@ -1,3 +1,8 @@ +--- +name: lint +description: Run and fix the repository static analysis suite. +--- + # Lint Run the full static analysis suite and fix issues. This matches the CI check on every PR. diff --git a/.claude/commands/pytester.md b/.agents/skills/pytester/SKILL.md similarity index 94% rename from .claude/commands/pytester.md rename to .agents/skills/pytester/SKILL.md index a1d23d78303..f3394f42980 100644 --- a/.claude/commands/pytester.md +++ b/.agents/skills/pytester/SKILL.md @@ -1,3 +1,8 @@ +--- +name: pytester +description: Write and run isolated pytester-based plugin tests. +--- + # Pytester Guide for pytester-based plugin/CLI tests. Run before writing or modifying these tests. diff --git a/.claude/commands/write-docstring.md b/.agents/skills/write-docstring/SKILL.md similarity index 98% rename from .claude/commands/write-docstring.md rename to .agents/skills/write-docstring/SKILL.md index 2492cb0b6ff..f3b58da192c 100644 --- a/.claude/commands/write-docstring.md +++ b/.agents/skills/write-docstring/SKILL.md @@ -1,3 +1,8 @@ +--- +name: write-docstring +description: Write specification docstrings using repository conventions. +--- + # Write Docstring Conventions for writing docstrings in `src/ethereum/`. Docstrings are the primary prose of the specification — they read as a narrative explaining how Ethereum works, not as traditional Python API documentation. They are rendered into HTML by docc, which parses them as **Markdown** (via mistletoe). Run this skill before writing or modifying docstrings. diff --git a/.claude/commands/write-test.md b/.agents/skills/write-test/SKILL.md similarity index 98% rename from .claude/commands/write-test.md rename to .agents/skills/write-test/SKILL.md index 786fadae24c..0eee5918221 100644 --- a/.claude/commands/write-test.md +++ b/.agents/skills/write-test/SKILL.md @@ -1,3 +1,8 @@ +--- +name: write-test +description: Write consensus tests using repository patterns and fixtures. +--- + # Write Test Conventions and patterns for writing consensus tests. Run this skill before writing or modifying tests. diff --git a/.claude/skills/assess-eip b/.claude/skills/assess-eip new file mode 120000 index 00000000000..63eae474352 --- /dev/null +++ b/.claude/skills/assess-eip @@ -0,0 +1 @@ +../../.agents/skills/assess-eip \ No newline at end of file diff --git a/.claude/skills/audit-config b/.claude/skills/audit-config new file mode 120000 index 00000000000..bfc79c98f11 --- /dev/null +++ b/.claude/skills/audit-config @@ -0,0 +1 @@ +../../.agents/skills/audit-config \ No newline at end of file diff --git a/.claude/skills/edit-workflow b/.claude/skills/edit-workflow new file mode 120000 index 00000000000..e2e8b3d3c5f --- /dev/null +++ b/.claude/skills/edit-workflow @@ -0,0 +1 @@ +../../.agents/skills/edit-workflow \ No newline at end of file diff --git a/.claude/skills/eip-checklist b/.claude/skills/eip-checklist new file mode 120000 index 00000000000..d3df71e6b1d --- /dev/null +++ b/.claude/skills/eip-checklist @@ -0,0 +1 @@ +../../.agents/skills/eip-checklist \ No newline at end of file diff --git a/.claude/skills/enhance-ported-test b/.claude/skills/enhance-ported-test new file mode 120000 index 00000000000..39d4b3e3c23 --- /dev/null +++ b/.claude/skills/enhance-ported-test @@ -0,0 +1 @@ +../../.agents/skills/enhance-ported-test \ No newline at end of file diff --git a/.claude/skills/fill-tests b/.claude/skills/fill-tests new file mode 120000 index 00000000000..f16006337a9 --- /dev/null +++ b/.claude/skills/fill-tests @@ -0,0 +1 @@ +../../.agents/skills/fill-tests \ No newline at end of file diff --git a/.claude/skills/grammar-check b/.claude/skills/grammar-check new file mode 120000 index 00000000000..298e6d0afeb --- /dev/null +++ b/.claude/skills/grammar-check @@ -0,0 +1 @@ +../../.agents/skills/grammar-check \ No newline at end of file diff --git a/.claude/skills/implement-eip b/.claude/skills/implement-eip new file mode 120000 index 00000000000..24538dd1344 --- /dev/null +++ b/.claude/skills/implement-eip @@ -0,0 +1 @@ +../../.agents/skills/implement-eip \ No newline at end of file diff --git a/.claude/skills/lint b/.claude/skills/lint new file mode 120000 index 00000000000..39815fa7842 --- /dev/null +++ b/.claude/skills/lint @@ -0,0 +1 @@ +../../.agents/skills/lint \ No newline at end of file diff --git a/.claude/skills/pytester b/.claude/skills/pytester new file mode 120000 index 00000000000..764f4886d66 --- /dev/null +++ b/.claude/skills/pytester @@ -0,0 +1 @@ +../../.agents/skills/pytester \ No newline at end of file diff --git a/.claude/skills/write-docstring b/.claude/skills/write-docstring new file mode 120000 index 00000000000..a4c44a7e4e2 --- /dev/null +++ b/.claude/skills/write-docstring @@ -0,0 +1 @@ +../../.agents/skills/write-docstring \ No newline at end of file diff --git a/.claude/skills/write-test b/.claude/skills/write-test new file mode 120000 index 00000000000..b03008d3452 --- /dev/null +++ b/.claude/skills/write-test @@ -0,0 +1 @@ +../../.agents/skills/write-test \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..a8f64c30192 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,77 @@ +# AGENTS.md + +Ethereum Execution Layer Specification written in Python. This is a **specification**, not production code — readability over performance. + +## Tooling + +- **uv** is the package manager. **just** is the command runner (`just --list`). +- The `execution_testing` package under `packages/testing/` is a UV workspace member. + +## Linting + +When done with changes, ask the user if they'd like to run `/lint` before committing. Don't skip this unless the user explicitly says to. + +## Code Style + +- 79 char lines, strict mypy, `pathlib` over `os.path` +- `snake_case` for variables/functions, `PascalCase` for classes, `UPPER_CASE` for constants +- Docstrings: imperative mood ("Return" not "Returns"), blank line after summary for multi-line +- Descriptive English names — avoid EIP numbers in identifiers +- Custom spell-check dictionary: `whitelist.txt` + +## Architecture + +- Each fork under `src/ethereum/forks/` is a **complete copy** of its predecessor (WET principle). Do NOT abstract across forks. +- Import isolation (enforced by `ethereum-spec-lint`): relative imports within a fork, absolute from previous fork only, shared modules (`ethereum.crypto`, `ethereum.utils`) always OK. Never import from future or ancient (2+ back) forks. + +## Branches + +- **There is no `main` branch.** Default branch = most active fork (currently `forks/amsterdam`). Run `git remote show origin | grep HEAD` to check. +- `mainnet` = stable specs for forks live on mainnet +- PRs target the default branch +- PRs strictly follow the template in `.github/PULL_REQUEST_TEMPLATE.md`. +- Never add Claude attribution links (`Claude-Session:` trailers or `claude.ai` URLs) to commit messages or PR descriptions. + +## PR Reviews + +Reviews are strictly read-only. Never submit a GitHub review, post review +findings as a PR or issue comment, approve, request changes, or react to review +threads. Return findings in chat or write them to a local draft instead. + +When reviewing PRs that implement or test EIPs: + +1. Identify the EIP number(s) from the branch name, PR title, or changed file paths +2. Fetch each EIP spec from `https://eips.ethereum.org/EIPS/eip-` before starting the review +3. Verify the implementation matches the EIP's specification requirements + +## When to Use Skills + +The skills below are canonical under `.agents/skills/`. Claude exposes the same +skills as `/name` commands through symlinked folders in `.claude/skills/`. + +- Writing or modifying tests → run `/write-test` first +- Cleaning up or future-proofing a `tests/ported_static/` test → run `/enhance-ported-test` first +- Writing or modifying pytester-based plugin tests → run `/pytester` first +- Filling test fixtures → run `/fill-tests` first +- Implementing an EIP or modifying fork code in `src/` → run `/implement-eip` first +- Modifying GitHub Actions workflows → run `/edit-workflow` first +- Assessing EIP complexity or scope → run `/assess-eip` +- Working on EIP test coverage or checklists → run `/eip-checklist` first +- Checking if config/skills are stale → run `/audit-config` +- Writing or modifying docstrings in `src/ethereum/` → run `/write-docstring` first +- Done with changes and ready to lint → run `/lint` + +## Available Skills + +- `/write-test` — test writing patterns, fixtures, markers, bytecode helpers +- `/enhance-ported-test` — ordered methodology to clean up & future-proof `tests/ported_static/` tests +- `/pytester` — pytester execution modes, isolation, output handling for plugin tests +- `/fill-tests` — `fill` CLI reference, flags, debugging, benchmark tests +- `/implement-eip` — fork structure, import rules, adding opcodes/precompiles/tx types +- `/edit-workflow` — GitHub Actions conventions and version pinning +- `/assess-eip` — structured EIP complexity assessment +- `/eip-checklist` — EIP testing checklist system for tracking coverage +- `/lint` — full static analysis suite with auto-fix workflow +- `/audit-config` — verify AGENTS.md and skills are still accurate +- `/write-docstring` — narrative Markdown docstring conventions for the spec +- `/grammar-check` — audit grammar in documentation and code comments diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 809c5043f86..00000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,70 +0,0 @@ -# CLAUDE.md - -Ethereum Execution Layer Specification written in Python. This is a **specification**, not production code — readability over performance. - -## Tooling - -- **uv** is the package manager. **just** is the command runner (`just --list`). -- The `execution_testing` package under `packages/testing/` is a UV workspace member. - -## Linting - -When done with changes, ask the user if they'd like to run `/lint` before committing. Don't skip this unless the user explicitly says to. - -## Code Style - -- 79 char lines, strict mypy, `pathlib` over `os.path` -- `snake_case` for variables/functions, `PascalCase` for classes, `UPPER_CASE` for constants -- Docstrings: imperative mood ("Return" not "Returns"), blank line after summary for multi-line -- Descriptive English names — avoid EIP numbers in identifiers -- Custom spell-check dictionary: `whitelist.txt` - -## Architecture - -- Each fork under `src/ethereum/forks/` is a **complete copy** of its predecessor (WET principle). Do NOT abstract across forks. -- Import isolation (enforced by `ethereum-spec-lint`): relative imports within a fork, absolute from previous fork only, shared modules (`ethereum.crypto`, `ethereum.utils`) always OK. Never import from future or ancient (2+ back) forks. - -## Branches - -- **There is no `main` branch.** Default branch = most active fork (currently `forks/amsterdam`). Run `git remote show origin | grep HEAD` to check. -- `mainnet` = stable specs for forks live on mainnet -- PRs target the default branch -- PRs strictly follow the template in `.github/PULL_REQUEST_TEMPLATE.md`. -- Never add Claude attribution links (`Claude-Session:` trailers or `claude.ai` URLs) to commit messages or PR descriptions. - -## PR Reviews - -When reviewing PRs that implement or test EIPs: - -1. Identify the EIP number(s) from the branch name, PR title, or changed file paths -2. Fetch each EIP spec from `https://eips.ethereum.org/EIPS/eip-` before starting the review -3. Verify the implementation matches the EIP's specification requirements - -## When to Use Skills - -- Writing or modifying tests → run `/write-test` first -- Cleaning up or future-proofing a `tests/ported_static/` test → run `/enhance-ported-test` first -- Writing or modifying pytester-based plugin tests → run `/pytester` first -- Filling test fixtures → run `/fill-tests` first -- Implementing an EIP or modifying fork code in `src/` → run `/implement-eip` first -- Modifying GitHub Actions workflows → run `/edit-workflow` first -- Assessing EIP complexity or scope → run `/assess-eip` -- Working on EIP test coverage or checklists → run `/eip-checklist` first -- Checking if config/skills are stale → run `/audit-config` -- Writing or modifying docstrings in `src/ethereum/` → run `/write-docstring` first -- Done with changes and ready to lint → run `/lint` - -## Available Skills - -- `/write-test` — test writing patterns, fixtures, markers, bytecode helpers -- `/enhance-ported-test` — ordered methodology to clean up & future-proof `tests/ported_static/` tests -- `/pytester` — pytester execution modes, isolation, output handling for plugin tests -- `/fill-tests` — `fill` CLI reference, flags, debugging, benchmark tests -- `/implement-eip` — fork structure, import rules, adding opcodes/precompiles/tx types -- `/edit-workflow` — GitHub Actions conventions and version pinning -- `/assess-eip` — structured EIP complexity assessment -- `/eip-checklist` — EIP testing checklist system for tracking coverage -- `/lint` — full static analysis suite with auto-fix workflow -- `/audit-config` — verify CLAUDE.md and skills are still accurate -- `/write-docstring` — narrative Markdown docstring conventions for the spec -- `/grammar-check` — audit grammar in documentation and code comments diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000000..47dc3e3d863 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From e436c5625e702510e476772f4590b9188ca7b12b Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:27:24 +0000 Subject: [PATCH 55/59] resolve the upstream merge conflicts Union the split imports, keep the Monad ported_static opt-out and the runloop env overrides, drop the conftest orphaned by the removal of the EIP-7610 tests, and move the fork's adopt-upstream-eip skill into the shared .agents/skills layout. Co-Authored-By: Claude --- .../skills/adopt-upstream-eip/SKILL.md | 5 +++++ .claude/skills/adopt-upstream-eip | 1 + .../cli/pytest_commands/plugins/filler/filler.py | 5 +---- .../forks/tests/test_opcode_gas_costs.py | 5 +---- .../testing/src/execution_testing/specs/blockchain.py | 5 +---- tests/paris/eip7610_create_collision/conftest.py | 10 ---------- tests/ported_static/conftest.py | 4 ---- 7 files changed, 9 insertions(+), 26 deletions(-) rename .claude/commands/adopt-upstream-eip.md => .agents/skills/adopt-upstream-eip/SKILL.md (99%) create mode 120000 .claude/skills/adopt-upstream-eip delete mode 100644 tests/paris/eip7610_create_collision/conftest.py diff --git a/.claude/commands/adopt-upstream-eip.md b/.agents/skills/adopt-upstream-eip/SKILL.md similarity index 99% rename from .claude/commands/adopt-upstream-eip.md rename to .agents/skills/adopt-upstream-eip/SKILL.md index 24ffd4878f2..034a5191c6d 100644 --- a/.claude/commands/adopt-upstream-eip.md +++ b/.agents/skills/adopt-upstream-eip/SKILL.md @@ -1,3 +1,8 @@ +--- +name: adopt-upstream-eip +description: Adopt a single upstream EIP into a work-in-progress Monad fork. +--- + # Adopt Upstream EIP Adopt a single upstream EIP into a work-in-progress Monad fork and release diff --git a/.claude/skills/adopt-upstream-eip b/.claude/skills/adopt-upstream-eip new file mode 120000 index 00000000000..1aad2f42301 --- /dev/null +++ b/.claude/skills/adopt-upstream-eip @@ -0,0 +1 @@ +../../.agents/skills/adopt-upstream-eip \ No newline at end of file diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py index f249447644c..220b10eb3ae 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py @@ -73,14 +73,11 @@ ) from execution_testing.specs import BaseTest from execution_testing.specs.base import FillResult, OpMode -<<<<<<< HEAD from execution_testing.test_types import ( + AllocGroupHash, EnvironmentDefaults, MonadRunloopDefaults, ) -======= -from execution_testing.test_types import AllocGroupHash, EnvironmentDefaults ->>>>>>> upstream/forks/amsterdam from execution_testing.test_types.chain_config_types import ( DEFAULT_CHAIN_ID, ChainConfigDefaults, diff --git a/packages/testing/src/execution_testing/forks/tests/test_opcode_gas_costs.py b/packages/testing/src/execution_testing/forks/tests/test_opcode_gas_costs.py index 13514262192..58bc5773d88 100644 --- a/packages/testing/src/execution_testing/forks/tests/test_opcode_gas_costs.py +++ b/packages/testing/src/execution_testing/forks/tests/test_opcode_gas_costs.py @@ -4,10 +4,8 @@ from execution_testing.vm import Bytecode, Op -<<<<<<< HEAD -from ..forks.forks import MONAD_TEN, Homestead, Osaka -======= from ..forks.forks import ( + MONAD_TEN, Berlin, ConstantinopleFix, Homestead, @@ -15,7 +13,6 @@ Osaka, SpuriousDragon, ) ->>>>>>> upstream/forks/amsterdam from ..helpers import Fork diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index bf6f58cb302..879e6f331b0 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -952,7 +952,7 @@ def generate_block_data( block_number=env.number, timestamp=env.timestamp ) env = env.set_fork_requirements(fork) -<<<<<<< HEAD + env.check_fork_fields(fork) # When filling with --monad-runloop, monad blocks must carry the # consensus-derived header fields the production runloop produces. @@ -972,9 +972,6 @@ def generate_block_data( prev_randao=MonadRunloopDefaults.prev_randao, ) -======= - env.check_fork_fields(fork) ->>>>>>> upstream/forks/amsterdam txs = block.txs[:] if any("gas_limit" not in tx.model_fields_set for tx in block.txs): max_tx_gas_limit = Transaction.calculate_max_gas_limit( diff --git a/tests/paris/eip7610_create_collision/conftest.py b/tests/paris/eip7610_create_collision/conftest.py deleted file mode 100644 index 999c5b9d688..00000000000 --- a/tests/paris/eip7610_create_collision/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Pytest configuration for EIP-7610 tests.""" - -import pytest - - -def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Mark all tests in this subdir as not valid for Monad forks.""" - metafunc.definition.add_marker( - pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) - ) diff --git a/tests/ported_static/conftest.py b/tests/ported_static/conftest.py index f46df4c7606..d28aa4b8cd4 100644 --- a/tests/ported_static/conftest.py +++ b/tests/ported_static/conftest.py @@ -34,7 +34,6 @@ def _fixture_format_tokens() -> tuple[str, ...]: return tuple(f"-{name}" for name in sorted(names, key=len, reverse=True)) -<<<<<<< HEAD def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: """Mark all tests in this subdir as not valid for Monad forks.""" metafunc.definition.add_marker( @@ -42,10 +41,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: ) -def _normalize_nodeid(nodeid: str) -> str: -======= def _normalize_nodeid(nodeid: str, tokens: tuple[str, ...]) -> str: ->>>>>>> upstream/forks/amsterdam """Strip pytest fixture-format suffixes to match the skip list format.""" for token in tokens: nodeid = nodeid.replace(token, "") From 2e0b9de74da64bc117fa3dca633d476c69efac6d Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:32:42 +0000 Subject: [PATCH 56/59] propagate the upstream fork changes into the Monad forks Drop EIP-7610's storage-only create collision, whose PreState hook upstream removed, and the stale get_last_256_block_hashes TODO. Co-Authored-By: Claude --- src/ethereum/forks/monad_eight/fork.py | 1 - .../forks/monad_eight/state_tracker.py | 24 ------------------- .../monad_eight/vm/instructions/system.py | 5 +--- .../forks/monad_eight/vm/interpreter.py | 3 +-- src/ethereum/forks/monad_next/fork.py | 1 - .../forks/monad_next/state_tracker.py | 24 ------------------- .../monad_next/vm/instructions/system.py | 5 +--- .../forks/monad_next/vm/interpreter.py | 3 +-- src/ethereum/forks/monad_nine/fork.py | 1 - .../forks/monad_nine/state_tracker.py | 24 ------------------- .../monad_nine/vm/instructions/system.py | 5 +--- .../forks/monad_nine/vm/interpreter.py | 3 +-- src/ethereum/forks/monad_ten/fork.py | 1 - src/ethereum/forks/monad_ten/state_tracker.py | 24 ------------------- .../forks/monad_ten/vm/instructions/system.py | 5 +--- .../forks/monad_ten/vm/interpreter.py | 3 +-- src/ethereum/state_paged.py | 8 ------- 17 files changed, 8 insertions(+), 132 deletions(-) diff --git a/src/ethereum/forks/monad_eight/fork.py b/src/ethereum/forks/monad_eight/fork.py index 0f02c0d8c28..4b457dbaefc 100644 --- a/src/ethereum/forks/monad_eight/fork.py +++ b/src/ethereum/forks/monad_eight/fork.py @@ -173,7 +173,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/monad_eight/state_tracker.py b/src/ethereum/forks/monad_eight/state_tracker.py index fb126a399a3..d9f2269f77d 100644 --- a/src/ethereum/forks/monad_eight/state_tracker.py +++ b/src/ethereum/forks/monad_eight/state_tracker.py @@ -294,30 +294,6 @@ def account_has_code_or_nonce( return account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if tx_state.parent.storage_writes.get(address): - return True - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/monad_eight/vm/instructions/system.py b/src/ethereum/forks/monad_eight/vm/instructions/system.py index 109775e4a2c..ee9f4508816 100644 --- a/src/ethereum/forks/monad_eight/vm/instructions/system.py +++ b/src/ethereum/forks/monad_eight/vm/instructions/system.py @@ -19,7 +19,6 @@ from ...state_tracker import ( account_has_code_or_nonce, - account_has_storage, get_account, increment_nonce, is_account_alive, @@ -102,9 +101,7 @@ def generic_create( evm.accessed_addresses.add(contract_address) - if account_has_code_or_nonce( - evm.message.tx_env.state, contract_address - ) or account_has_storage(evm.message.tx_env.state, contract_address): + if account_has_code_or_nonce(evm.message.tx_env.state, contract_address): increment_nonce(evm.message.tx_env.state, evm.message.current_target) push(evm.stack, U256(0)) return diff --git a/src/ethereum/forks/monad_eight/vm/interpreter.py b/src/ethereum/forks/monad_eight/vm/interpreter.py index 9d647318982..07d86617301 100644 --- a/src/ethereum/forks/monad_eight/vm/interpreter.py +++ b/src/ethereum/forks/monad_eight/vm/interpreter.py @@ -33,7 +33,6 @@ from ..blocks import Log from ..state_tracker import ( account_has_code_or_nonce, - account_has_storage, copy_tx_state, destroy_storage, get_account, @@ -123,7 +122,7 @@ def process_message_call(message: Message) -> MessageCallOutput: if message.target == Bytes0(b""): is_collision = account_has_code_or_nonce( tx_state, message.current_target - ) or account_has_storage(tx_state, message.current_target) + ) if is_collision: return MessageCallOutput( gas_left=Uint(0), diff --git a/src/ethereum/forks/monad_next/fork.py b/src/ethereum/forks/monad_next/fork.py index 653f05d030f..e0db9dc5826 100644 --- a/src/ethereum/forks/monad_next/fork.py +++ b/src/ethereum/forks/monad_next/fork.py @@ -183,7 +183,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/monad_next/state_tracker.py b/src/ethereum/forks/monad_next/state_tracker.py index 5b3d4be0bd2..ead87e8e361 100644 --- a/src/ethereum/forks/monad_next/state_tracker.py +++ b/src/ethereum/forks/monad_next/state_tracker.py @@ -294,30 +294,6 @@ def account_has_code_or_nonce( return account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if tx_state.parent.storage_writes.get(address): - return True - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/monad_next/vm/instructions/system.py b/src/ethereum/forks/monad_next/vm/instructions/system.py index 7b8634c5d7e..540b86cee14 100644 --- a/src/ethereum/forks/monad_next/vm/instructions/system.py +++ b/src/ethereum/forks/monad_next/vm/instructions/system.py @@ -19,7 +19,6 @@ from ...state_tracker import ( account_has_code_or_nonce, - account_has_storage, get_account, increment_nonce, is_account_alive, @@ -103,9 +102,7 @@ def generic_create( evm.accessed_addresses.add(contract_address) - if account_has_code_or_nonce( - evm.message.tx_env.state, contract_address - ) or account_has_storage(evm.message.tx_env.state, contract_address): + if account_has_code_or_nonce(evm.message.tx_env.state, contract_address): increment_nonce(evm.message.tx_env.state, evm.message.current_target) push(evm.stack, U256(0)) return diff --git a/src/ethereum/forks/monad_next/vm/interpreter.py b/src/ethereum/forks/monad_next/vm/interpreter.py index 13089bc8125..df7ee011d08 100644 --- a/src/ethereum/forks/monad_next/vm/interpreter.py +++ b/src/ethereum/forks/monad_next/vm/interpreter.py @@ -33,7 +33,6 @@ from ..blocks import Log from ..state_tracker import ( account_has_code_or_nonce, - account_has_storage, copy_tx_state, destroy_storage, get_account, @@ -213,7 +212,7 @@ def process_message_call(message: Message) -> MessageCallOutput: if message.target == Bytes0(b""): is_collision = account_has_code_or_nonce( tx_state, message.current_target - ) or account_has_storage(tx_state, message.current_target) + ) if is_collision: return MessageCallOutput( gas_left=Uint(0), diff --git a/src/ethereum/forks/monad_nine/fork.py b/src/ethereum/forks/monad_nine/fork.py index e7c56f8c815..d1cbfd26180 100644 --- a/src/ethereum/forks/monad_nine/fork.py +++ b/src/ethereum/forks/monad_nine/fork.py @@ -180,7 +180,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/monad_nine/state_tracker.py b/src/ethereum/forks/monad_nine/state_tracker.py index 5b3d4be0bd2..ead87e8e361 100644 --- a/src/ethereum/forks/monad_nine/state_tracker.py +++ b/src/ethereum/forks/monad_nine/state_tracker.py @@ -294,30 +294,6 @@ def account_has_code_or_nonce( return account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if tx_state.parent.storage_writes.get(address): - return True - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/monad_nine/vm/instructions/system.py b/src/ethereum/forks/monad_nine/vm/instructions/system.py index 7b8634c5d7e..540b86cee14 100644 --- a/src/ethereum/forks/monad_nine/vm/instructions/system.py +++ b/src/ethereum/forks/monad_nine/vm/instructions/system.py @@ -19,7 +19,6 @@ from ...state_tracker import ( account_has_code_or_nonce, - account_has_storage, get_account, increment_nonce, is_account_alive, @@ -103,9 +102,7 @@ def generic_create( evm.accessed_addresses.add(contract_address) - if account_has_code_or_nonce( - evm.message.tx_env.state, contract_address - ) or account_has_storage(evm.message.tx_env.state, contract_address): + if account_has_code_or_nonce(evm.message.tx_env.state, contract_address): increment_nonce(evm.message.tx_env.state, evm.message.current_target) push(evm.stack, U256(0)) return diff --git a/src/ethereum/forks/monad_nine/vm/interpreter.py b/src/ethereum/forks/monad_nine/vm/interpreter.py index a5545bf593f..512fb6a24f4 100644 --- a/src/ethereum/forks/monad_nine/vm/interpreter.py +++ b/src/ethereum/forks/monad_nine/vm/interpreter.py @@ -33,7 +33,6 @@ from ..blocks import Log from ..state_tracker import ( account_has_code_or_nonce, - account_has_storage, copy_tx_state, destroy_storage, get_account, @@ -213,7 +212,7 @@ def process_message_call(message: Message) -> MessageCallOutput: if message.target == Bytes0(b""): is_collision = account_has_code_or_nonce( tx_state, message.current_target - ) or account_has_storage(tx_state, message.current_target) + ) if is_collision: return MessageCallOutput( gas_left=Uint(0), diff --git a/src/ethereum/forks/monad_ten/fork.py b/src/ethereum/forks/monad_ten/fork.py index 653f05d030f..e0db9dc5826 100644 --- a/src/ethereum/forks/monad_ten/fork.py +++ b/src/ethereum/forks/monad_ten/fork.py @@ -183,7 +183,6 @@ def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: """ recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] diff --git a/src/ethereum/forks/monad_ten/state_tracker.py b/src/ethereum/forks/monad_ten/state_tracker.py index 5b3d4be0bd2..ead87e8e361 100644 --- a/src/ethereum/forks/monad_ten/state_tracker.py +++ b/src/ethereum/forks/monad_ten/state_tracker.py @@ -294,30 +294,6 @@ def account_has_code_or_nonce( return account.nonce != Uint(0) or account.code_hash != EMPTY_CODE_HASH -def account_has_storage(tx_state: TransactionState, address: Address) -> bool: - """ - Check if an account has storage. - - Parameters - ---------- - tx_state : - The transaction state. - address : - Address of the account that needs to be checked. - - Returns - ------- - has_storage : ``bool`` - True if the account has storage, False otherwise. - - """ - if tx_state.storage_writes.get(address): - return True - if tx_state.parent.storage_writes.get(address): - return True - return tx_state.parent.pre_state.account_has_storage(address) - - def account_exists_and_is_empty( tx_state: TransactionState, address: Address ) -> bool: diff --git a/src/ethereum/forks/monad_ten/vm/instructions/system.py b/src/ethereum/forks/monad_ten/vm/instructions/system.py index 7b8634c5d7e..540b86cee14 100644 --- a/src/ethereum/forks/monad_ten/vm/instructions/system.py +++ b/src/ethereum/forks/monad_ten/vm/instructions/system.py @@ -19,7 +19,6 @@ from ...state_tracker import ( account_has_code_or_nonce, - account_has_storage, get_account, increment_nonce, is_account_alive, @@ -103,9 +102,7 @@ def generic_create( evm.accessed_addresses.add(contract_address) - if account_has_code_or_nonce( - evm.message.tx_env.state, contract_address - ) or account_has_storage(evm.message.tx_env.state, contract_address): + if account_has_code_or_nonce(evm.message.tx_env.state, contract_address): increment_nonce(evm.message.tx_env.state, evm.message.current_target) push(evm.stack, U256(0)) return diff --git a/src/ethereum/forks/monad_ten/vm/interpreter.py b/src/ethereum/forks/monad_ten/vm/interpreter.py index 6449afb2f23..e339ded2c7b 100644 --- a/src/ethereum/forks/monad_ten/vm/interpreter.py +++ b/src/ethereum/forks/monad_ten/vm/interpreter.py @@ -33,7 +33,6 @@ from ..blocks import Log from ..state_tracker import ( account_has_code_or_nonce, - account_has_storage, copy_tx_state, destroy_storage, get_account, @@ -213,7 +212,7 @@ def process_message_call(message: Message) -> MessageCallOutput: if message.target == Bytes0(b""): is_collision = account_has_code_or_nonce( tx_state, message.current_target - ) or account_has_storage(tx_state, message.current_target) + ) if is_collision: return MessageCallOutput( gas_left=Uint(0), diff --git a/src/ethereum/state_paged.py b/src/ethereum/state_paged.py index 94e3c0d468d..d3b20e83a66 100644 --- a/src/ethereum/state_paged.py +++ b/src/ethereum/state_paged.py @@ -80,14 +80,6 @@ def get_storage(self, address: Address, key: Bytes32) -> U256: assert isinstance(value, U256) return value - def account_has_storage(self, address: Address) -> bool: - """ - Check whether an account has any storage. - - Only needed for EIP-7610. - """ - return address in self._storage_tries - def compute_state_root(self, block_diff: BlockDiff) -> Root: """ Compute the state root after applying `block_diff` to the From a16a615eecb920270b47043fd3c2cd44b2608437 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:52:19 +0000 Subject: [PATCH 57/59] fix the statetest t8n senders_authorities input The flag took the literal `--stdin`, which argparse read as an option rather than a value, so every `statetest` run died in the parser. Co-Authored-By: Claude --- .../src/execution_testing/evm_tools/statetest/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/execution_testing/evm_tools/statetest/__init__.py b/packages/testing/src/execution_testing/evm_tools/statetest/__init__.py index 4bd0fbe89a7..80536e1161d 100644 --- a/packages/testing/src/execution_testing/evm_tools/statetest/__init__.py +++ b/packages/testing/src/execution_testing/evm_tools/statetest/__init__.py @@ -142,6 +142,7 @@ def run_test_case( { "env": env, "alloc": alloc, + "senders_authorities": {}, "txs": txs, } ) @@ -154,7 +155,7 @@ def run_test_case( "--input.alloc", "stdin", "--input.senders_authorities", - "--stdin", + "stdin", "--input.env", "stdin", "--input.txs", From 6f806d25dd4c324bebd6b7e2305b861e67beb007 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:32:34 +0000 Subject: [PATCH 58/59] docs(skills): add the upstream merge skill Co-Authored-By: Claude --- .agents/skills/merge-from-upstream/SKILL.md | 89 +++++++++++++++++++++ .claude/skills/merge-from-upstream | 1 + 2 files changed, 90 insertions(+) create mode 100644 .agents/skills/merge-from-upstream/SKILL.md create mode 120000 .claude/skills/merge-from-upstream diff --git a/.agents/skills/merge-from-upstream/SKILL.md b/.agents/skills/merge-from-upstream/SKILL.md new file mode 100644 index 00000000000..2328e0cdb5a --- /dev/null +++ b/.agents/skills/merge-from-upstream/SKILL.md @@ -0,0 +1,89 @@ +--- +name: merge-from-upstream +description: Merge upstream execution-specs into the fork's from-upstream branch. +--- + +# Merge From Upstream + +Merge the current default branch of `ethereum/execution-specs` into the +`from-upstream` branch of `monad-developers/execution-specs`, carry the +applicable changes into the Monad forks, and verify the result. Run this +skill before starting such work. + +The commit sequence is the deliverable as much as the merged tree is: a +reviewer reads the conflict resolution and the Monad propagation as +separate diffs, so never fold them into the merge commit or into each +other. + +## 1. Prepare the branch + +- Check out `from-upstream`. +- Verify it is not the head branch of a currently open PR. +- Reset it to the `monad-developers/execution-specs` default branch. + +## 2. Record the fixture set baseline + +Collect the tests the `tests-monad` release job fills, with +`--collect-only`, and save the result for the closing comparison. The +fill parameters live under the `monad` key in +`.github/configs/feature.yaml`; `.github/actions/build-fixtures` and the +`fill-release` recipe in the `Justfile` add the rest. + +The `N/M tests collected` summary counts items before the filler drops +the ones that generate no fixture for the fork range, so it overstates +the release set. Take the node IDs, not that number. + +## 3. Merge + +- Fetch the upstream remote. +- Merge upstream's default branch as a merge commit, **leaving the + conflicts unresolved in the merge commit**. +- Stage the conflicted paths explicitly. Staging everything would also + commit untracked directories left behind by other branches. + +## 4. Resolve the conflicts + +Put the resolution in a separate commit after the merge commit. + +## 5. Propagate into the Monad forks + +Analyze the changes upstream made to its own forks (Amsterdam, Osaka, +Prague and the rest). Filter them to those that apply to the `MONAD_*` +forks inheriting those upstream forks, and apply those to the WET +implementations in `src/ethereum/forks`. + +These files do not conflict, so nothing flags them. They still have to +accommodate the upstream change whenever it applies — for instance +because the Monad fork adopted the same EIP. + +Do these changes in a further commit. + +## 6. Stop for review + +If anything about the changes so far is doubtful, stop and request human +review before verifying. + +## 7. Verify + +Lint, then fill. Base the fill on the `tests-monad` release command and +pass `--maxfail 1` so breakage surfaces early. Fill fine-grained first, +over the tests related to the touched files and features, then sweep the +full set. + +A full sweep runs for hours. Split it into chunks that partition the +collected set, so no single run is long enough to be interrupted, and +check the chunk totals add up to the collected count. + +## 8. Fix what the verification finds + +Use follow-up commits: fold related changes together, keep unrelated +changes separate. + +## 9. Report the fixture set change + +Collect the release tests again with `--collect-only`, compare against +the step 2 baseline, and report the change in the resulting fixture set. + +## 10. Stop for review + +Once verification succeeds, stop and request human review. diff --git a/.claude/skills/merge-from-upstream b/.claude/skills/merge-from-upstream new file mode 120000 index 00000000000..97868072ff9 --- /dev/null +++ b/.claude/skills/merge-from-upstream @@ -0,0 +1 @@ +../../.agents/skills/merge-from-upstream \ No newline at end of file From 9e5b1a68a58b9c48a01a77c5bd29f1c4ab62cded Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:35:05 +0000 Subject: [PATCH 59/59] docs(skills): list the fork's own skills in AGENTS.md Co-Authored-By: Claude --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a8f64c30192..d68356ea5f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,8 @@ skills as `/name` commands through symlinked folders in `.claude/skills/`. - Writing or modifying pytester-based plugin tests → run `/pytester` first - Filling test fixtures → run `/fill-tests` first - Implementing an EIP or modifying fork code in `src/` → run `/implement-eip` first +- Adopting a single upstream EIP into a work-in-progress Monad fork → run `/adopt-upstream-eip` first +- Merging upstream changes into `from-upstream` → run `/merge-from-upstream` first - Modifying GitHub Actions workflows → run `/edit-workflow` first - Assessing EIP complexity or scope → run `/assess-eip` - Working on EIP test coverage or checklists → run `/eip-checklist` first @@ -68,6 +70,8 @@ skills as `/name` commands through symlinked folders in `.claude/skills/`. - `/pytester` — pytester execution modes, isolation, output handling for plugin tests - `/fill-tests` — `fill` CLI reference, flags, debugging, benchmark tests - `/implement-eip` — fork structure, import rules, adding opcodes/precompiles/tx types +- `/adopt-upstream-eip` — adopt one upstream EIP into a work-in-progress Monad fork and release its fixtures +- `/merge-from-upstream` — merge upstream, propagate the changes to the Monad forks, verify the fixture set - `/edit-workflow` — GitHub Actions conventions and version pinning - `/assess-eip` — structured EIP complexity assessment - `/eip-checklist` — EIP testing checklist system for tracking coverage