From 24124a6e41519cd1acef6fbf276a093631b1c53f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Tue, 22 Sep 2026 10:44:48 +0200 Subject: [PATCH 01/28] feat(tests): port the repeated ECRECOVER call fillers (#3590) * feat(tests): port the repeated ECRECOVER call fillers Fold Call50000_ecrec and static_Call50000_ecrec into a single parametrized test over every call opcode, with ten iterations instead of fifty thousand. The fillers repeat the call until the transaction runs out of gas, so their whole post-state is the empty one a reverted transaction leaves behind; running the loop to completion instead lets the residue be checked. What the precompile receives, stipend included, is exactly one gas short of its price, so every call fails: the success flags accumulate to their seeded base, the loop counter proves all ten iterations ran, the seeded return word survives and the precompile receives no value. One gas more and every call recovers instead. The input window stays as wide as the fillers made it, far wider than the 128 bytes the precompile reads. * refactor: docstring and test implementation --------- Co-authored-by: LouisTsai --- tests/frontier/precompiles/test_ecrecover.py | 119 ++++++++++- .../test_call50000_ecrec.py | 150 -------------- .../test_static_call50000_ecrec.py | 188 ------------------ 3 files changed, 118 insertions(+), 339 deletions(-) delete mode 100644 tests/ported_static/stQuadraticComplexityTest/test_call50000_ecrec.py delete mode 100644 tests/ported_static/stStaticCall/test_static_call50000_ecrec.py diff --git a/tests/frontier/precompiles/test_ecrecover.py b/tests/frontier/precompiles/test_ecrecover.py index 1200707cef7..1cadfdc946f 100644 --- a/tests/frontier/precompiles/test_ecrecover.py +++ b/tests/frontier/precompiles/test_ecrecover.py @@ -1,10 +1,21 @@ """Tests ecrecover precompiled contract.""" +from typing import Any + import pytest -from execution_testing import Account, Alloc, StateTestFiller, Transaction +from execution_testing import ( + Account, + Alloc, + StateTestFiller, + Storage, + Transaction, + While, +) from execution_testing.forks.helpers import Fork from execution_testing.vm import Opcodes as Op +from .spec import EcrecoverInput, Spec + @pytest.mark.ported_from( [ @@ -1033,3 +1044,109 @@ def test_precompiles( } state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.ported_from( + [ + "state_tests/stQuadraticComplexityTest/Call50000_ecrecFiller.json", + "state_tests/stStaticCall/static_Call50000_ecrecFiller.json", + ], + coverage_missed_reason=( + "The fillers repeat the call until the transaction runs out of gas, " + "so their whole post-state is the empty one a reverted transaction " + "leaves behind. The port runs its iterations to completion and " + "checks the residue instead, keeping the oversized input window but " + "not the cost of the repetition itself." + ), +) +@pytest.mark.with_all_call_opcodes +@pytest.mark.valid_from("Frontier") +def test_repeated_underfunded_calls( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + call_opcode: Op, +) -> None: + """ + Test that repeated underfunded calls to ecrecover all fail, leave the + return buffer untouched and move no value to the precompile. + """ + iterations = 10 + + # The same signature recovers an address in `test_precompiles`, so the + # gas is the only reason these calls fail. + signature = EcrecoverInput( + msg_hash=( + 0x18C547E4F7B0F325AD1E56F57E26C745B09A3E503D86E00E5255FF7F715D3D1C + ), + v=0x1C, + r=0x73B1693892219D736CABA55BDB67216E485557EA6B6AF75F37096C9AA6A5A75F, + s=0xEEB940B1D03B21E36B0E47E79769F095FE2AB855BD91E3A38756B7D75A9C4549, + ) + + # A value-bearing call adds the stipend to what the callee receives, so + # the operand that leaves the precompile one gas short depends on the + # opcode. + gas_costs = fork.gas_costs() + sends_value = "value" in call_opcode.kwargs + stipend = gas_costs.CALL_STIPEND if sends_value else 0 + forwarded_gas = gas_costs.PRECOMPILE_ECRECOVER - stipend - 1 + + # Memory: the input window, then the return buffer, then the loop + # counter. The window is far wider than the 128 bytes the precompile + # reads, as in the fillers: a client that copies the whole window + # before charging for the call pays for it once per iteration. + args_size = 50_000 + ret_offset = args_size + counter_offset = ret_offset + 32 + + # A failed call returns no bytes, so both seeded values must survive. + successes_base = 0xC0DE + ret_sentinel = b"\xff" * 32 + + storage = Storage() + successes_slot = storage.store_next(successes_base, "successes") + + # One wei per iteration, so no call can fail for want of balance. + caller_balance = iterations + value_kwarg: dict[str, Any] = {"value": 1} if sends_value else {} + call = call_opcode( + gas=forwarded_gas, + address=Spec.ECRECOVER, + args_offset=0, + args_size=args_size, + ret_offset=ret_offset, + ret_size=32, + **value_kwarg, + ) + body = Op.SSTORE( + successes_slot, Op.ADD(Op.SLOAD(successes_slot), call) + ) + Op.MSTORE(counter_offset, Op.ADD(Op.MLOAD(counter_offset), 1)) + + caller = pre.deploy_contract( + Op.CALLDATACOPY(0, 0, len(bytes(signature))) + + Op.MSTORE(ret_offset, ret_sentinel) + + While( + body=body, + condition=Op.LT(Op.MLOAD(counter_offset), iterations), + ) + + Op.SSTORE(storage.store_next(iterations), Op.MLOAD(counter_offset)) + + Op.SSTORE(storage.store_next(ret_sentinel), Op.MLOAD(ret_offset)) + + Op.STOP, + storage={successes_slot: successes_base}, + balance=caller_balance, + ) + + tx = Transaction( + to=caller, + data=signature, + sender=pre.fund_eoa(), + protected=fork.supports_protected_txs(), + ) + + post = { + caller: Account(storage=storage, balance=caller_balance), + Spec.ECRECOVER: Account.NONEXISTENT, + } + + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stQuadraticComplexityTest/test_call50000_ecrec.py b/tests/ported_static/stQuadraticComplexityTest/test_call50000_ecrec.py deleted file mode 100644 index c08fe1e1611..00000000000 --- a/tests/ported_static/stQuadraticComplexityTest/test_call50000_ecrec.py +++ /dev/null @@ -1,150 +0,0 @@ -""" -Test_call50000_ecrec. - -Ported from: -state_tests/stQuadraticComplexityTest/Call50000_ecrecFiller.json -""" - -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 - -from tests.ported_static.post_state_resolution import ( - resolve_expect_post, -) - -REFERENCE_SPEC_GIT_PATH = "N/A" -REFERENCE_SPEC_VERSION = "N/A" - - -@pytest.mark.ported_from( - ["state_tests/stQuadraticComplexityTest/Call50000_ecrecFiller.json"], -) -@pytest.mark.valid_from("Cancun") -@pytest.mark.valid_until("Prague") -@pytest.mark.slow -@pytest.mark.parametrize( - "d, g, v", - [ - pytest.param( - 0, - 0, - 0, - id="-g0", - ), - pytest.param( - 0, - 1, - 0, - id="-g1", - ), - ], -) -@pytest.mark.pre_alloc_mutable -def test_call50000_ecrec( - state_test: StateTestFiller, - pre: Alloc, - fork: Fork, - d: int, - g: int, - v: int, -) -> None: - """Test_call50000_ecrec.""" - coinbase = Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) - sender = pre.fund_eoa(amount=0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=3000000000, - ) - - # Source: lll - # { (def 'i 0x80) (for {} (< @i 50000) [i](+ @i 1) [[ 0 ]] (CALL 500 1 1 0 50000 0 0) ) [[ 1 ]] @i} # noqa: E501 - target = pre.deploy_contract( # noqa: F841 - code=Op.JUMPDEST - + Op.JUMPI( - pc=0x2C, condition=Op.ISZERO(Op.LT(Op.MLOAD(offset=0x80), 0xC350)) - ) - + Op.SSTORE( - key=0x0, - value=Op.CALL( - gas=0x1F4, - address=0x1, - value=0x1, - args_offset=0x0, - args_size=0xC350, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.MSTORE(offset=0x80, value=Op.ADD(Op.MLOAD(offset=0x80), 0x1)) - + Op.JUMP(pc=0x0) - + Op.JUMPDEST - + Op.SSTORE(key=0x1, value=Op.MLOAD(offset=0x80)) - + Op.STOP, - balance=0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, - nonce=0, - ) - - expect_entries_: list[dict] = [ - { - "indexes": {"data": -1, "gas": 1, "value": -1}, - "network": [">=Cancun=Cancun None: - """Test_static_call50000_ecrec.""" - coinbase = Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) - sender = pre.fund_eoa(amount=0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=95000000, - ) - - # Source: lll - # { [[ 0 ]] (CALL (GAS) (CALLDATALOAD 0) (CALLVALUE) 0 0 0 0) [[ 1 ]] 1 } - target = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE( - key=0x0, - value=Op.CALL( - gas=Op.GAS, - address=Op.CALLDATALOAD(offset=0x0), - value=Op.CALLVALUE, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.SSTORE(key=0x1, value=0x1) - + Op.STOP, - nonce=0, - address=Address(0xC0E4183389EB57F779A986D8C878F89B9401DC8E), # noqa: E501 - ) - # Source: lll - # { (def 'i 0x80) (for {} (< @i 50000) [i](+ @i 1) [[ 0 ]] (STATICCALL 500 1 0 50000 0 0) ) [[ 1 ]] @i} # noqa: E501 - addr = pre.deploy_contract( # noqa: F841 - code=Op.JUMPDEST - + Op.JUMPI( - pc=0x2A, condition=Op.ISZERO(Op.LT(Op.MLOAD(offset=0x80), 0xC350)) - ) - + Op.SSTORE( - key=0x0, - value=Op.STATICCALL( - gas=0x1F4, - address=0x1, - args_offset=0x0, - args_size=0xC350, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.MSTORE(offset=0x80, value=Op.ADD(Op.MLOAD(offset=0x80), 0x1)) - + Op.JUMP(pc=0x0) - + Op.JUMPDEST - + Op.SSTORE(key=0x1, value=Op.MLOAD(offset=0x80)) - + Op.STOP, - balance=0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, - nonce=0, - address=Address(0x88C698DF82BBA0A5BC4EDED3C9ABFCAA22ADEF92), # noqa: E501 - ) - # Source: lll - # { (def 'i 0x80) (for {} (< @i 50000) [i](+ @i 1) (MSTORE 0 (STATICCALL 500 1 0 50000 0 0)) ) (MSTORE 32 @i ) } # noqa: E501 - addr_2 = pre.deploy_contract( # noqa: F841 - code=Op.JUMPDEST - + Op.JUMPI( - pc=0x2A, condition=Op.ISZERO(Op.LT(Op.MLOAD(offset=0x80), 0xC350)) - ) - + Op.MSTORE( - offset=0x0, - value=Op.STATICCALL( - gas=0x1F4, - address=0x1, - args_offset=0x0, - args_size=0xC350, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.MSTORE(offset=0x80, value=Op.ADD(Op.MLOAD(offset=0x80), 0x1)) - + Op.JUMP(pc=0x0) - + Op.JUMPDEST - + Op.MSTORE(offset=0x20, value=Op.MLOAD(offset=0x80)) - + Op.STOP, - balance=0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, - nonce=0, - address=Address(0xB5C3E48B7024DBBDBE53D636ADCC0531CDC8DA1A), # noqa: E501 - ) - - expect_entries_: list[dict] = [ - { - "indexes": {"data": 0, "gas": -1, "value": -1}, - "network": [">=Cancun=Cancun Date: Tue, 22 Sep 2026 16:54:20 +0800 Subject: [PATCH 02/28] feat(tests): close out eip-7954 checklist (#3546) Co-authored-by: fselmo Co-authored-by: spencer-tb --- .../eip_checklist_external_coverage.txt | 3 + .../eip_checklist_not_applicable.txt | 14 + .../spec.py | 2 +- .../test_cases.md | 33 +- .../test_fork_transition.py | 565 ++++++++++-------- .../test_max_code_size.py | 324 ++++++++-- .../test_max_initcode_size.py | 238 +++++++- .../test_codesize_oog_invalid_size.py | 1 - .../test_create2_code_size_limit.py | 14 +- .../test_create_code_size_limit.py | 16 +- .../test_create2_init_code_size_limit.py | 4 + .../test_create_init_code_size_limit.py | 4 + .../test_creation_tx_init_code_size_limit.py | 4 + 13 files changed, 859 insertions(+), 363 deletions(-) create mode 100644 tests/amsterdam/eip7954_increase_max_contract_size/eip_checklist_external_coverage.txt create mode 100644 tests/amsterdam/eip7954_increase_max_contract_size/eip_checklist_not_applicable.txt diff --git a/tests/amsterdam/eip7954_increase_max_contract_size/eip_checklist_external_coverage.txt b/tests/amsterdam/eip7954_increase_max_contract_size/eip_checklist_external_coverage.txt new file mode 100644 index 00000000000..2564b4fa808 --- /dev/null +++ b/tests/amsterdam/eip7954_increase_max_contract_size/eip_checklist_external_coverage.txt @@ -0,0 +1,3 @@ +general/code_coverage/eels = EIP-7954 adds only the raised MAX_CODE_SIZE and MAX_INIT_CODE_SIZE constants in forks/amsterdam/vm/interpreter.py; their four comparisons (creation-transaction check in transactions.py, CREATE and CREATE2 checks in vm/instructions/system.py, code deposit check in interpreter.py) are each driven in both directions by this suite +general/code_coverage/test_coverage = Fill the suite with `uv run fill --cov tests/amsterdam/eip7954_increase_max_contract_size`; every negative case has a positive sibling one byte smaller with the same setup, and every factory or caller slot is pre-set to a sentinel the failing path must overwrite, so a case cannot pass by never running +general/code_coverage/missed_lines = No EIP-7954 lines are excluded; the raised limits are constants compared in four places, all covered at the limit and one byte over it diff --git a/tests/amsterdam/eip7954_increase_max_contract_size/eip_checklist_not_applicable.txt b/tests/amsterdam/eip7954_increase_max_contract_size/eip_checklist_not_applicable.txt new file mode 100644 index 00000000000..41dad336de4 --- /dev/null +++ b/tests/amsterdam/eip7954_increase_max_contract_size/eip_checklist_not_applicable.txt @@ -0,0 +1,14 @@ +opcode = EIP does not introduce or modify an opcode +precompile = EIP does not introduce a precompile +removed_precompile = EIP does not remove a precompile +system_contract = EIP does not introduce a system contract +transaction_type = EIP does not introduce a new transaction type +block_header_field = EIP does not add any new block header fields +block_body_field = EIP does not add any new block body fields +gas_cost_changes = EIP only raises the code and initcode size limits; the initcode word cost and the per-byte code deposit charge keep their existing prices +gas_refunds_changes = EIP does not change any refund rules +blob_count_changes = EIP does not introduce any blob count changes +execution_layer_request = EIP does not introduce an execution layer request +new_transaction_validity_constraint = The creation-transaction initcode size limit exists since EIP-3860; this EIP only raises its threshold (see modified_transaction_validity_constraint) +block_level_constraint = The code and initcode size limits apply per account and per transaction, not to a block +general/code_coverage/second_client = Optional diff --git a/tests/amsterdam/eip7954_increase_max_contract_size/spec.py b/tests/amsterdam/eip7954_increase_max_contract_size/spec.py index bef566b2240..618bc6ca8de 100644 --- a/tests/amsterdam/eip7954_increase_max_contract_size/spec.py +++ b/tests/amsterdam/eip7954_increase_max_contract_size/spec.py @@ -13,5 +13,5 @@ class ReferenceSpec: ref_spec_7954 = ReferenceSpec( git_path="EIPS/eip-7954.md", - version="1dc9bc870f864d7ad1095fc73ba8ca098d02c732", + version="2e62d6a88aa596e4efbf3592cda7706f8a51dac3", ) diff --git a/tests/amsterdam/eip7954_increase_max_contract_size/test_cases.md b/tests/amsterdam/eip7954_increase_max_contract_size/test_cases.md index 786f01340da..264a99277e2 100644 --- a/tests/amsterdam/eip7954_increase_max_contract_size/test_cases.md +++ b/tests/amsterdam/eip7954_increase_max_contract_size/test_cases.md @@ -2,24 +2,31 @@ | Function Name | Goal | Setup | Expectation | Status | |---------------|------|-------|-------------|--------| -| `test_max_code_size` | Enforce new `MAX_CODE_SIZE` boundary for contract creation transactions | Alice deploys contracts with runtime code at the new max and one byte over. | New max: contract deployed. Over max: deployment fails. | ✅ Completed | -| `test_max_code_size_via_create` | Enforce new `MAX_CODE_SIZE` boundary via CREATE/CREATE2 opcodes | Same as above but deployment is done through a factory contract using CREATE and CREATE2. | New max: child contract deployed. Over max: child contract does not exist. | ✅ Completed | -| `test_max_initcode_size` | Enforce new `MAX_INITCODE_SIZE` boundary for contract creation transactions | Alice sends creation transactions with initcode at the new max and one byte over. | New max: transaction accepted, contract deployed. Over max: transaction rejected. | ✅ Completed | -| `test_max_initcode_size_via_create` | Enforce new `MAX_INITCODE_SIZE` boundary via CREATE/CREATE2 opcodes | Same as above but initcode is passed through a factory contract using CREATE and CREATE2. | New max: child contract deployed. Over max: CREATE returns 0, child contract does not exist. | ✅ Completed | -| `test_max_initcode_size_gas_metering` | Verify initcode gas metering at the new max (transaction level) | Alice sends a creation transaction with max-size initcode. Gas limit set to exact intrinsic cost, then one short. | Exact gas: contract deployed. One short: transaction rejected. | ✅ Completed | -| `test_max_code_size_deposit_gas` | Verify code deposit gas is charged correctly at the new max | Alice deploys a contract with exactly `MAX_CODE_SIZE` bytes. Gas set to exact deposit cost, then one short. | Exact gas: contract deployed. One short: deployment fails (out of gas during code deposit). | ✅ Completed | +| `test_max_code_size` | Enforce new `MAX_CODE_SIZE` boundary for contract creation transactions | Alice deploys contracts with runtime code one byte over the previous max, one byte under the new max, at the new max and one byte over the new max. | All sizes through the new max deploy; one byte over the new max fails, burning the execution allowance so the receipt shows the transaction gas limit cap. | ✅ Completed | +| `test_max_code_size_via_create` | Enforce new `MAX_CODE_SIZE` boundary via CREATE/CREATE2 opcodes | Same as above but deployment is done through a factory contract using CREATE and CREATE2, with a sentinel pre-set in the factory's storage slot. | All sizes through the new max deploy. One byte over makes the create opcode push zero and the child does not exist. | ✅ Completed | +| `test_max_initcode_size` | Enforce new `MAX_INITCODE_SIZE` boundary for contract creation transactions | Alice sends creation transactions with initcode one byte over the previous max, one byte under the new max, at the new max and one byte over the new max. | All sizes through the new max are accepted; one byte over the new max is rejected. | ✅ Completed | +| `test_max_initcode_size_via_create` | Enforce new `MAX_INITCODE_SIZE` boundary via CREATE/CREATE2 opcodes | Same as above but initcode is passed through a factory contract using CREATE and CREATE2, with a sentinel pre-set in the factory's storage slot. | All sizes through the new max create the child. One byte over aborts the factory frame before a child frame runs, preserving the sentinel and burning the whole gas allowance. | ✅ Completed | +| `test_max_initcode_size_calldata_floor` | Enforce the calldata floor a max-size initcode creation transaction must cover, and pin that the floor is also what it pays | Alice sends a creation transaction with max-size initcode, with the gas limit one gas below the calldata floor, exactly at it, and with slack above it. | Below: transaction rejected for missing the floor. At and above: contract deployed, and the receipt shows the floor charged, rather than the gas limit or the much smaller amount the deployment spends. | ✅ Completed | +| `test_max_code_size_deposit_gas` | Verify code deposit gas, and the initcode word cost, are charged correctly at the new maxima | Alice deploys a contract with exactly `MAX_CODE_SIZE` bytes, from minimal initcode and from initcode padded to `MAX_INITCODE_SIZE`. Gas set to the exact intrinsic, execution and deposit cost, then one short. | Exact gas: contract deployed. One short: deployment fails (out of gas during code deposit). | ✅ Completed | | `test_max_code_size_external_opcodes` | Verify external code opcodes work with max-size contracts | Deterministically pre-deploy a max-size self-checking contract. Call it to run EXTCODESIZE, EXTCODEHASH, and EXTCODECOPY on itself via ADDRESS. | Each opcode returns the correct value for the max-size contract. | ✅ Completed | +| `test_max_code_size_external_code_bounds` | Verify external code opcodes address a max-size contract from another account across its whole range | A checker contract reads EXTCODESIZE and EXTCODEHASH of a `MAX_CODE_SIZE` target, copies the full code, then copies the last 32 bytes and 32 bytes starting at the code size. | Size, hash and full-copy hash match the target. The last-word copy returns the final code bytes; the read starting at the code size zero-fills. | ✅ Completed | | `test_max_code_size_self_opcodes` | Verify self code opcodes work with max-size contracts | Pre-deploy a max-size contract with CODESIZE and CODECOPY checker logic. Call via DELEGATECALL so opcodes operate on the large contract's own code. | CODESIZE returns the correct length, CODECOPY produces the correct hash. | ✅ Completed | -| `test_max_code_size_high_jumpdest` | Enforce JUMP destination validity and code execution past the old size limits | Deploy a `MAX_CODE_SIZE` contract that jumps near the new limit (far beyond the old 24 KiB code and 48 KiB initcode limits), to a real `JUMPDEST` or to a `PUSH1` byte, and call it via a caller that records the call result. | Valid `JUMPDEST`: jump succeeds, the contract executes at the high offset and stores a sentinel. Non-`JUMPDEST`: jump is rejected, call fails, nothing is stored. | ✅ Completed | -| `test_max_code_size_jumpdest_in_immediate` | Verify jumpdest analysis classifies a `0x5B` immediate byte at max code size | Place a `0x5B` as the last byte of a `MAX_CODE_SIZE` contract, right after a `PUSH1`/`DUPN`/`SWAPN`/`EXCHANGE`, and jump to it. | `PUSH1`: `0x5B` is data, skipped, jump rejected. `DUPN`/`SWAPN`/`EXCHANGE`: per EIP-8024 `0x5B` is an invalid immediate, kept as a `JUMPDEST`, jump accepted. | ✅ Completed | +| `test_max_code_size_via_delegation` | Run a max-size contract through an EIP-7702 delegation | An EOA delegates to a `MAX_CODE_SIZE` contract that stores CODESIZE, the hash of its CODECOPY output and EXTCODESIZE of ADDRESS; call the EOA. | CODESIZE and the copy hash match the full target code; EXTCODESIZE returns the length of the delegation designation. | ✅ Completed | +| `test_max_code_size_linear_execution` | Execute a max-size contract end to end without a jump | Deploy `MAX_CODE_SIZE` JUMPDESTs and call it at the transaction gas limit cap. | The call halts at the end of the code and the receipt charges one gas per byte over the intrinsic cost. | ✅ Completed | +| `test_max_code_size_high_jumpdest` | Enforce JUMP destination validity and code execution past the old size limits | Deploy a `MAX_CODE_SIZE` contract that stores a flag and jumps to its last byte (far beyond the old 24 KiB code and 48 KiB initcode limits), a real `JUMPDEST` or a bare `STOP`, or to `MAX_CODE_SIZE` itself, one past the end; call it via a caller that records the call result. | Valid `JUMPDEST`: jump succeeds and the prefix store survives. Non-`JUMPDEST` byte or one past the end: jump is rejected, call fails, nothing is stored. | ✅ Completed | +| `test_max_code_size_jumpdest_in_immediate` | Verify jumpdest analysis classifies a `0x5B` immediate byte at max code size | Place a `0x5B` as the last byte of a `MAX_CODE_SIZE` contract, right after a `PUSH1`/`DUPN`/`SWAPN`/`EXCHANGE`, or after a `PUSH2` that has no second immediate byte, and jump to it. | `PUSH1` and truncated `PUSH2`: `0x5B` is data, skipped, jump rejected. `DUPN`/`SWAPN`/`EXCHANGE`: per EIP-8024 `0x5B` is an invalid immediate, kept as a `JUMPDEST`, jump accepted. | ✅ Completed | | `test_max_code_size_with_max_initcode` | Deploy max-size code when initcode is also at max size | Alice deploys a contract with `MAX_CODE_SIZE` bytes of runtime code using initcode padded to `MAX_INITCODE_SIZE`. | Contract deployed with the full max-size runtime code. | ✅ Completed | +| `test_max_code_size_with_max_initcode_via_create` | Deploy max-size code from max-size initcode via CREATE/CREATE2 opcodes | A factory stores a `RETURN` of `MAX_CODE_SIZE` zeroed memory bytes and runs CREATE/CREATE2 over `MAX_INITCODE_SIZE` bytes of memory. | Child contract deployed with `MAX_CODE_SIZE` bytes of runtime code. | ✅ Completed | +| `test_max_initcode_size_code_opcodes` | Verify self code opcodes see a max-size initcode in full while it runs | A factory runs CREATE over `MAX_INITCODE_SIZE` bytes of memory; the initcode stores its own CODESIZE and the hash of its CODECOPY output. | CODESIZE returns `MAX_INITCODE_SIZE` and the hash matches the whole initcode. | ✅ Completed | +| `test_max_initcode_size_linear_execution` | Execute a max-size initcode end to end without a jump | A factory assembles `MAX_INITCODE_SIZE` bytes from two EXTCODECOPYs of a `MAX_CODE_SIZE` JUMPDEST contract, writes a store and RETURN into the last word, and runs CREATE over it. | The child exists with its store set, which only the tail past every JUMPDEST can write. | ✅ Completed | +| `test_max_initcode_size_high_jumpdest` | Enforce JUMP destination validity inside a max-size initcode | A factory runs CREATE over `MAX_INITCODE_SIZE` bytes of memory; the initcode jumps to its last bytes, either a real `JUMPDEST` or a zero byte, or to `MAX_INITCODE_SIZE` itself, one past the end. | Valid `JUMPDEST`: the initcode runs at the high offset and the child contract keeps its store. Zero byte or one past the end: the jump is rejected and the child contract does not exist. | ✅ Completed | | `test_warm_after_failed_create_over_max_code_size` | A failed CREATE/CREATE2 over max code size leaves the would-be address warm | A creator runs CREATE/CREATE2 whose initcode returns `MAX_CODE_SIZE + 1` bytes; a checker then measures the gas of a `BALANCE` on that address. | The address is warm: the post-RETURN size-check rejection still leaves it in the access list. | ✅ Completed | -| `test_max_code_size_fork_transition` | New `MAX_CODE_SIZE` activates exactly at the fork boundary | Before and after the fork, deploy a contract one byte over the parent fork's max code size (valid under the new limit; its initcode stays within both forks' initcode limits). | Pre-fork: deployment fails at code deposit (exceeds old limit). Post-fork: deployment succeeds. | ✅ Completed | -| `test_max_code_size_via_create_fork_transition` | New `MAX_CODE_SIZE` activates at the fork boundary via CREATE/CREATE2 opcodes | Same as above but deployment is done through a factory contract using CREATE and CREATE2. | Pre-fork: child contract does not exist. Post-fork: child contract deployed. | ✅ Completed | -| `test_max_initcode_size_fork_transition` | New `MAX_INITCODE_SIZE` activates exactly at the fork boundary for transactions | Before the fork, send a creation transaction with the new `MAX_INITCODE_SIZE` bytes of initcode. After the fork, send the same transaction. | Pre-fork: block rejected (initcode exceeds old limit). Post-fork: transaction accepted, contract deployed. | ✅ Completed | -| `test_max_initcode_size_via_create_fork_transition` | New `MAX_INITCODE_SIZE` activates at the fork boundary via CREATE/CREATE2 opcodes | Same as above but initcode is passed through a factory contract using CREATE and CREATE2. | Pre-fork: CREATE fails (initcode exceeds old limit). Post-fork: child contract deployed. | ✅ Completed | +| `test_max_code_size_fork_transition` | Each block is held to the `MAX_CODE_SIZE` active in it | Before and after the fork, send a creation transaction whose short initcode returns runtime code at the parent fork's max, one byte over it, at the new max, and one byte over the new max. | A block deploys the contract only when the size fits the limit active in that block; otherwise the code deposit fails, nothing is deployed and the block records the transaction gas limit cap. | ✅ Completed | +| `test_max_code_size_via_create_fork_transition` | Each block is held to the active `MAX_CODE_SIZE` via CREATE/CREATE2 opcodes | The same four boundaries as the transaction path, deployed through a factory with a sentinel pre-set in its storage slot. | Sizes within the limit active in that block: child contract created. Oversized: the create opcode pushes zero over the sentinel and the child contract does not exist. | ✅ Completed | +| `test_max_initcode_size_fork_transition` | Each block validates creation transactions against the `MAX_INITCODE_SIZE` active in it | Before and after the fork, send a creation transaction with initcode at the parent fork's max, one byte over it, at the new max, and one byte over the new max. | A block is accepted only when the initcode fits the limit active in that block; otherwise the transaction is rejected and the block is invalid. | ✅ Completed | +| `test_max_initcode_size_via_create_fork_transition` | Each block is held to the active `MAX_INITCODE_SIZE` via CREATE/CREATE2 opcodes | The same four boundaries as the transaction path, with the initcode built from zeroed factory memory and a sentinel pre-set in the factory's storage slot. | Sizes within the limit active in that block: child contract created. Oversized: the create opcode aborts the factory frame before any child frame runs, leaving the sentinel in place and burning the whole gas allowance. | ✅ Completed | | `test_max_code_size_with_max_initcode_fork_transition` | Both new limits activate together at the fork boundary | Before the fork, deploy max code with max initcode. After the fork, attempt the same deployment. | Pre-fork: block rejected (initcode exceeds old limit). Post-fork: contract deployed with max-size runtime code. | ✅ Completed | -| `test_parent_max_code_size_across_fork` | Old `MAX_CODE_SIZE` still works on both sides of the transition | Before and after the fork, deploy a contract with the old `MAX_CODE_SIZE` bytes of runtime code. | Both deployments succeed. The old limit remains valid after the fork. | ✅ Completed | +| `test_max_code_size_with_max_initcode_via_create_fork_transition` | Both new limits activate together at the fork boundary via CREATE/CREATE2 opcodes | Before and after the fork, a factory runs CREATE/CREATE2 over `MAX_INITCODE_SIZE` bytes of memory whose initcode returns `MAX_CODE_SIZE` bytes. | Pre-fork: the initcode limit binds first, so the create opcode aborts the factory frame and the sentinel survives. Post-fork: child contract deployed with `MAX_CODE_SIZE` bytes of runtime code. | ✅ Completed | | `test_over_max_code_size_mainnet` | Deploying above the new limit fails on mainnet | Alice deploys a contract with `MAX_CODE_SIZE + 1` bytes of runtime code. | Contract does not exist (deployment fails during code deposit). | ✅ Completed | | `test_over_max_initcode_size_mainnet` | Oversized initcode creation is rejected on mainnet | Alice sends a creation transaction with `MAX_INITCODE_SIZE + 1` bytes of initcode. | Transaction rejected. No contract deployed. | ✅ Completed | | `test_max_code_size_with_max_initcode_mainnet` | Verify opcodes on a max-size contract on mainnet | Call the deterministic max-size self-checking contract. It queries EXTCODESIZE, EXTCODEHASH, and EXTCODECOPY on itself via ADDRESS. | Each opcode returns the correct value for the max-size contract. | ✅ Completed | diff --git a/tests/amsterdam/eip7954_increase_max_contract_size/test_fork_transition.py b/tests/amsterdam/eip7954_increase_max_contract_size/test_fork_transition.py index 90aabfa5560..d0836bb165f 100644 --- a/tests/amsterdam/eip7954_increase_max_contract_size/test_fork_transition.py +++ b/tests/amsterdam/eip7954_increase_max_contract_size/test_fork_transition.py @@ -6,7 +6,7 @@ exactly at the EIP7954 fork boundary (timestamp 15,000). """ -from typing import Any +from typing import Any, Callable import pytest from execution_testing import ( @@ -14,6 +14,8 @@ Alloc, Block, BlockchainTestFiller, + EIPChecklist, + Header, Initcode, Op, Transaction, @@ -21,6 +23,7 @@ TransitionFork, compute_create_address, ) +from execution_testing import Macros as Om from .spec import ref_spec_7954 @@ -29,273 +32,313 @@ pytestmark = pytest.mark.valid_at_transition_to("EIP7954") -CREATE2_SALT = 0xC0FFEE +PRE_FORK_TIMESTAMP = 14_999 +POST_FORK_TIMESTAMP = 15_000 +SENTINEL = 0xFF +"""Pre-set storage value that only a store which actually ran can replace.""" + +@pytest.mark.parametrize( + "code_size", + [ + pytest.param( + lambda f: f.transitions_from().max_code_size(), + id="at_parent_max", + ), + pytest.param( + lambda f: f.transitions_from().max_code_size() + 1, + id="over_parent_max", + ), + pytest.param( + lambda f: f.transitions_to().max_code_size(), + id="at_max", + ), + pytest.param( + lambda f: f.transitions_to().max_code_size() + 1, + id="over_max", + ), + ], +) def test_max_code_size_fork_transition( blockchain_test: BlockchainTestFiller, pre: Alloc, fork: TransitionFork, + code_size: Callable[[TransitionFork], int], ) -> None: - """Ensure the new max code size limit activates at the fork boundary.""" - parent = fork.transitions_from() - assert parent is not None, "Parent fork must be defined for this test" - code_size = parent.max_code_size() + 1 - deploy_code = Op.JUMPDEST * code_size - initcode = Initcode(deploy_code=deploy_code) - - alice = pre.fund_eoa() - bob = pre.fund_eoa() - - create_address_pre = compute_create_address(address=alice, nonce=0) - create_address_post = compute_create_address(address=bob, nonce=0) - - blocks = [ - Block( - timestamp=14_999, - txs=[ - Transaction( - sender=alice, - to=None, - data=initcode, - ) - ], - ), - Block( - timestamp=15_000, - txs=[ - Transaction( - sender=bob, - to=None, - data=initcode, - ) - ], - ), - ] - - post: dict[Any, Account | None] = { - create_address_pre: Account.NONEXISTENT, - create_address_post: Account(code=deploy_code), - } + """ + Ensure a creation transaction is held to the code size limit active in + its own block. + """ + size = code_size(fork) + # Memory is zeroed, so the deployed code needs no initcode payload. + initcode = Op.RETURN(offset=0, size=size) + + blocks = [] + post: dict[Any, Account | None] = {} + for timestamp in (PRE_FORK_TIMESTAMP, POST_FORK_TIMESTAMP): + sender = pre.fund_eoa() + block_fork = fork.fork_at(timestamp=timestamp) + deployed = size <= block_fork.max_code_size() + # A rejected deposit halts the frame and burns the execution + # allowance, so the block records exactly the cap: pre-fork + # because the transaction is capped, post-fork because the + # reservoir is handed back. + gas_limit_cap = block_fork.transaction_gas_limit_cap() + assert gas_limit_cap is not None + blocks.append( + Block( + timestamp=timestamp, + txs=[Transaction(sender=sender, to=None, data=initcode)], + header_verify=( + None if deployed else Header(gas_used=gas_limit_cap) + ), + ) + ) + post[compute_create_address(address=sender, nonce=0)] = ( + Account(code=b"\x00" * size) if deployed else Account.NONEXISTENT + ) blockchain_test(pre=pre, blocks=blocks, post=post) @pytest.mark.parametrize("create_opcode", [Op.CREATE, Op.CREATE2]) +@pytest.mark.parametrize( + "code_size", + [ + pytest.param( + lambda f: f.transitions_from().max_code_size(), + id="at_parent_max", + ), + pytest.param( + lambda f: f.transitions_from().max_code_size() + 1, + id="over_parent_max", + ), + pytest.param( + lambda f: f.transitions_to().max_code_size(), + id="at_max", + ), + pytest.param( + lambda f: f.transitions_to().max_code_size() + 1, + id="over_max", + ), + ], +) def test_max_code_size_via_create_fork_transition( blockchain_test: BlockchainTestFiller, pre: Alloc, fork: TransitionFork, + code_size: Callable[[TransitionFork], int], create_opcode: Op, ) -> None: - """Ensure the new max code size limit activates at the fork via opcodes.""" - parent = fork.transitions_from() - assert parent is not None, "Parent fork must be defined for this test" - code_size = parent.max_code_size() + 1 - deploy_code = Op.JUMPDEST * code_size - initcode = Initcode(deploy_code=deploy_code) - initcode_bytes = bytes(initcode) - - alice = pre.fund_eoa() - bob = pre.fund_eoa() + """ + Ensure a create opcode is held to the code size limit active in its own + block. + """ + size = code_size(fork) + initcode_bytes = bytes(Op.RETURN(offset=0, size=size)) create_call = ( - create_opcode( - value=0, offset=0, size=Op.CALLDATASIZE, salt=CREATE2_SALT - ) + create_opcode(value=0, offset=0, size=len(initcode_bytes), salt=0) if create_opcode == Op.CREATE2 - else create_opcode(value=0, offset=0, size=Op.CALLDATASIZE) + else create_opcode(value=0, offset=0, size=len(initcode_bytes)) ) - factory_code = ( - Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) - + Op.SSTORE(0, create_call) - + Op.STOP + Om.MSTORE(initcode_bytes, 0) + Op.SSTORE(0, create_call) + Op.STOP ) - factory_pre = pre.deploy_contract(factory_code) - factory_post = pre.deploy_contract(factory_code) + blocks = [] + post: dict[Any, Account | None] = {} + for timestamp in (PRE_FORK_TIMESTAMP, POST_FORK_TIMESTAMP): + sender = pre.fund_eoa() + # A factory per block keeps both creations at the same nonce. + factory = pre.deploy_contract(factory_code, storage={0: SENTINEL}) + blocks.append( + Block( + timestamp=timestamp, + txs=[Transaction(sender=sender, to=factory)], + ) + ) + created = size <= fork.fork_at(timestamp=timestamp).max_code_size() + create_address = compute_create_address( + address=factory, + nonce=1, + initcode=initcode_bytes, + opcode=create_opcode, + ) + # The oversized code is only detected once the initcode returns, so + # the create opcode pushes zero over the sentinel and the factory + # keeps running. + post[factory] = Account(storage={0: create_address if created else 0}) + post[create_address] = ( + Account(code=b"\x00" * size) if created else Account.NONEXISTENT + ) - create_address_pre = compute_create_address( - address=factory_pre, - nonce=1, - salt=CREATE2_SALT, - initcode=initcode, - opcode=create_opcode, - ) - create_address_post = compute_create_address( - address=factory_post, - nonce=1, - salt=CREATE2_SALT, - initcode=initcode, - opcode=create_opcode, - ) + blockchain_test(pre=pre, blocks=blocks, post=post) - blocks = [ - Block( - timestamp=14_999, - txs=[ - Transaction( - sender=alice, - to=factory_pre, - data=initcode_bytes, - ) + +@pytest.mark.parametrize( + "initcode_size", + [ + pytest.param( + lambda f: f.transitions_from().max_initcode_size(), + id="at_parent_max", + marks=[ + EIPChecklist.ModifiedTransactionValidityConstraint.Test.ForkTransition.AcceptedBeforeFork(), + EIPChecklist.ModifiedTransactionValidityConstraint.Test.ForkTransition.AcceptedAfterFork(), ], ), - Block( - timestamp=15_000, - txs=[ - Transaction( - sender=bob, - to=factory_post, - data=initcode_bytes, - ) + pytest.param( + lambda f: f.transitions_from().max_initcode_size() + 1, + id="over_parent_max", + marks=[ + pytest.mark.exception_test, + EIPChecklist.ModifiedTransactionValidityConstraint.Test.ForkTransition.RejectedBeforeFork(), + EIPChecklist.ModifiedTransactionValidityConstraint.Test.ForkTransition.AcceptedAfterFork(), ], ), - ] - - post: dict[Any, Account | None] = { - create_address_pre: Account.NONEXISTENT, - create_address_post: Account(code=deploy_code), - } - - blockchain_test(pre=pre, blocks=blocks, post=post) - - -@pytest.mark.exception_test + pytest.param( + lambda f: f.transitions_to().max_initcode_size(), + id="at_max", + marks=pytest.mark.exception_test, + ), + pytest.param( + lambda f: f.transitions_to().max_initcode_size() + 1, + id="over_max", + marks=[ + pytest.mark.exception_test, + EIPChecklist.ModifiedTransactionValidityConstraint.Test.ForkTransition.RejectedAfterFork(), + ], + ), + ], +) def test_max_initcode_size_fork_transition( blockchain_test: BlockchainTestFiller, pre: Alloc, fork: TransitionFork, + initcode_size: Callable[[TransitionFork], int], ) -> None: - """Ensure the new max initcode size limit activates exactly at the fork.""" - initcode = Initcode( - deploy_code=Op.STOP, - initcode_length=fork.transitions_to().max_initcode_size(), - ) - - alice = pre.fund_eoa() - bob = pre.fund_eoa() - - create_address_post = compute_create_address(address=bob, nonce=0) - - initcode_too_large = TransactionException.INITCODE_SIZE_EXCEEDED - - blocks = [ - # Pre-fork: initcode at the new max exceeds the parent fork's limit, - # so the tx is rejected and the block is invalid. - Block( - timestamp=14_999, - txs=[ - Transaction( - sender=alice, - to=None, - data=initcode, - error=initcode_too_large, - ) - ], - exception=initcode_too_large, - ), - # Post-fork: the new limit is in effect, tx succeeds. - Block( - timestamp=15_000, - txs=[ - Transaction( - sender=bob, - to=None, - data=initcode, - ) - ], - ), - ] - - post: dict[Any, Account | None] = { - create_address_post: Account(code=Op.STOP), - } + """ + Ensure a creation transaction is validated against the initcode size + limit active in its own block. + """ + size = initcode_size(fork) + initcode = Initcode(deploy_code=Op.STOP, initcode_length=size) + + blocks = [] + post: dict[Any, Account | None] = {} + for timestamp in (PRE_FORK_TIMESTAMP, POST_FORK_TIMESTAMP): + sender = pre.fund_eoa() + accepted = ( + size <= fork.fork_at(timestamp=timestamp).max_initcode_size() + ) + error = ( + None if accepted else TransactionException.INITCODE_SIZE_EXCEEDED + ) + blocks.append( + Block( + timestamp=timestamp, + txs=[ + Transaction( + sender=sender, + to=None, + data=initcode, + error=error, + ) + ], + exception=error, + ) + ) + post[compute_create_address(address=sender, nonce=0)] = ( + Account(code=Op.STOP) if accepted else Account.NONEXISTENT + ) blockchain_test(pre=pre, blocks=blocks, post=post) @pytest.mark.parametrize("create_opcode", [Op.CREATE, Op.CREATE2]) +@pytest.mark.parametrize( + "initcode_size", + [ + pytest.param( + lambda f: f.transitions_from().max_initcode_size(), + id="at_parent_max", + ), + pytest.param( + lambda f: f.transitions_from().max_initcode_size() + 1, + id="over_parent_max", + ), + pytest.param( + lambda f: f.transitions_to().max_initcode_size(), + id="at_max", + ), + pytest.param( + lambda f: f.transitions_to().max_initcode_size() + 1, + id="over_max", + ), + ], +) def test_max_initcode_size_via_create_fork_transition( blockchain_test: BlockchainTestFiller, pre: Alloc, fork: TransitionFork, + initcode_size: Callable[[TransitionFork], int], create_opcode: Op, ) -> None: - """Ensure the new max initcode size limit activates at fork via opcodes.""" - initcode = Initcode( - deploy_code=Op.STOP, - initcode_length=fork.transitions_to().max_initcode_size(), - ) - initcode_bytes = bytes(initcode) - - alice = pre.fund_eoa() - bob = pre.fund_eoa() + """ + Ensure a create opcode is held to the initcode size limit active in its + own block. + """ + size = initcode_size(fork) + # The initcode returns no code, so only its leading bytes are written + # and the rest of the size comes from zeroed memory. + initcode_prologue = bytes(Op.RETURN(offset=0, size=0)) + initcode_bytes = initcode_prologue.ljust(size, b"\x00") create_call = ( - create_opcode( - value=0, offset=0, size=Op.CALLDATASIZE, salt=CREATE2_SALT - ) + create_opcode(value=0, offset=0, size=size, salt=0) if create_opcode == Op.CREATE2 - else create_opcode(value=0, offset=0, size=Op.CALLDATASIZE) + else create_opcode(value=0, offset=0, size=size) ) - factory_code = ( - Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) - + Op.SSTORE(0, create_call) - + Op.STOP + Om.MSTORE(initcode_prologue, 0) + Op.SSTORE(0, create_call) + Op.STOP ) - factory_pre = pre.deploy_contract(factory_code) - factory_post = pre.deploy_contract(factory_code) - - create_address_pre = compute_create_address( - address=factory_pre, - nonce=1, - salt=CREATE2_SALT, - initcode=initcode, - opcode=create_opcode, - ) - create_address_post = compute_create_address( - address=factory_post, - nonce=1, - salt=CREATE2_SALT, - initcode=initcode, - opcode=create_opcode, - ) - - blocks = [ - Block( - timestamp=14_999, - txs=[ - Transaction( - sender=alice, - to=factory_pre, - data=initcode_bytes, - ) - ], - ), - Block( - timestamp=15_000, - txs=[ - Transaction( - sender=bob, - to=factory_post, - data=initcode_bytes, - ) - ], - ), - ] - - # Pre-fork: CREATE returns 0 (initcode exceeds parent fork limit) - # Post-fork: CREATE succeeds - post: dict[Any, Account | None] = { - factory_pre: Account(storage={0: 0}), - create_address_pre: Account.NONEXISTENT, - factory_post: Account(storage={0: create_address_post}), - create_address_post: Account(code=Op.STOP), - } + blocks = [] + post: dict[Any, Account | None] = {} + for timestamp in (PRE_FORK_TIMESTAMP, POST_FORK_TIMESTAMP): + sender = pre.fund_eoa() + factory = pre.deploy_contract(factory_code, storage={0: SENTINEL}) + block_fork = fork.fork_at(timestamp=timestamp) + created = size <= block_fork.max_initcode_size() + # The abort is an exceptional halt of the factory, so the whole + # execution allowance burns and the block records exactly the cap. + gas_limit_cap = block_fork.transaction_gas_limit_cap() + assert gas_limit_cap is not None + blocks.append( + Block( + timestamp=timestamp, + txs=[Transaction(sender=sender, to=factory)], + header_verify=( + None if created else Header(gas_used=gas_limit_cap) + ), + ) + ) + create_address = compute_create_address( + address=factory, + nonce=1, + initcode=initcode_bytes, + opcode=create_opcode, + ) + # An oversized initcode aborts the create opcode before any child + # frame runs, taking the factory frame down with it, so the sentinel + # survives. + post[factory] = Account( + storage={0: create_address if created else SENTINEL} + ) + post[create_address] = ( + Account(code=b"") if created else Account.NONEXISTENT + ) blockchain_test(pre=pre, blocks=blocks, post=post) @@ -322,7 +365,7 @@ def test_max_code_size_with_max_initcode_fork_transition( blocks = [ Block( - timestamp=14_999, + timestamp=PRE_FORK_TIMESTAMP, txs=[ Transaction( sender=alice, @@ -334,7 +377,7 @@ def test_max_code_size_with_max_initcode_fork_transition( exception=initcode_too_large, ), Block( - timestamp=15_000, + timestamp=POST_FORK_TIMESTAMP, txs=[ Transaction( sender=bob, @@ -352,51 +395,61 @@ def test_max_code_size_with_max_initcode_fork_transition( blockchain_test(pre=pre, blocks=blocks, post=post) -def test_parent_max_code_size_across_fork( +@pytest.mark.parametrize("create_opcode", [Op.CREATE, Op.CREATE2]) +def test_max_code_size_with_max_initcode_via_create_fork_transition( blockchain_test: BlockchainTestFiller, pre: Alloc, fork: TransitionFork, + create_opcode: Op, ) -> None: - """Ensure previous max code size works after transition.""" - parent = fork.transitions_from() - assert parent is not None, "Parent fork must be defined for this test" - - code_size = parent.max_code_size() - deploy_code = Op.JUMPDEST * code_size - initcode = Initcode(deploy_code=deploy_code) + """ + Ensure both new limits activate together at the fork boundary through + the create opcodes. + """ + max_code_size = fork.transitions_to().max_code_size() + max_initcode_size = fork.transitions_to().max_initcode_size() + initcode_prologue = bytes(Op.RETURN(offset=0, size=max_code_size)) + initcode_bytes = initcode_prologue.ljust(max_initcode_size, b"\x00") - alice = pre.fund_eoa() - bob = pre.fund_eoa() - - create_address_pre = compute_create_address(address=alice, nonce=0) - create_address_post = compute_create_address(address=bob, nonce=0) - - blocks = [ - Block( - timestamp=14_999, - txs=[ - Transaction( - sender=alice, - to=None, - data=initcode, - ) - ], - ), - Block( - timestamp=15_000, - txs=[ - Transaction( - sender=bob, - to=None, - data=initcode, - ) - ], - ), - ] + create_call = ( + create_opcode(value=0, offset=0, size=max_initcode_size, salt=0) + if create_opcode == Op.CREATE2 + else create_opcode(value=0, offset=0, size=max_initcode_size) + ) + factory_code = ( + Om.MSTORE(initcode_prologue, 0) + Op.SSTORE(0, create_call) + Op.STOP + ) - post: dict[Any, Account | None] = { - create_address_pre: Account(code=deploy_code), - create_address_post: Account(code=deploy_code), - } + blocks = [] + post: dict[Any, Account | None] = {} + for timestamp in (PRE_FORK_TIMESTAMP, POST_FORK_TIMESTAMP): + sender = pre.fund_eoa() + factory = pre.deploy_contract(factory_code, storage={0: SENTINEL}) + blocks.append( + Block( + timestamp=timestamp, + txs=[Transaction(sender=sender, to=factory)], + ) + ) + # The initcode limit binds first: pre-fork the create opcode aborts + # the factory frame before the returned code size is ever checked. + created = ( + max_initcode_size + <= fork.fork_at(timestamp=timestamp).max_initcode_size() + ) + create_address = compute_create_address( + address=factory, + nonce=1, + initcode=initcode_bytes, + opcode=create_opcode, + ) + post[factory] = Account( + storage={0: create_address if created else SENTINEL} + ) + post[create_address] = ( + Account(code=b"\x00" * max_code_size) + if created + else Account.NONEXISTENT + ) blockchain_test(pre=pre, blocks=blocks, post=post) diff --git a/tests/amsterdam/eip7954_increase_max_contract_size/test_max_code_size.py b/tests/amsterdam/eip7954_increase_max_contract_size/test_max_code_size.py index d6b52684e67..a27fd1cc74c 100644 --- a/tests/amsterdam/eip7954_increase_max_contract_size/test_max_code_size.py +++ b/tests/amsterdam/eip7954_increase_max_contract_size/test_max_code_size.py @@ -14,11 +14,16 @@ Initcode, Op, StateTestFiller, + Storage, Transaction, + TransactionReceipt, compute_create_address, keccak256, ) +from execution_testing import Macros as Om +from execution_testing.forks import Osaka +from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 from .spec import ref_spec_7954 REFERENCE_SPEC_GIT_PATH = ref_spec_7954.git_path @@ -26,9 +31,12 @@ pytestmark = pytest.mark.valid_from("EIP7954") -CREATE2_SALT = 0xC0FFEE +SENTINEL = 0xFF +"""Pre-set storage value that only a store which actually ran can replace.""" DEPLOY_CODE_SIZE_PARAMS = [ + pytest.param(lambda _: Osaka.max_code_size() + 1, id="over_previous_max"), + pytest.param(lambda f: f.max_code_size() - 1, id="under_max"), pytest.param(lambda f: f.max_code_size(), id="at_max"), pytest.param(lambda f: f.max_code_size() + 1, id="over_max"), ] @@ -59,6 +67,14 @@ def test_max_code_size( if code_size <= fork.max_code_size(): post[create_address] = Account(code=deploy_code) else: + # The oversized deposit halts the frame: the state gas reservoir + # is handed back and the whole execution allowance burns, so the + # sender pays exactly the cap. + gas_limit_cap = fork.transaction_gas_limit_cap() + assert gas_limit_cap is not None + tx.expected_receipt = TransactionReceipt( + cumulative_gas_used=gas_limit_cap + ) post[create_address] = Account.NONEXISTENT state_test(pre=pre, tx=tx, post=post) @@ -82,9 +98,7 @@ def test_max_code_size_via_create( alice = pre.fund_eoa() create_call = ( - create_opcode( - value=0, offset=0, size=Op.CALLDATASIZE, salt=CREATE2_SALT - ) + create_opcode(value=0, offset=0, size=Op.CALLDATASIZE, salt=0) if create_opcode == Op.CREATE2 else create_opcode(value=0, offset=0, size=Op.CALLDATASIZE) ) @@ -95,12 +109,11 @@ def test_max_code_size_via_create( + Op.STOP ) - factory = pre.deploy_contract(factory_code) + factory = pre.deploy_contract(factory_code, storage={0: SENTINEL}) create_address = compute_create_address( address=factory, nonce=1, - salt=CREATE2_SALT, initcode=initcode, opcode=create_opcode, ) @@ -111,6 +124,8 @@ def test_max_code_size_via_create( data=initcode_bytes, ) + # The oversized code is only detected once the initcode returns, so the + # create opcode pushes zero and the factory keeps running. created = code_size <= fork.max_code_size() post: dict[Any, Account | None] = { factory: Account(storage={0: create_address if created else 0}), @@ -130,15 +145,31 @@ def test_max_code_size_via_create( pytest.param(1, id="short_one_gas"), ], ) +@pytest.mark.parametrize( + "initcode_length", + [ + pytest.param(None, id="minimal_initcode"), + # Pins the initcode word cost over the whole new initcode range: the + # exact-fit gas limit only covers the deployment if every word of the + # padded initcode is charged. + pytest.param(lambda f: f.max_initcode_size(), id="max_initcode"), + ], +) def test_max_code_size_deposit_gas( state_test: StateTestFiller, pre: Alloc, fork: Fork, gas_shortfall: int, + initcode_length: Callable[[Fork], int] | None, ) -> None: """Ensure code deposit gas is charged correctly at the new max.""" deploy_code = Op.JUMPDEST * fork.max_code_size() - initcode = Initcode(deploy_code=deploy_code) + initcode = Initcode( + deploy_code=deploy_code, + initcode_length=( + initcode_length(fork) if initcode_length is not None else None + ), + ) alice = pre.fund_eoa() create_address = compute_create_address(address=alice, nonce=0) @@ -155,25 +186,38 @@ def test_max_code_size_deposit_gas( contract_creation=True, ) + exact_gas = ( + intrinsic_gas + + top_frame_state_gas + + initcode.evm_gas(fork) + + initcode.deployment_gas(fork) + ) tx = Transaction( sender=alice, to=None, data=initcode, - gas_limit=( - intrinsic_gas - + top_frame_state_gas - + initcode.evm_gas(fork) - + initcode.deployment_gas(fork) - - gas_shortfall - ), + gas_limit=exact_gas - gas_shortfall, ) - # With shortfall, code deposit OOGs: tx succeeds but - # contract is not deployed - post = { - create_address: Account(code=deploy_code) - if not gas_shortfall - else Account.NONEXISTENT, - } + + # The receipt pin below reads the cap, which only holds while the exact + # fit exceeds it and the deposit is funded from the reservoir. + gas_limit_cap = fork.transaction_gas_limit_cap() + assert gas_limit_cap is not None + assert exact_gas > gas_limit_cap + + post: dict[Any, Account | None] = {} + if gas_shortfall: + # The deposit halts the frame, burning the whole execution gas + # allowance while the state gas reservoir is handed back. + tx.expected_receipt = TransactionReceipt( + cumulative_gas_used=gas_limit_cap + ) + post[create_address] = Account.NONEXISTENT + else: + # Both gas pools land on exactly zero, so a misprice in any term + # shows up here. + tx.expected_receipt = TransactionReceipt(cumulative_gas_used=exact_gas) + post[create_address] = Account(code=deploy_code) state_test(pre=pre, tx=tx, post=post) @@ -278,6 +322,77 @@ def test_max_code_size_self_opcodes( state_test(pre=pre, tx=tx, post=post) +def test_max_code_size_via_delegation( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Ensure an EIP-7702 delegation runs a max-size contract in full. + + The delegated account's frame executes the target's code, so CODESIZE + and CODECOPY see all `MAX_CODE_SIZE` bytes, while EXTCODESIZE on + ADDRESS sees the delegation designation instead. + """ + logic = ( + Op.SSTORE(0, Op.CODESIZE) + + Op.CODECOPY(0, 0, Op.CODESIZE) + + Op.SSTORE(1, Op.SHA3(0, Op.CODESIZE)) + + Op.SSTORE(2, Op.EXTCODESIZE(Op.ADDRESS)) + + Op.STOP + ) + target_code = logic + Op.JUMPDEST * (fork.max_code_size() - len(logic)) + target = pre.deploy_contract(target_code) + delegated = pre.fund_eoa(delegation=target) + + tx = Transaction( + sender=pre.fund_eoa(), + to=delegated, + gas_limit=fork.transaction_gas_limit_cap(), + ) + + post = { + delegated: Account( + storage={ + 0: len(target_code), + 1: keccak256(bytes(target_code)), + 2: len(Spec7702.delegation_designation(target)), + } + ) + } + + state_test(pre=pre, tx=tx, post=post) + + +def test_max_code_size_linear_execution( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Ensure a max-size contract executes from its first byte to its last + without a jump, then halts at the end of the code. + + The body is `MAX_CODE_SIZE` JUMPDESTs, so the receipt pins one gas per + byte on top of the intrinsic cost: a client that stops short, or fails + past the old limit, charges a different amount. + """ + target_code = Op.JUMPDEST * fork.max_code_size() + target = pre.deploy_contract(target_code) + + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()() + tx = Transaction( + sender=pre.fund_eoa(), + to=target, + gas_limit=fork.transaction_gas_limit_cap(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=intrinsic_gas + target_code.gas_cost(fork) + ), + ) + + state_test(pre=pre, tx=tx, post={target: Account(code=target_code)}) + + @pytest.mark.parametrize( "create_opcode", [ @@ -302,15 +417,13 @@ def test_warm_after_failed_create_over_max_code_size( initcode = Op.RETURN(offset=0, size=fork.max_code_size() + 1) initcode_bytes = bytes(initcode) if create_opcode == Op.CREATE2: - salt = CREATE2_SALT create_call = create_opcode( value=0, offset=0, size=len(initcode_bytes), - salt=salt, + salt=0, ) else: - salt = 0 create_call = create_opcode( value=0, offset=0, size=len(initcode_bytes) ) @@ -324,7 +437,6 @@ def test_warm_after_failed_create_over_max_code_size( contract_address = compute_create_address( address=creator_address, nonce=1, - salt=salt, initcode=initcode_bytes, opcode=create_opcode, ) @@ -344,9 +456,13 @@ def test_warm_after_failed_create_over_max_code_size( + Op.STOP ) + # Fund the oversized deposit's state gas so the size check is the only + # thing standing between the initcode's RETURN and a deployed contract. tx = Transaction( to=entry_address, - gas_limit=fork.transaction_gas_limit_cap(), + state_gas_reservoir=fork.create_state_gas( + code_size=fork.max_code_size() + 1 + ), sender=pre.fund_eoa(), ) @@ -360,59 +476,65 @@ def test_warm_after_failed_create_over_max_code_size( @pytest.mark.parametrize( - "valid_jumpdest", + "dest_delta,tail,valid_jump", [ - pytest.param(True, id="valid_high_jumpdest"), - pytest.param(False, id="invalid_high_dest"), + pytest.param(-1, Op.JUMPDEST, True, id="valid_high_jumpdest"), + # A bare STOP, not a JUMPDEST: a client that wrongly accepts the + # jump halts normally and keeps the prefix store. + pytest.param(-1, Op.STOP, False, id="invalid_high_dest"), + # One past the last byte, which is itself a JUMPDEST: the code + # bounds must reject the jump before any JUMPDEST lookup. + pytest.param(0, Op.JUMPDEST, False, id="invalid_past_end"), ], ) def test_max_code_size_high_jumpdest( state_test: StateTestFiller, pre: Alloc, fork: Fork, - valid_jumpdest: bool, + dest_delta: int, + tail: Bytecode, + valid_jump: bool, ) -> None: """ Ensure jump destination validity is enforced past the old size limits. - Deploy a `MAX_CODE_SIZE` contract that stores a sentinel and then jumps - near the new limit, far beyond the old 24 KiB code and 48 KiB initcode + Deploy a `MAX_CODE_SIZE` contract that stores a flag and then jumps to + the new limit, far beyond the old 24 KiB code and 48 KiB initcode limits, then call it through a caller that records the call's success: - - ``valid_high_jumpdest``: the target byte is a real ``JUMPDEST``, so the - jump succeeds, the frame returns, and the sentinel store is kept. - - ``invalid_high_dest``: the target byte is a ``STOP`` (not a + - ``valid_high_jumpdest``: the last byte is a real ``JUMPDEST``, so the + jump succeeds, the frame returns, and the prefix store is kept. + - ``invalid_high_dest``: the last byte is a ``STOP`` (not a ``JUMPDEST``), so the jump is rejected, the frame reverts, and the - sentinel store is discarded. + prefix store is discarded. + - ``invalid_past_end``: the target is `MAX_CODE_SIZE` itself, one past + the last byte, which is again a real ``JUMPDEST``. The code bounds + must reject the jump; a lookup that clamps or wraps would accept it. A client whose jumpdest analysis or code execution does not cover the - full new code range fails one of the two cases. No existing test - executes a contract at a program counter beyond the old limit. + full new code range fails one of the cases. No existing test executes + a contract at a program counter beyond the old limit. """ - if valid_jumpdest: - tail = Op.JUMPDEST - else: - # A bare STOP, not a JUMPDEST: jumping here is invalid. A client that - # wrongly accepts it halts normally and keeps the prefix store (1). - tail = Op.STOP - - dest = fork.max_code_size() - len(tail) + max_code_size = fork.max_code_size() + dest = max_code_size + dest_delta push_size = (dest.bit_length() + 7) // 8 push_op = getattr(Op, f"PUSH{push_size}") prefix = Op.SSTORE(0, 1) + push_op(dest) + Op.JUMP - target_code = prefix + Op.INVALID * (dest - len(prefix)) + tail - assert len(target_code) == fork.max_code_size() + filler_len = max_code_size - len(prefix) - len(tail) + target_code = prefix + Op.INVALID * filler_len + tail + assert len(target_code) == max_code_size target = pre.deploy_contract(target_code) caller = pre.deploy_contract( - Op.SSTORE(0, Op.CALL(gas=Op.GAS, address=target)) + Op.STOP + Op.SSTORE(0, Op.CALL(gas=Op.GAS, address=target)) + Op.STOP, + storage={0: SENTINEL}, ) tx = Transaction(sender=pre.fund_eoa(), to=caller) # Valid: jump completes, call succeeds (1), and the store is kept. - # Invalid: jump reverts, call fails (0), and nothing is stored. - stored = 1 if valid_jumpdest else 0 + # Invalid: jump reverts, the call fails and writes 0 over the sentinel. + stored = 1 if valid_jump else 0 post = { caller: Account(storage={0: stored}), target: Account(storage={0: stored}), @@ -430,13 +552,20 @@ def test_max_code_size_high_jumpdest( pytest.param( Op.EXCHANGE[b"\x5b"], True, id="exchange_immediate_accepted" ), + # A PUSH2 with only one immediate byte left before the end of the + # code: the analysis skips past the end and never marks the byte. + pytest.param( + bytes(Op.PUSH2) + b"\x5b", + False, + id="push2_truncated_data_rejected", + ), ], ) def test_max_code_size_jumpdest_in_immediate( state_test: StateTestFiller, pre: Alloc, fork: Fork, - tail: Bytecode, + tail: Bytecode | bytes, accepted: bool, ) -> None: """ @@ -451,6 +580,9 @@ def test_max_code_size_jumpdest_in_immediate( - ``dupn``/``swapn``/``exchange``: per EIP-8024 `0x5B` is an *invalid* immediate for these opcodes, so it is not skipped and stays a valid `JUMPDEST`, and the jump is accepted. + - ``push2_truncated``: the `PUSH2` has a single immediate byte left, + so the analysis skips past the end of the code; the `0x5B` is data + and the jump is rejected. Exercises the immediate-skipping branches of jumpdest analysis well past the old 24 KiB code and 48 KiB initcode limits. @@ -466,13 +598,14 @@ def test_max_code_size_jumpdest_in_immediate( target = pre.deploy_contract(target_code) caller = pre.deploy_contract( - Op.SSTORE(0, Op.CALL(gas=Op.GAS, address=target)) + Op.STOP + Op.SSTORE(0, Op.CALL(gas=Op.GAS, address=target)) + Op.STOP, + storage={0: SENTINEL}, ) tx = Transaction(sender=pre.fund_eoa(), to=caller) # Accepted: jump completes, the call succeeds (1), the store is kept. - # Rejected: jump reverts, the call fails (0), nothing is stored. + # Rejected: jump reverts, the call fails and writes 0 over the sentinel. stored = 1 if accepted else 0 post = { caller: Account(storage={0: stored}), @@ -480,3 +613,86 @@ def test_max_code_size_jumpdest_in_immediate( } state_test(pre=pre, tx=tx, post=post) + + +def test_max_code_size_external_code_bounds( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Ensure the external code opcodes address a max-size contract from + another account, including its last word and the zero fill past its end. + """ + max_code_size = fork.max_code_size() + target_code = Op.JUMPDEST * (max_code_size - 1) + Op.INVALID + target = pre.deploy_contract(target_code) + code_hash = keccak256(bytes(target_code)) + last_word = int.from_bytes(bytes(target_code)[-32:], "big") + + storage = Storage() + checker = pre.deploy_contract( + Op.SSTORE(storage.store_next(max_code_size), Op.EXTCODESIZE(target)) + + Op.SSTORE(storage.store_next(code_hash), Op.EXTCODEHASH(target)) + + Op.EXTCODECOPY(target, 0, 0, Op.EXTCODESIZE(target)) + + Op.SSTORE( + storage.store_next(code_hash), + Op.SHA3(0, Op.EXTCODESIZE(target)), + ) + + Op.EXTCODECOPY(target, 0, max_code_size - 32, 32) + + Op.SSTORE(storage.store_next(last_word), Op.MLOAD(0)) + # A read starting at the code size zero-fills, clearing the word + # copied above. + + Op.EXTCODECOPY(target, 0, max_code_size, 32) + + Op.SSTORE(storage.store_next(1), Op.ISZERO(Op.MLOAD(0))) + + Op.STOP + ) + + tx = Transaction(sender=pre.fund_eoa(), to=checker) + + state_test(pre=pre, tx=tx, post={checker: Account(storage=storage)}) + + +@pytest.mark.with_all_create_opcodes() +def test_max_code_size_with_max_initcode_via_create( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + create_opcode: Op, +) -> None: + """ + Ensure max-size code deploys from max-size initcode through the create + opcodes. + """ + max_code_size = fork.max_code_size() + max_initcode_size = fork.max_initcode_size() + # Memory is zeroed, so both the deployed code and the initcode padding + # need no factory writes. + initcode_prefix = bytes(Op.RETURN(0, max_code_size)) + initcode_bytes = initcode_prefix.ljust(max_initcode_size, b"\x00") + + create_call = ( + create_opcode(value=0, offset=0, size=max_initcode_size, salt=0) + if create_opcode == Op.CREATE2 + else create_opcode(value=0, offset=0, size=max_initcode_size) + ) + factory = pre.deploy_contract( + Om.MSTORE(initcode_prefix, 0) + Op.SSTORE(0, create_call) + Op.STOP, + storage={0: SENTINEL}, + ) + + create_address = compute_create_address( + address=factory, + nonce=1, + initcode=initcode_bytes, + opcode=create_opcode, + ) + + tx = Transaction(sender=pre.fund_eoa(), to=factory) + + post: dict[Any, Account | None] = { + factory: Account(storage={0: create_address}), + create_address: Account(code=b"\x00" * max_code_size), + } + + state_test(pre=pre, tx=tx, post=post) diff --git a/tests/amsterdam/eip7954_increase_max_contract_size/test_max_initcode_size.py b/tests/amsterdam/eip7954_increase_max_contract_size/test_max_initcode_size.py index bd83b30c467..43c268a0434 100644 --- a/tests/amsterdam/eip7954_increase_max_contract_size/test_max_initcode_size.py +++ b/tests/amsterdam/eip7954_increase_max_contract_size/test_max_initcode_size.py @@ -1,7 +1,7 @@ """ Test [EIP-7954: Increase Maximum Contract Size](https://eips.ethereum.org/EIPS/eip-7954). -Tests for the increased maximum initcode size (64 KiB). +Tests for the increased maximum initcode size (128 KiB). """ from typing import Any, Callable @@ -10,14 +10,20 @@ from execution_testing import ( Account, Alloc, + BalAccountExpectation, + BlockAccessListExpectation, Fork, Initcode, Op, StateTestFiller, Transaction, TransactionException, + TransactionReceipt, compute_create_address, + keccak256, ) +from execution_testing import Macros as Om +from execution_testing.forks import Osaka from .spec import ref_spec_7954 @@ -26,14 +32,23 @@ pytestmark = pytest.mark.valid_from("EIP7954") -CREATE2_SALT = 0xC0FFEE +SENTINEL = 0xFF +"""Pre-set storage value that only a store which actually ran can replace.""" INITCODE_SIZE_PARAMS = [ + pytest.param( + lambda _: Osaka.max_initcode_size() + 1, id="over_previous_max" + ), + pytest.param(lambda f: f.max_initcode_size() - 1, id="under_max"), pytest.param(lambda f: f.max_initcode_size(), id="at_max"), pytest.param(lambda f: f.max_initcode_size() + 1, id="over_max"), ] TX_INITCODE_SIZE_PARAMS = [ + pytest.param( + lambda _: Osaka.max_initcode_size() + 1, id="over_previous_max" + ), + pytest.param(lambda f: f.max_initcode_size() - 1, id="under_max"), pytest.param(lambda f: f.max_initcode_size(), id="at_max"), pytest.param( lambda f: f.max_initcode_size() + 1, @@ -98,9 +113,7 @@ def test_max_initcode_size_via_create( alice = pre.fund_eoa() create_call = ( - create_opcode( - value=0, offset=0, size=Op.CALLDATASIZE, salt=CREATE2_SALT - ) + create_opcode(value=0, offset=0, size=Op.CALLDATASIZE, salt=0) if create_opcode == Op.CREATE2 else create_opcode(value=0, offset=0, size=Op.CALLDATASIZE) ) @@ -111,12 +124,11 @@ def test_max_initcode_size_via_create( + Op.STOP ) - factory = pre.deploy_contract(factory_code) + factory = pre.deploy_contract(factory_code, storage={0: SENTINEL}) create_address = compute_create_address( address=factory, nonce=1, - salt=CREATE2_SALT, initcode=initcode, opcode=create_opcode, ) @@ -128,62 +140,234 @@ def test_max_initcode_size_via_create( gas_limit=fork.transaction_gas_limit_cap(), ) - # Opcode-level: oversized initcode causes OutOfGasError - # (tx succeeds, CREATE returns 0) + # An oversized initcode aborts the create opcode before any child frame + # runs, taking the factory frame down with it, so the sentinel survives. created = size <= fork.max_initcode_size() post: dict[Any, Account | None] = { - factory: Account(storage={0: create_address if created else 0}), + factory: Account(storage={0: create_address if created else SENTINEL}), } + bal = None if created: post[create_address] = Account(code=Op.STOP) else: + # The child address is never computed, so it must be missing from + # the block access list, and the aborted factory frame leaves no + # changes of its own. The abort is an exceptional halt, so the + # whole gas allowance burns: a client that merely reverted the + # factory would leave the same state but refund the rest. + tx.expected_receipt = TransactionReceipt( + cumulative_gas_used=tx.gas_limit + ) post[create_address] = Account.NONEXISTENT + bal = BlockAccessListExpectation( + account_expectations={ + factory: BalAccountExpectation.empty(), + create_address: None, + } + ) - state_test(pre=pre, tx=tx, post=post) + state_test(pre=pre, tx=tx, post=post, expected_block_access_list=bal) @pytest.mark.inclusion_test @pytest.mark.parametrize( - "gas_shortfall", + "gas_limit_delta", [ - pytest.param(0, id="exact_gas"), pytest.param( - 1, - id="short_one_gas", + -1, + id="below_floor", marks=pytest.mark.exception_test, ), + pytest.param(0, id="at_floor"), + # Slack above the floor separates the gas limit from the gas + # charged, so the receipt can only match if the floor is priced. + pytest.param(100_000, id="above_floor"), ], ) -def test_max_initcode_size_gas_metering( +def test_max_initcode_size_calldata_floor( state_test: StateTestFiller, pre: Alloc, fork: Fork, - gas_shortfall: int, + gas_limit_delta: int, ) -> None: - """Verify initcode gas metering at the new max initcode size.""" + """ + Ensure a creation transaction carrying a max-size initcode must cover the + calldata floor of that initcode, and pays exactly it. + """ initcode = Initcode( deploy_code=Op.STOP, initcode_length=fork.max_initcode_size() ) alice = pre.fund_eoa() + floor_gas = fork.transaction_data_floor_cost_calculator()( + data=initcode, contract_creation=True + ) intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( - calldata=initcode, contract_creation=True + calldata=initcode, + contract_creation=True, + return_cost_deducted_prior_execution=True, ) + # An initcode this large costs more by the floor than by the intrinsic + # cost, so the floor is the threshold the transaction is held to and the + # shortfall case is rejected for missing the floor, not the intrinsic. + assert floor_gas > intrinsic_gas tx = Transaction( sender=alice, to=None, data=initcode, - gas_limit=intrinsic_gas - gas_shortfall, - error=TransactionException.INTRINSIC_GAS_TOO_LOW - if gas_shortfall - else None, + gas_limit=floor_gas + gas_limit_delta, + ) + + create_address = compute_create_address(address=alice, nonce=0) + post: dict[Any, Account | None] = {} + if gas_limit_delta < 0: + tx.error = TransactionException.INTRINSIC_GAS_BELOW_FLOOR_GAS_COST + post[create_address] = Account.NONEXISTENT + else: + # The deployment spends a fraction of the floor, so the floor is + # what the sender is charged, spare gas or not. + tx.expected_receipt = TransactionReceipt(cumulative_gas_used=floor_gas) + post[create_address] = Account(code=Op.STOP) + + state_test(pre=pre, tx=tx, post=post) + + +def test_max_initcode_size_code_opcodes( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Ensure the self code opcodes see a max-size initcode in full while it + runs. + """ + max_initcode_size = fork.max_initcode_size() + logic = ( + Op.SSTORE(0, Op.CODESIZE) + + Op.CODECOPY(0, 0, Op.CODESIZE) + + Op.SSTORE(1, Op.SHA3(0, Op.CODESIZE)) + + Op.RETURN(0, 0) + ) + # The unwritten initcode bytes stay zero, so the factory only stores the + # leading logic. + initcode_bytes = bytes(logic).ljust(max_initcode_size, b"\x00") + + factory = pre.deploy_contract( + Om.MSTORE(bytes(logic), 0) + + Op.SSTORE(0, Op.CREATE(value=0, offset=0, size=max_initcode_size)) + + Op.STOP, + storage={0: SENTINEL}, + ) + create_address = compute_create_address(address=factory, nonce=1) + + tx = Transaction(sender=pre.fund_eoa(), to=factory) + + post: dict[Any, Account | None] = { + factory: Account(storage={0: create_address}), + create_address: Account( + code=b"", + storage={ + 0: max_initcode_size, + 1: keccak256(initcode_bytes), + }, + ), + } + + state_test(pre=pre, tx=tx, post=post) + + +def test_max_initcode_size_linear_execution( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Ensure a max-size initcode executes from its first byte to its last + without a jump. + + The factory assembles the initcode in memory from two copies of a + `MAX_CODE_SIZE` JUMPDEST contract, overwrites the last word with a + store and a RETURN, and runs CREATE over it. The child's storage can + only be set by stepping through every byte before the tail. + """ + max_code_size = fork.max_code_size() + max_initcode_size = fork.max_initcode_size() + assert max_initcode_size == 2 * max_code_size + + source = pre.deploy_contract(Op.JUMPDEST * max_code_size) + tail = Op.SSTORE(0, 1) + Op.RETURN(0, 0) + last_word = bytes(Op.JUMPDEST * (32 - len(tail)) + tail) + + factory = pre.deploy_contract( + Op.EXTCODECOPY(source, 0, 0, max_code_size) + + Op.EXTCODECOPY(source, max_code_size, 0, max_code_size) + + Om.MSTORE(last_word, max_initcode_size - 32) + + Op.SSTORE(0, Op.CREATE(value=0, offset=0, size=max_initcode_size)) + + Op.STOP, + storage={0: SENTINEL}, ) + create_address = compute_create_address(address=factory, nonce=1) + + tx = Transaction(sender=pre.fund_eoa(), to=factory) - post = { - compute_create_address(address=alice, nonce=0): Account.NONEXISTENT - if gas_shortfall - else Account(code=Op.STOP), + post: dict[Any, Account | None] = { + factory: Account(storage={0: create_address}), + create_address: Account(code=b"", storage={0: 1}), } state_test(pre=pre, tx=tx, post=post) + + +@pytest.mark.parametrize( + "past_end,valid_jumpdest", + [ + pytest.param(False, True, id="valid_high_jumpdest"), + pytest.param(False, False, id="invalid_high_dest"), + # The tail and its JUMPDEST are in place, but the target is + # MAX_INITCODE_SIZE itself, one past the last byte. + pytest.param(True, True, id="invalid_past_end"), + ], +) +def test_max_initcode_size_high_jumpdest( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + past_end: bool, + valid_jumpdest: bool, +) -> None: + """ + Ensure jumpdest analysis reaches the last bytes of a max-size initcode, + far past the previous initcode limit, and stops at its end. + """ + max_initcode_size = fork.max_initcode_size() + tail = Op.JUMPDEST + Op.SSTORE(0, 1) + Op.RETURN(0, 0) + tail_offset = max_initcode_size - len(tail) + dest = max_initcode_size if past_end else tail_offset + push_size = (dest.bit_length() + 7) // 8 + push_op = getattr(Op, f"PUSH{push_size}") + + factory_code = Om.MSTORE(bytes(push_op(dest) + Op.JUMP), 0) + # Without the tail the jump lands on a zero byte, a STOP that is not a + # valid jump destination. + if valid_jumpdest: + factory_code += Om.MSTORE(bytes(tail), tail_offset) + factory_code += ( + Op.SSTORE(0, Op.CREATE(value=0, offset=0, size=max_initcode_size)) + + Op.STOP + ) + + factory = pre.deploy_contract(factory_code, storage={0: SENTINEL}) + create_address = compute_create_address(address=factory, nonce=1) + + tx = Transaction(sender=pre.fund_eoa(), to=factory) + + post: dict[Any, Account | None] = {} + if valid_jumpdest and not past_end: + post[factory] = Account(storage={0: create_address}) + post[create_address] = Account(storage={0: 1}) + else: + post[factory] = Account(storage={0: 0}) + post[create_address] = Account.NONEXISTENT + + state_test(pre=pre, tx=tx, post=post) diff --git a/tests/ported_static/stCodeSizeLimit/test_codesize_oog_invalid_size.py b/tests/ported_static/stCodeSizeLimit/test_codesize_oog_invalid_size.py index 4c284806244..36856afc97a 100644 --- a/tests/ported_static/stCodeSizeLimit/test_codesize_oog_invalid_size.py +++ b/tests/ported_static/stCodeSizeLimit/test_codesize_oog_invalid_size.py @@ -26,7 +26,6 @@ ["state_tests/stCodeSizeLimit/codesizeOOGInvalidSizeFiller.json"], ) @pytest.mark.valid_from("Cancun") -@pytest.mark.valid_before("EIP7954") @pytest.mark.parametrize( "d, g, v", [ diff --git a/tests/ported_static/stCodeSizeLimit/test_create2_code_size_limit.py b/tests/ported_static/stCodeSizeLimit/test_create2_code_size_limit.py index 05dca04f085..8b1857fe4fe 100644 --- a/tests/ported_static/stCodeSizeLimit/test_create2_code_size_limit.py +++ b/tests/ported_static/stCodeSizeLimit/test_create2_code_size_limit.py @@ -32,6 +32,10 @@ ["state_tests/stCodeSizeLimit/create2CodeSizeLimitFiller.yml"], ) @pytest.mark.valid_from("Cancun") +# Kept before EIP-7954: the fixed 15M gas transaction cannot fund the +# EIP-8037 code-deposit state gas of a max-size contract (about 100M at +# 64KiB). tests/amsterdam/eip7954_increase_max_contract_size covers the +# raised limit with fork-sized gas. @pytest.mark.valid_before("EIP7954") @pytest.mark.parametrize( "d, g, v", @@ -97,13 +101,13 @@ def test_create2_code_size_limit( address=Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B), # noqa: E501 ) - # Initcode: PUSH2 PUSH1 0 RETURN. Sizes scale with - # fork.max_code_size() so pre-7954 forks get the original 0x6000 - # / 0x6001 and Amsterdam+ gets 0x8000 / 0x8001. + # Initcode: PUSH PUSH1 0 RETURN, sized from + # fork.max_code_size(), so the vectors keep their original 0x6000 / + # 0x6001 bytes on every fork this test runs. max_code_size = fork.max_code_size() tx_data = [ - Bytes(b"\x61" + max_code_size.to_bytes(2) + b"\x60\x00\xf3"), - Bytes(b"\x61" + (max_code_size + 1).to_bytes(2) + b"\x60\x00\xf3"), + Bytes(Op.RETURN(offset=0, size=max_code_size)), + Bytes(Op.RETURN(offset=0, size=max_code_size + 1)), ] tx_gas = [15000000] valid_create2_address = compute_create_address( diff --git a/tests/ported_static/stCodeSizeLimit/test_create_code_size_limit.py b/tests/ported_static/stCodeSizeLimit/test_create_code_size_limit.py index fff45614bbb..8b38c1d7567 100644 --- a/tests/ported_static/stCodeSizeLimit/test_create_code_size_limit.py +++ b/tests/ported_static/stCodeSizeLimit/test_create_code_size_limit.py @@ -31,6 +31,10 @@ ["state_tests/stCodeSizeLimit/createCodeSizeLimitFiller.yml"], ) @pytest.mark.valid_from("Cancun") +# Kept before EIP-7954: the fixed 15M gas transaction cannot fund the +# EIP-8037 code-deposit state gas of a max-size contract (about 100M at +# 64KiB). tests/amsterdam/eip7954_increase_max_contract_size covers the +# raised limit with fork-sized gas. @pytest.mark.valid_before("EIP7954") @pytest.mark.parametrize( "d, g, v", @@ -122,14 +126,14 @@ def test_create_code_size_limit( post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) - # Initcode: PUSH2 PUSH1 0 RETURN. Sizes scale with - # fork.max_code_size() so pre-7954 forks get 0x6000 / 0x6001 and - # Amsterdam+ gets 0x8000 / 0x8001. CREATE address is - # nonce-derived and unaffected by the initcode bytes. + # Initcode: PUSH PUSH1 0 RETURN, sized from + # fork.max_code_size(), so the vectors keep their original 0x6000 / + # 0x6001 bytes on every fork this test runs. The CREATE + # address is nonce-derived and unaffected by the initcode bytes. max_code_size = fork.max_code_size() tx_data = [ - Bytes(b"\x61" + max_code_size.to_bytes(2) + b"\x60\x00\xf3"), - Bytes(b"\x61" + (max_code_size + 1).to_bytes(2) + b"\x60\x00\xf3"), + Bytes(Op.RETURN(offset=0, size=max_code_size)), + Bytes(Op.RETURN(offset=0, size=max_code_size + 1)), ] tx_gas = [15000000] diff --git a/tests/ported_static/stEIP3860_limitmeterinitcode/test_create2_init_code_size_limit.py b/tests/ported_static/stEIP3860_limitmeterinitcode/test_create2_init_code_size_limit.py index 3734cef6049..e783b87a664 100644 --- a/tests/ported_static/stEIP3860_limitmeterinitcode/test_create2_init_code_size_limit.py +++ b/tests/ported_static/stEIP3860_limitmeterinitcode/test_create2_init_code_size_limit.py @@ -33,6 +33,10 @@ ], ) @pytest.mark.valid_from("Cancun") +# Kept before EIP-7954: the 0xC000 / 0xC001 initcode sizes are baked into +# the vectors. The raised limit is covered with fork-derived sizes by +# tests/shanghai/eip3860_initcode and +# tests/amsterdam/eip7954_increase_max_contract_size. @pytest.mark.valid_before("EIP7954") @pytest.mark.parametrize( "d, g, v", diff --git a/tests/ported_static/stEIP3860_limitmeterinitcode/test_create_init_code_size_limit.py b/tests/ported_static/stEIP3860_limitmeterinitcode/test_create_init_code_size_limit.py index 6547fff3b58..6d92fb06823 100644 --- a/tests/ported_static/stEIP3860_limitmeterinitcode/test_create_init_code_size_limit.py +++ b/tests/ported_static/stEIP3860_limitmeterinitcode/test_create_init_code_size_limit.py @@ -34,6 +34,10 @@ ], ) @pytest.mark.valid_from("Cancun") +# Kept before EIP-7954: the 0xC000 / 0xC001 initcode sizes are baked into +# the vectors. The raised limit is covered with fork-derived sizes by +# tests/shanghai/eip3860_initcode and +# tests/amsterdam/eip7954_increase_max_contract_size. @pytest.mark.valid_before("EIP7954") @pytest.mark.parametrize( "d, g, v", diff --git a/tests/ported_static/stEIP3860_limitmeterinitcode/test_creation_tx_init_code_size_limit.py b/tests/ported_static/stEIP3860_limitmeterinitcode/test_creation_tx_init_code_size_limit.py index 62649b547ec..7a944c17b53 100644 --- a/tests/ported_static/stEIP3860_limitmeterinitcode/test_creation_tx_init_code_size_limit.py +++ b/tests/ported_static/stEIP3860_limitmeterinitcode/test_creation_tx_init_code_size_limit.py @@ -34,6 +34,10 @@ ], ) @pytest.mark.valid_from("Cancun") +# Kept before EIP-7954: the 0xC000 / 0xC001 initcode sizes are baked into +# the vectors. The raised limit is covered with fork-derived sizes by +# tests/shanghai/eip3860_initcode and +# tests/amsterdam/eip7954_increase_max_contract_size. @pytest.mark.valid_before("EIP7954") @pytest.mark.parametrize( "d, g, v", From 37615c92a4b1c53358c45c3ccd9cde3b1a4252b2 Mon Sep 17 00:00:00 2001 From: spencer Date: Tue, 22 Sep 2026 11:03:12 +0200 Subject: [PATCH 03/28] feat(tests): more amsterdam coverage gaps (#3627) Co-authored-by: 72684086+LouisTsai-Csie@users.noreply.github.com --- .../test_eip_mainnet.py | 57 ++++++++++++++++--- .../conftest.py | 9 ++- .../test_execution_gas.py | 36 ++++++++++++ .../test_transaction_validity.py | 41 +++++++++++++ .../test_selfdestruct_no_burn.py | 54 ++++++++++++++++++ 5 files changed, 189 insertions(+), 8 deletions(-) diff --git a/tests/amsterdam/eip7954_increase_max_contract_size/test_eip_mainnet.py b/tests/amsterdam/eip7954_increase_max_contract_size/test_eip_mainnet.py index adcbdedadcc..1566c7fdbe7 100644 --- a/tests/amsterdam/eip7954_increase_max_contract_size/test_eip_mainnet.py +++ b/tests/amsterdam/eip7954_increase_max_contract_size/test_eip_mainnet.py @@ -15,6 +15,7 @@ StateTestFiller, Transaction, TransactionException, + TransactionReceipt, compute_create_address, keccak256, ) @@ -27,29 +28,71 @@ pytestmark = [pytest.mark.valid_at("EIP7954"), pytest.mark.mainnet] -def test_over_max_code_size_mainnet( +@pytest.mark.parametrize( + "oversized", + [ + pytest.param(False, id="at_limit"), + pytest.param(True, id="above_limit"), + ], +) +def test_code_size_limit_mainnet( state_test: StateTestFiller, pre: Alloc, fork: Fork, + oversized: bool, ) -> None: - """Verify deployment above the new limit is rejected on mainnet.""" - deploy_code = Op.JUMPDEST * (fork.max_code_size() + 1) + """Verify both sides of the code size limit with a funded deposit.""" + deploy_code = Op.JUMPDEST * (fork.max_code_size() + oversized) initcode = Initcode(deploy_code=deploy_code) alice = pre.fund_eoa() create_address = compute_create_address(address=alice, nonce=0) + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + calldata=initcode, + contract_creation=True, + return_cost_deducted_prior_execution=True, + ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + contract_creation=True, + ) + required_gas = ( + intrinsic_gas + + top_frame_state_gas + + initcode.evm_gas(fork) + + initcode.deployment_gas(fork) + ) + floor_gas = fork.transaction_data_floor_cost_calculator()( + data=initcode, contract_creation=True + ) + gas_limit_cap = fork.transaction_gas_limit_cap() + assert gas_limit_cap is not None + # Fail the fill if repricing makes execution itself exceed the cap. + assert ( + max(floor_gas, intrinsic_gas + initcode.execution_cost(fork)) + <= gas_limit_cap + ) tx = Transaction( sender=alice, to=None, data=initcode, - gas_limit=fork.transaction_gas_limit_cap(), + gas_limit=max(required_gas, floor_gas) + 1, ) - + # An exceptional halt burns the execution allowance and returns state + # gas. The allowance need not reach the cap after a future repricing. + gas_used = ( + max(floor_gas, min(tx.gas_limit, gas_limit_cap)) + if oversized + else max(required_gas, floor_gas) + ) + tx.expected_receipt = TransactionReceipt(cumulative_gas_used=gas_used) post: dict[Any, Account | None] = { - create_address: Account.NONEXISTENT, + create_address: ( + Account.NONEXISTENT + if oversized + else Account(nonce=1, code=deploy_code) + ), } - state_test(pre=pre, tx=tx, post=post) diff --git a/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py b/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py index f92a0e003a0..26069de2bb3 100644 --- a/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py +++ b/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py @@ -107,6 +107,12 @@ def contract_creating_tx(to: Address | None) -> bool: return to is None +@pytest.fixture +def data_byte(request: pytest.FixtureRequest) -> bytes: + """Return the byte repeated to build the transaction data.""" + return getattr(request, "param", b"\x01") + + @pytest.fixture def intrinsic_gas_data_floor_minimum_delta() -> int: """ @@ -124,6 +130,7 @@ def tx_data( authorization_list: List[AuthorizationTuple] | None, contract_creating_tx: bool, intrinsic_gas_data_floor_minimum_delta: int, + data_byte: bytes, ) -> Bytes: """ All tests in this file use data that is generated dynamically depending on @@ -168,7 +175,7 @@ def tx_data( """ def bytes_to_data(byte_count: int) -> Bytes: - return Bytes(b"\x01" * byte_count) + return Bytes(data_byte * byte_count) fork_intrinsic_cost_calculator = ( fork.transaction_intrinsic_cost_calculator() diff --git a/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_execution_gas.py b/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_execution_gas.py index 2b810befd7b..62f497da11d 100644 --- a/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_execution_gas.py +++ b/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_execution_gas.py @@ -7,6 +7,7 @@ import pytest from execution_testing import ( AccessList, + Account, Address, Alloc, AuthorizationTuple, @@ -175,3 +176,38 @@ def test_gas_consumption_below_data_floor( post={}, tx=tx, ) + + +@pytest.mark.with_all_tx_types(selector=lambda ty: ty < 3) +@pytest.mark.parametrize( + "data", + [ + pytest.param(b"\x00" * 32, id="zero_bytes"), + pytest.param(b"\x00\x01" * 16, id="mixed_bytes"), + ], +) +def test_standard_calldata_cost_above_floor( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + tx_type: int, + data: bytes, +) -> None: + """Pin zero-byte pricing for plain calldata with execution above floor.""" + code = Op.SSTORE(0, 2, original_value=1, current_value=1, new_value=2) + contract = pre.deploy_contract(code=code, storage={0: 1}) + intrinsic = fork.transaction_intrinsic_cost_calculator()( + calldata=data, return_cost_deducted_prior_execution=True + ) + gas_used = intrinsic + code.gas_cost(fork) + floor = fork.transaction_data_floor_cost_calculator()(data=data) + assert gas_used > floor + tx = Transaction( + ty=tx_type, + sender=pre.fund_eoa(), + to=contract, + data=data, + gas_limit=gas_used + 1, + expected_receipt=TransactionReceipt(cumulative_gas_used=gas_used), + ) + state_test(pre=pre, tx=tx, post={contract: Account(storage={0: 2})}) diff --git a/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_transaction_validity.py b/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_transaction_validity.py index b6205314d05..6e052fa34c4 100644 --- a/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_transaction_validity.py +++ b/tests/amsterdam/eip7976_increase_calldata_floor_cost/test_transaction_validity.py @@ -349,6 +349,47 @@ def test_transaction_validity_type_4( ) +@pytest.mark.parametrize( + "data_byte", + [pytest.param(b"\x00", id="zero_bytes")], + indirect=True, +) +@pytest.mark.parametrize( + "authorization_list", + [ + pytest.param( + [Address(1)], + id="single_authorization", + ), + pytest.param( + [Address(i + 1) for i in range(10)], + id="multiple_authorizations", + ), + ], + indirect=True, +) +@pytest.mark.parametrize( + "ty", + [pytest.param(4, id="type_4")], +) +def test_transaction_validity_zero_byte_data( + state_test: StateTestFiller, + pre: Alloc, + tx: Transaction, +) -> None: + """ + Pin the standard zero-byte weight at both validity boundaries. + + Use authorizations to lift the standard cost above the floor for + short calldata, then cross into floor-dominated pricing. + """ + state_test( + pre=pre, + post={}, + tx=tx, + ) + + @pytest.mark.parametrize( "ty", [pytest.param(0, id="type_0"), pytest.param(2, id="type_2")], diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py index 9221966b897..e7e1a77664a 100644 --- a/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py @@ -19,6 +19,7 @@ Conditional, Fork, Hash, + Initcode, Op, StateTestFiller, Storage, @@ -465,3 +466,56 @@ def test_deterministic_factory_redeploy_takes_balance( created: Account.NONEXISTENT, }, ) + + +@pytest.mark.parametrize( + "selfdestruct_to_self", + [ + pytest.param(True, id="selfdestruct_to_self"), + pytest.param(False, id="selfdestruct_to_other_then_refunded"), + ], +) +def test_deployed_code_selfdestruct_clears_code( + state_test: StateTestFiller, + pre: Alloc, + selfdestruct_to_self: bool, +) -> None: + """ + Verify finalization clears deployed code and storage but keeps balance. + + Call a contract created in the same transaction so that it holds + deployed code and a written storage slot when it self-destructs. + """ + endowment = 5 + sender = pre.fund_eoa() + if selfdestruct_to_self: + beneficiary = None + selfdestruct = Op.SELFDESTRUCT(Op.ADDRESS) + else: + beneficiary = pre.fund_eoa(amount=1) + selfdestruct = Op.SELFDESTRUCT(beneficiary) + initcode = Initcode(deploy_code=Op.SSTORE(0, 1) + selfdestruct) + factory_code = Om.MSTORE(initcode, 0) + Op.SSTORE( + 0, Op.CREATE(value=endowment, offset=0, size=len(initcode)) + ) + factory_code += Op.POP(Op.CALL(gas=Op.GAS, address=Op.SLOAD(0))) + if beneficiary is not None: + # A donor's SELFDESTRUCT refunds the swept endowment without running + # the victim's code again, so EIP-161 does not remove the remnant. + factory_code += Op.POP(Op.CALL(gas=Op.GAS, address=Op.CALLDATALOAD(0))) + factory = pre.deploy_contract(code=factory_code + Op.STOP) + created = compute_create_address(address=factory, nonce=1) + tx_data = b"" + post = { + factory: Account(balance=0, nonce=2, storage={0: created}), + created: Account(balance=endowment, nonce=0, code=b"", storage={}), + } + if beneficiary is not None: + donor = pre.deploy_contract( + code=Op.SELFDESTRUCT(created), balance=endowment + ) + tx_data = Hash(donor, left_padding=True) + post[donor] = Account(balance=0) + post[beneficiary] = Account(balance=1 + endowment) + tx = Transaction(sender=sender, to=factory, value=endowment, data=tx_data) + state_test(pre=pre, post=post, tx=tx) From 80354f6e7e9e4814a4c05b3f8a20c504f6bfd774 Mon Sep 17 00:00:00 2001 From: spencer Date: Tue, 22 Sep 2026 11:36:46 +0200 Subject: [PATCH 04/28] feat(tests): EIP-8037 - no refill for failed creates onto alive accounts (#3529) Add a regression test proving that a create frame failing on an alive destination refills no NEW_ACCOUNT state gas: the factory's gas is tuned so its probe SSTORE only fits when the reservoir funds it, and removing the refill guard fails all twelve no_reservoir fixtures. Correct the collision-test docstring (an alive target is never charged, so nothing is refunded) and record the EELS line-coverage evidence for the checklist: every EIP-8037 line is executed, with the remaining misses classified as pre-existing code. --- .../eip_checklist_external_coverage.txt | 2 + .../test_state_gas_create.py | 122 +++++++++++++++++- 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/eip_checklist_external_coverage.txt b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/eip_checklist_external_coverage.txt index 28f2ca9a555..c7b80d8f8cc 100644 --- a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/eip_checklist_external_coverage.txt +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/eip_checklist_external_coverage.txt @@ -1 +1,3 @@ general/code_coverage/test_coverage = Measured on 2026-09-10 with #3524 (e330e06f60) rebased onto forks/amsterdam (c998b45fe8): 4936/4944 test statements and 452/462 branches covered by 2116 passing fixture variants. The eight missed statements are six defensive invalid-parameter raises and two lines adapting SSTORE counts to future gas repricing. Reproduce with --cov=tests/amsterdam/eip8037_state_creation_gas_cost_increase --cov-branch; see #3524 for scope and validation limits. +general/code_coverage/eels = Measured on 2026-09-21 at forks/amsterdam c335bc4e9e with `uv run fill --fork=Amsterdam --cov=ethereum.forks.amsterdam --cov-branch tests/amsterdam/eip8037_state_creation_gas_cost_increase` (2179 fixtures through the in-process t8n). Every EIP-8037 line is executed: vm/__init__.py 67/67 statements, vm/gas.py 338/351, vm/interpreter.py 146/149, vm/instructions/system.py 278/293, vm/instructions/storage.py 66/67, transactions.py 256/292 and fork.py 192/298, where every miss is pre-existing code classified under missed_lines. The one EIP-8037 path this directory does not reach, the top-frame NEW_ACCOUNT charge for a value transfer to a non-alive recipient (charge_value_transfer_to_non_alive_account), is exercised by tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_top_frame_charges.py::test_top_frame_new_account_charged_as_state_gas. +general/code_coverage/missed_lines = All misses in the files EIP-8037 touches are pre-existing branches: the static-context and delegated-code arms of CALL, CALLCODE, DELEGATECALL, STATICCALL, SELFDESTRUCT and TSTORE; EIP-7702 warm delegation access, authority recovery and authorization validation errors; transaction decoding, signature recovery and validity errors (nonce overflow, priority fee above max fee, blob count and versioned-hash checks, chain id, insufficient balance, non-EOA sender, missing system contract code); the memory-expansion overflow and gas_left shortfall branches of vm/gas.py together with excess-blob-gas arithmetic and the blob-gas capacity check; and block-level functions the t8n never runs (state_transition, execute_block, apply_body, validate_header, check_gas_limit, get_last_256_block_hashes, process_general_purpose_requests, process_withdrawals). The two-dimensional admission checks in check_block_gas_capacity and the gas split and accounting in check_transaction and process_transaction are fully executed. 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 9949dc89b05..23d9cbb0dfd 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 @@ -2284,14 +2284,15 @@ def test_create_collision_refunds_state_gas( create_opcode: Op, ) -> None: """ - Verify CREATE/CREATE2 address collision refunds account state gas. + Verify a CREATE/CREATE2 address collision leaves the reservoir intact. - The collision path increments the factory nonce and burns the - forwarded execution gas (consumed by the never-spawned child), but - still refunds `GAS_NEW_ACCOUNT` to the reservoir. Tight gas - tuning limits the factory's post-collision `gas_left` so the - probe SSTORE can only succeed via the refunded reservoir, not - by spilling state gas from `gas_left`. + The collision target has code, so it is alive and no + `GAS_NEW_ACCOUNT` charge is made for it. The collision path + increments the factory nonce and burns the forwarded execution gas + (consumed by the never-spawned child) while the reservoir returns + untouched. Tight gas tuning limits the factory's post-collision + `gas_left` so the probe SSTORE can only succeed via the intact + reservoir, not by spilling state gas from `gas_left`. """ sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) @@ -3610,3 +3611,110 @@ def test_no_account_charge_on_existing_account( ), } state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.with_all_create_opcodes() +@pytest.mark.parametrize( + ("failure_op", "halts"), + [ + pytest.param(Op.REVERT(0, 0), False, id="revert"), + pytest.param(Op.INVALID, True, id="halt"), + ], +) +@pytest.mark.parametrize( + "reservoir_covers", + [ + pytest.param(False, id="no_reservoir"), + pytest.param(True, id="reservoir_covers"), + ], +) +@pytest.mark.valid_from("EIP8037") +def test_failed_create_on_existing_account_refills_nothing( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + create_opcode: Op, + failure_op: Bytecode, + halts: bool, + reservoir_covers: bool, +) -> None: + """ + Verify a create frame failing on an alive destination refills nothing. + + The pre-funded target is never charged NEW_ACCOUNT, so the child + failure has no charge to return. The factory's gas is tuned so its + probe SSTORE only fits when the reservoir funds the state gas: a + phantom refill would let the `no_reservoir` arms succeed. + """ + sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + + init_code = failure_op + mstore_value, size = init_code_at_high_bytes(init_code) + salt = 0 + create_call = ( + create_opcode( + value=0, + offset=0, + size=size, + salt=salt, + init_code_size=size, + account_new=False, + ) + if create_opcode == Op.CREATE2 + else create_opcode( + value=0, + offset=0, + size=size, + init_code_size=size, + account_new=False, + ) + ) + + probe_slot = 0 + setup_code = Op.MSTORE(0, mstore_value) + Op.POP(create_call) + factory_code = setup_code + Op.SSTORE(probe_slot, 1) + factory = pre.deploy_contract(code=factory_code) + + target = compute_create_address( + address=factory, + nonce=1, + salt=salt, + initcode=bytes(init_code), + opcode=create_opcode, + ) + pre.fund_address(target, amount=1) + + # Leave the factory a window where the probe's execution fits but + # spilling its state gas from `gas_left` does not. + probe_execution = Op.SSTORE(probe_slot, 1).execution_cost(fork) + retained = probe_execution + sstore_state_gas // 2 + if halts: + # The halt burns the child's grant; the withheld 1/64 remains. + remaining = retained * 64 + else: + # The revert returns the grant less what the init code spent. + remaining = retained + init_code.execution_cost(fork) + forwarded_gas = remaining + setup_code.execution_cost(fork) + + caller_storage = Storage() + caller = pre.deploy_contract( + code=Op.SSTORE( + caller_storage.store_next(reservoir_covers, "factory_succeeds"), + Op.CALL(gas=forwarded_gas, address=factory), + ) + ) + + tx = Transaction( + to=caller, + state_gas_reservoir=sstore_state_gas if reservoir_covers else 0, + sender=pre.fund_eoa(), + ) + + post = { + caller: Account(storage=caller_storage), + factory: Account(nonce=2, storage={probe_slot: 1}) + if reservoir_covers + else Account(nonce=1, storage={}), + target: Account(nonce=0, balance=1, code=b""), + } + state_test(pre=pre, post=post, tx=tx) From 0a53ad834bb2d85d94e17f0c6246f0ff4bf5a4de Mon Sep 17 00:00:00 2001 From: spencer Date: Tue, 22 Sep 2026 16:14:17 +0200 Subject: [PATCH 05/28] feat(tests): cover block state budget after authorization preparation rollback (#3540) * feat(tests): cover block state budget after authorization preparation rollback * fix(tests): derive preparation rollback block gas budget * refactor: test implementation * fix(tests): annotate post and header in preparation rollback test --------- Co-authored-by: LouisTsai --- .../test_authorization_oog.py | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_oog.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_oog.py index e7fa95345c3..df6116789e7 100644 --- a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_oog.py +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_oog.py @@ -110,6 +110,47 @@ def _intrinsic_execution( ) +def _build_preparation_authorizations( + fork: Fork, + pre: Alloc, + *, + cap: int, + with_reservoir: bool, +) -> tuple[list[AuthorizationScenario], int]: + """ + Build authorizations and return the gas needed to apply all of them. + + When a reservoir is required, add enough authorizations for preparation + to consume the execution cap before it exhausts state gas. + """ + sample_auth = build_authorization(pre, AuthorizationAction.CREATES_ACCOUNT) + base_intrinsic = _intrinsic_execution( + fork, [], recipient_type=RecipientType.DELEGATION_7702 + ) + per_auth_intrinsic = ( + _intrinsic_execution( + fork, + [sample_auth.authorization], + recipient_type=RecipientType.DELEGATION_7702, + ) + - base_intrinsic + ) + per_auth_gas = per_auth_intrinsic + ( + _auth_top_frame_charges(fork, [sample_auth.authorization]) + ) + auth_count = ( + (cap - base_intrinsic) // per_auth_gas + 2 if with_reservoir else 2 + ) + authorizations = [sample_auth] + [ + build_authorization(pre, AuthorizationAction.CREATES_ACCOUNT) + for _ in range(auth_count - 1) + ] + return ( + authorizations, + base_intrinsic + auth_count * per_auth_gas, + ) + + def _applied_delegation_bal( scenario: AuthorizationScenario, ) -> BalAccountExpectation: @@ -1413,6 +1454,165 @@ def test_reverted_dispatch_state_gas_counts_toward_block_limit( ) +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.inclusion_test +@pytest.mark.parametrize("failure_point", ["authorization", "dispatch_access"]) +@pytest.mark.parametrize( + "with_reservoir", [False, True], ids=["spill", "mixed"] +) +@pytest.mark.parametrize( + "probe_delta,probe_error", + [ + pytest.param(0, None, id="exact_fit"), + pytest.param( + 1, + TransactionException.GAS_ALLOWANCE_EXCEEDED, + id="exceeded", + marks=pytest.mark.exception_test, + ), + ], +) +def test_preparation_rollback_restores_block_state_budget( + fork: Fork, + pre: Alloc, + blockchain_test: BlockchainTestFiller, + failure_point: str, + with_reservoir: bool, + probe_delta: int, + probe_error: TransactionException | None, +) -> None: + """ + Exclude rolled-back authorization charges from the block state budget. + + Fail before or after the authorization gas commit, then probe the next + transaction's inclusion at the restored state-budget boundary. + """ + cap = fork.transaction_gas_limit_cap() + assert cap is not None, "EIP-7825 cap expected on this fork" + + execution_gas, state_gas, cumulative_gas_used = 0, 0, 0 + + # Seed the block with state gas that must remain charged. + seed_code = Op.SSTORE(0, 1, original_value=0, new_value=1) + seed_recipient = pre.deploy_contract(code=seed_code) + + execution_gas = _intrinsic_execution( + fork, [], recipient_type=RecipientType.CONTRACT + ) + seed_code.execution_cost(fork) + state_gas += seed_code.state_cost(fork) + cumulative_gas_used = execution_gas + state_gas + + seed_tx = Transaction( + sender=pre.fund_eoa(), + to=seed_recipient, + gas_limit=cumulative_gas_used, + ) + + # Build a transaction that runs out of gas during preparation. + authorizations, preparation_gas = _build_preparation_authorizations( + fork, + pre, + cap=cap, + with_reservoir=with_reservoir, + ) + authorization_list = [ + authorization.authorization for authorization in authorizations + ] + + # Starve the last AUTH_BASE charge, or the access after all + # authorizations have applied and their state gas has been committed. + match failure_point: + case "authorization": + failing_gas_limit = preparation_gas - 1 + case "dispatch_access": + failing_gas_limit = ( + preparation_gas + + fork.transaction_top_frame_execution_gas( + recipient_type=RecipientType.DELEGATION_7702, + ) + - 1 + ) + case _: + raise ValueError(f"Unexpected failure point: {failure_point}") + + failing_execution_gas = min(cap, failing_gas_limit) + auth_state_gas = fork.transaction_top_frame_state_gas( + recipient_type=RecipientType.CONTRACT, + authorizations=authorization_list, + ) + gas_above_cap = failing_gas_limit - cap + if with_reservoir: + assert 0 < gas_above_cap < auth_state_gas + else: + assert gas_above_cap < 0 + + # Preparation rollback restores its state gas, so only execution gas + # remains charged to the block. + execution_gas += failing_execution_gas + cumulative_gas_used += failing_execution_gas + + stop_contract = pre.deploy_contract(code=Op.STOP) + failing_sender = pre.fund_eoa() + failing_tx = Transaction( + sender=failing_sender, + to=pre.fund_eoa(delegation=stop_contract), + authorization_list=authorization_list, + gas_limit=failing_gas_limit, + expected_receipt=TransactionReceipt( + cumulative_gas_used=cumulative_gas_used, + ), + ) + + # Give the block enough execution headroom that only its cumulative + # state budget can decide whether the boundary probe is included. + block_gas_limit = state_gas + execution_gas + 2 * cap + probe_gas_limit = block_gas_limit - state_gas + probe_delta + execution_headroom = block_gas_limit - execution_gas + assert probe_gas_limit < block_gas_limit + assert min(cap, probe_gas_limit) < execution_headroom, ( + "the execution budget must not decide the probe's inclusion" + ) + + probe_tx = Transaction( + sender=pre.fund_eoa(), + to=stop_contract, + gas_limit=probe_gas_limit, + error=probe_error, + ) + + post: dict[Address, Account | None] = {} + expected_header: Header | None = None + if probe_error is None: + execution_gas += _intrinsic_execution( + fork, + [], + recipient_type=RecipientType.CONTRACT, + ) + post = { + seed_recipient: Account(storage={0: 1}), + failing_sender: Account(nonce=1), + **{ + authorization.authority: Account.NONEXISTENT + for authorization in authorizations + }, + } + expected_header = Header(gas_used=max(execution_gas, state_gas)) + + blockchain_test( + genesis_environment=Environment(gas_limit=block_gas_limit), + pre=pre, + blocks=[ + Block( + txs=[seed_tx, failing_tx, probe_tx], + gas_limit=block_gas_limit, + exception=probe_error, + header_verify=expected_header, + ), + ], + post=post, + ) + + @EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() def test_recipient_new_account_refilled_on_dispatch_halt_with_reservoir( fork: Fork, From 4b43084b416e70fd451daab3889123855c3a324c Mon Sep 17 00:00:00 2001 From: danceratopz Date: Tue, 22 Sep 2026 17:12:10 +0200 Subject: [PATCH 06/28] fix(spec-tests): validate BPO fixtures with the framework's blob schedule (#3633) --- .../helpers/load_blockchain_tests.py | 63 +++++++++- tests/json_loader/test_fork_blob_schedule.py | 110 ++++++++++++++++++ 2 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 tests/json_loader/test_fork_blob_schedule.py diff --git a/tests/json_loader/helpers/load_blockchain_tests.py b/tests/json_loader/helpers/load_blockchain_tests.py index d21d44f8fd8..7d8b767823c 100644 --- a/tests/json_loader/helpers/load_blockchain_tests.py +++ b/tests/json_loader/helpers/load_blockchain_tests.py @@ -3,7 +3,7 @@ from contextlib import ExitStack from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, Optional, Tuple, Type from unittest.mock import call, patch import pytest @@ -12,7 +12,8 @@ from ethereum_rlp.exceptions import RLPException from ethereum_types.numeric import U64, U256, Uint from execution_testing.fixtures.blockchain import FixtureHeader -from execution_testing.forks import get_transition_forks +from execution_testing.forks import get_forks, get_transition_forks +from execution_testing.forks.base_fork import BaseFork from ethereum.crypto.hash import keccak256 from ethereum.exceptions import EthereumException, StateWithEmptyAccount @@ -26,6 +27,15 @@ from .exceptional_test_patterns import exceptional_blockchain_test_patterns from .fixtures import Fixture, FixturesFile, FixtureTestItem +TESTING_FORKS: Dict[str, Type[BaseFork]] = { + fork.name(): fork for fork in get_forks() +} +""" +Forks of the testing framework by name. The network of a fixture, or either +end of a transition fixture's network, is the name of the testing fork it +was filled for. +""" + TRANSITION_FORKS = {fork.name(): fork for fork in get_transition_forks()} """ Transition forks of the testing framework by name. The network of a @@ -256,8 +266,16 @@ def mock_pow(fork_load: Load) -> bool: ) with ExitStack() as stack: - if self.transition is not None: + if self.transition is None: + self._schedule_blob_params(stack, load, self.fork_name) + else: self._schedule_fork(stack, load, self.transition.criteria) + self._schedule_blob_params( + stack, current, self.transition.from_fork + ) + self._schedule_blob_params( + stack, load, self.transition.to_fork + ) for json_block in json_data["blocks"]: if ( @@ -327,6 +345,45 @@ def _schedule_fork( patch.object(fork_module, "FORK_CRITERIA", criteria) ) + @staticmethod + def _schedule_blob_params( + stack: ExitStack, load: Load, fork_name: str + ) -> None: + """ + Give the fork of `load` the blob schedule that the testing + framework's fork `fork_name` is filled with, for the duration of + `stack`, when that fork is a blob parameter only (BPO) fork. + + A BPO fork of the spec is a copy of its predecessor whose blob + schedule is a placeholder; the schedule lives in the testing + framework's fork of the same name. The fill's `T8N` clones the spec + fork with that schedule, but a clone cannot validate a chain across + a fork transition: `calculate_excess_blob_gas` recognises the + parent header by the previous fork's `Header` class, which the + clone refers to under another name. The constants are patched in + place instead. `GasCosts.BLOB_TARGET_GAS_PER_BLOCK` and the `fork` + module's `MAX_BLOB_GAS_PER_BLOCK` are derived from the schedule at + import time, so they are patched along with it. + """ + testing_fork = TESTING_FORKS.get(fork_name) + if testing_fork is None or not testing_fork.bpo_fork(): + return + gas_costs = load.fork.hardfork.module("vm.gas").GasCosts + fork_module = load.fork.hardfork.module("fork") + per_blob: U64 = gas_costs.PER_BLOB + target = U64(testing_fork.target_blobs_per_block()) + maximum = U64(testing_fork.max_blobs_per_block()) + update_fraction = Uint(testing_fork.blob_base_fee_update_fraction()) + patches = ( + (gas_costs, "BLOB_SCHEDULE_TARGET", target), + (gas_costs, "BLOB_SCHEDULE_MAX", maximum), + (gas_costs, "BLOB_TARGET_GAS_PER_BLOCK", per_blob * target), + (gas_costs, "BLOB_BASE_FEE_UPDATE_FRACTION", update_fraction), + (fork_module, "MAX_BLOB_GAS_PER_BLOCK", per_blob * maximum), + ) + for owner, name, value in patches: + stack.enter_context(patch.object(owner, name, value)) + def reportinfo(self) -> Tuple[Path, int, str]: """Return information for test reporting.""" return self.path, 1, self.name diff --git a/tests/json_loader/test_fork_blob_schedule.py b/tests/json_loader/test_fork_blob_schedule.py new file mode 100644 index 00000000000..07e823e35b5 --- /dev/null +++ b/tests/json_loader/test_fork_blob_schedule.py @@ -0,0 +1,110 @@ +"""Tests for scheduling the blob parameters of a BPO fork's fixtures.""" + +from contextlib import ExitStack +from typing import Any, Tuple, Type + +import pytest +from execution_testing.forks.base_fork import BaseFork + +from ethereum_spec_tools.loaders.fixture_loader import Load + +from . import FORKS +from .helpers.load_blockchain_tests import ( + TESTING_FORKS, + BlockchainTestFixture, +) + +BPO_NETWORKS = sorted( + network + for network, fork in TESTING_FORKS.items() + if fork.bpo_fork() and network in FORKS +) + + +def spec_blob_schedule(load: Load) -> Tuple[int, int, int]: + """Return the blob schedule constants of the spec fork of `load`.""" + gas_costs = load.fork.hardfork.module("vm.gas").GasCosts + return ( + int(gas_costs.BLOB_SCHEDULE_TARGET), + int(gas_costs.BLOB_SCHEDULE_MAX), + int(gas_costs.BLOB_BASE_FEE_UPDATE_FRACTION), + ) + + +def derived_blob_constants(load: Load) -> Tuple[int, int]: + """Return the constants the spec fork derives from its blob schedule.""" + gas_costs = load.fork.hardfork.module("vm.gas").GasCosts + fork_module = load.fork.hardfork.module("fork") + return ( + int(gas_costs.BLOB_TARGET_GAS_PER_BLOCK), + int(fork_module.MAX_BLOB_GAS_PER_BLOCK), + ) + + +def framework_blob_schedule(fork: Type[BaseFork]) -> Tuple[int, int, int]: + """Return the blob schedule constants of the testing framework fork.""" + return ( + fork.target_blobs_per_block(), + fork.max_blobs_per_block(), + fork.blob_base_fee_update_fraction(), + ) + + +def test_every_bpo_fork_is_known_to_both_sides() -> None: + """Pair every BPO fork of the testing framework with a spec fork.""" + assert BPO_NETWORKS + for network, fork in TESTING_FORKS.items(): + if fork.bpo_fork(): + assert network in FORKS + + +@pytest.mark.parametrize("network", BPO_NETWORKS) +def test_bpo_fork_derives_its_constants_from_the_schedule( + network: str, +) -> None: + """ + Check the derivation the patch reproduces for the spec's own values. + """ + load = Load(FORKS[network].short_name) + per_blob = int(load.fork.hardfork.module("vm.gas").GasCosts.PER_BLOB) + target, maximum, _ = spec_blob_schedule(load) + assert derived_blob_constants(load) == ( + per_blob * target, + per_blob * maximum, + ) + + +@pytest.mark.parametrize("network", BPO_NETWORKS) +def test_bpo_fork_validates_with_the_framework_blob_schedule( + network: str, +) -> None: + """Patch in the schedule the fixtures were filled with, then restore.""" + load = Load(FORKS[network].short_name) + per_blob = int(load.fork.hardfork.module("vm.gas").GasCosts.PER_BLOB) + spec_schedule = spec_blob_schedule(load) + spec_derived = derived_blob_constants(load) + framework_schedule = framework_blob_schedule(TESTING_FORKS[network]) + target, maximum, _ = framework_schedule + + with ExitStack() as stack: + BlockchainTestFixture._schedule_blob_params(stack, load, network) + assert spec_blob_schedule(load) == framework_schedule + assert derived_blob_constants(load) == ( + per_blob * target, + per_blob * maximum, + ) + + assert spec_blob_schedule(load) == spec_schedule + assert derived_blob_constants(load) == spec_derived + + +@pytest.mark.parametrize("network", ["Cancun", "Osaka", "Amsterdam"]) +def test_non_bpo_fork_is_left_alone(network: str) -> None: + """Patch nothing for a fork that is not a BPO fork.""" + load = Load(FORKS[network].short_name) + gas_costs = load.fork.hardfork.module("vm.gas").GasCosts + before: dict[str, Any] = dict(vars(gas_costs)) + + with ExitStack() as stack: + BlockchainTestFixture._schedule_blob_params(stack, load, network) + assert dict(vars(gas_costs)) == before From 756dc47153fadb9292983600365e98e817f479f4 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Tue, 22 Sep 2026 17:41:19 +0200 Subject: [PATCH 07/28] chore(ci,releases): fill `tests@` releases through Amsterdam (#3632) --- .github/configs/feature.yaml | 10 ++++++---- .github/scripts/resolve_cached_release.py | 6 +++--- .github/workflows/release_fixtures.yaml | 10 +++++----- docs/dev/releasing_tests.md | 2 +- docs/running_tests/consume/cache.md | 2 +- docs/running_tests/releases.md | 10 ++++++---- 6 files changed, 22 insertions(+), 18 deletions(-) diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index 735fffa0cd9..3826e1b1c85 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -5,12 +5,14 @@ # Any `-devnet` input resolves to the shared `devnet` entry but keeps # its name in the tag; the devnet number lives in the version (X), not the # feature name, so this file needs no edits for new devnets. -# `tests` is also what the scheduled nightly run of the `release_fixtures` -# workflow fills: mainnet forks only, so the rotating nightly artifact is a -# release-ready rehearsal of the next tests@ release. +# `tests` is the stable release clients gate their master branches on; its +# `--until` tracks the fork those branches implement (the policy is stated +# in docs/running_tests/releases.md). `tests` is also what the scheduled +# nightly run of the `release_fixtures` workflow fills, so the rotating +# nightly artifact is a release-ready rehearsal of the next tests@ release. tests: evm-type: eels - fill-params: --until=BPO4 --generate-all-formats + fill-params: --until=Amsterdam --generate-all-formats benchmark: evm-type: benchmark diff --git a/.github/scripts/resolve_cached_release.py b/.github/scripts/resolve_cached_release.py index 6c8f9450133..e5faca07796 100644 --- a/.github/scripts/resolve_cached_release.py +++ b/.github/scripts/resolve_cached_release.py @@ -11,8 +11,8 @@ Dispatching `release_fixtures.yaml` with the `cached` flag drafts a `tests@` release from the newest nightly artifact instead of -refilling: the scheduled nightly runs already build the mainnet -`tests` feature into a release-shaped `fixtures_` artifact. +refilling: the scheduled nightly runs already build the `tests` +feature into a release-shaped `fixtures_` artifact. The `commit` input picks the nightly built at that commit instead of the newest one. This script validates the request, picks the nightly run whose artifact the release job downloads, and pins the exact @@ -118,7 +118,7 @@ def newest_tests_tag(repository: str) -> str: The `tests@` ref prefix cannot match any other feature's tags (those are namespaced `tests-@`), so every match is a - mainnet tests release. + `tests` release. The listing is paginated in ref-name order, not version order (`tests@v9...` sorts after `tests@v20...`), so every page must be diff --git a/.github/workflows/release_fixtures.yaml b/.github/workflows/release_fixtures.yaml index e906685c180..6234d08dd77 100644 --- a/.github/workflows/release_fixtures.yaml +++ b/.github/workflows/release_fixtures.yaml @@ -2,10 +2,10 @@ name: Create Fixture Release run-name: ${{ github.event_name == 'schedule' && 'Nightly Fill' || format('Create Fixture Release {0}@{1}{2}', inputs.feature, inputs.version, (inputs.cached || inputs.commit != '') && ' (cached)' || '') }} -# Scheduled runs fill the mainnet `tests` feature (all tests, all fixture -# formats, up to the latest mainnet fork -- no dev forks) through the exact +# Scheduled runs fill the `tests` feature (all tests, all fixture formats, +# per its fill-params in .github/configs/feature.yaml) through the exact # release pipeline, but skip the `release` job, so no tag or draft release -# is created: a rotating, always-available artifact of the mainnet +# is created: a rotating, always-available artifact of the `tests` # fixtures. The cron fires at 02:00 UTC: the self-hosted runners are past # the EU/US daytime peaks and results are ready before the EU morning. # @@ -122,8 +122,8 @@ jobs: id: matrix shell: bash env: - # Scheduled runs have no inputs: fill the mainnet `tests` - # feature; the placeholder version passes validation and is + # Scheduled runs have no inputs: fill the `tests` feature; + # the placeholder version passes validation and is # otherwise unused because the `release` job is skipped for # scheduled runs. INPUT_FEATURE: ${{ inputs.feature || 'tests' }} diff --git a/docs/dev/releasing_tests.md b/docs/dev/releasing_tests.md index 7c16de3c4a0..5fe48702213 100644 --- a/docs/dev/releasing_tests.md +++ b/docs/dev/releasing_tests.md @@ -87,7 +87,7 @@ The release is created as a draft; review and publish it from the GitHub release ## Nightly fill -The same workflow also runs on a nightly schedule (02:00 UTC) as a release rehearsal: it fills the mainnet `tests` feature (all tests, slow included, all fixture formats, up to the latest mainnet fork — dev forks are not included) through the exact release pipeline, but stops after `combine`, so no tag or release is created. Each run uploads a `fixtures_` workflow artifact (short hash of the built commit, containing `fixtures.tar.gz`) with a 5-day retention: a rotating, always-available build of the mainnet fixtures, effectively a `tests@` release candidate on demand. A scheduled run skips itself when there are no new commits since the last nightly that actually filled — a skipped or failed nightly never advances that baseline, so no commit slips through unfilled. A quiet stretch without commits still re-fills once the last fill is four days old, or its artifact is gone, so a live artifact always exists within the five-day retention. +The same workflow also runs on a nightly schedule (02:00 UTC) as a release rehearsal: it fills the `tests` feature (all tests, slow included, all fixture formats, per its `fill-params` in `feature.yaml`) through the exact release pipeline, but stops after `combine`, so no tag or release is created. Each run uploads a `fixtures_` workflow artifact (short hash of the built commit, containing `fixtures.tar.gz`) with a 5-day retention: a rotating, always-available build of the `tests` fixtures, effectively a `tests@` release candidate on demand. A scheduled run skips itself when there are no new commits since the last nightly that actually filled — a skipped or failed nightly never advances that baseline, so no commit slips through unfilled. A quiet stretch without commits still re-fills once the last fill is four days old, or its artifact is gone, so a live artifact always exists within the five-day retention. ## Cached releases diff --git a/docs/running_tests/consume/cache.md b/docs/running_tests/consume/cache.md index 9bb5e5af471..27594a436ce 100644 --- a/docs/running_tests/consume/cache.md +++ b/docs/running_tests/consume/cache.md @@ -52,7 +52,7 @@ A release specification has the format `@`. **Supported release names:** -- `tests`: The mainnet release, all tests for all forks up to and including the latest mainnet fork. A bare `latest` or `vX.Y.Z` input is shorthand for `tests@latest`, respectively `tests@vX.Y.Z`. +- `tests`: The stable release for client CI, all tests for all forks up to and including the fork clients' master branches implement (see [Test Release Types](../releases.md#test-release-types)). A bare `latest` or `vX.Y.Z` input is shorthand for `tests@latest`, respectively `tests@vX.Y.Z`. - `-devnet`: Devnet releases, e.g. `bal-devnet`, `glamsterdam-devnet`. - Other features: e.g. `benchmark`, `zkevm`. diff --git a/docs/running_tests/releases.md b/docs/running_tests/releases.md index d482606d920..d31563f8c8e 100644 --- a/docs/running_tests/releases.md +++ b/docs/running_tests/releases.md @@ -29,9 +29,11 @@ and cadence. | Devnet | `-devnet@vX.Y.Z` | `fixtures_-devnet.tar.gz` | All forks, all tests, for an upcoming-fork feature under active devnet testing | the devnet or EIP branch | | Benchmark | `benchmark@vX.Y.Z` | `fixtures_benchmark.tar.gz` | EVM benchmarking tests | latest `forks/*` branch | -- "Tests" releases track clients' production branches and are tagged frequently (roughly - once or twice a week). They are the "must pass" release for mainnet CI, and supersede the - old `fixtures_stable` / `fixtures_develop` artifacts. +- "Tests" releases aim to match clients' master branches. Once clients have merged the + upcoming fork into those branches ahead of a pending client release, the `tests` release + includes that fork too, and `X` bumps to its fork number. They are the "must pass" release + for client CI, tagged frequently (roughly once or twice a week), and supersede the old + `fixtures_stable` / `fixtures_develop` artifacts. - "Devnet" releases target a specific feature under active development (e.g. `bal-devnet`). They are advisory/non-blocking and may not yet cover every EIP; see the corresponding release notes for the coverage provided. @@ -148,7 +150,7 @@ fixtures/ Mapped to a typical client CI setup: -- **Blocking gate (current + past forks)**: Pin a specific `tests@vX.Y.Z` for reproducible, +- **Blocking gate (the forks on your `master` branch)**: Pin a specific `tests@vX.Y.Z` for reproducible, no-rug-pull CI on your `master`/production branch, or follow the latest `tests` release if a moving target is acceptable. This supersedes the old `fixtures_develop` / `fixtures_stable` artifacts. From d90f3975bd2713afe992df075ccd71664b1185a2 Mon Sep 17 00:00:00 2001 From: Guruprasad Kamath <48196632+gurukamath@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:47:44 -0500 Subject: [PATCH 08/28] feat(tests): improve EIP-8038 coverage, checklist, and ref-spec pin (#3613) Co-authored-by: spencer Co-authored-by: danceratopz --- .../forks/forks/eips/amsterdam/eip_2780.py | 6 +- .../forks/forks/eips/amsterdam/eip_8038.py | 9 +- src/ethereum/forks/amsterdam/__init__.py | 2 +- src/ethereum/forks/amsterdam/vm/gas.py | 8 +- .../amsterdam/vm/instructions/storage.py | 17 +- .../__init__.py | 2 +- .../eip_checklist_external_coverage.txt | 4 + .../eip_checklist_not_applicable.txt | 13 + .../spec.py | 2 +- .../test_access_list_gas.py | 54 +- .../test_call_gas.py | 425 +++++++++++- .../test_create_gas.py | 292 +++++++- .../test_eip_mainnet.py | 15 +- .../test_exact_balance_no_fallback.py | 2 +- .../test_ext_code_opcodes_gas.py | 124 +++- .../test_fork_transition.py | 648 ++++++++++++++---- .../test_selfdestruct_gas.py | 186 ++++- .../test_set_code_auth_gas.py | 71 +- .../test_set_code_auth_refunds.py | 2 +- .../test_sload_gas.py | 2 +- .../test_sstore_gas.py | 102 ++- .../test_sstore_refunds.py | 282 +++++++- .../test_sstore_refunds_revert.py | 291 ++++++++ .../test_transient_storage_regression.py | 2 +- .../test_create2_oo_gafter_init_code.py | 17 +- .../test_create_address_warm_after_fail.py | 9 +- .../test_create_oo_gafter_init_code.py | 17 +- .../test_14_revert_after_nested_staticcall.py | 9 +- .../test_suicide_to_existing_contract.py | 9 +- .../test_suicide_to_not_existing_contract.py | 9 +- .../test_eip2929.py | 19 +- .../test_eip2929_minus_ff.py | 18 +- .../test_gas_cost_memory.py | 18 +- .../test_raw_ext_code_copy_gas.py | 29 +- .../test_raw_ext_code_copy_memory_gas.py | 30 +- .../test_raw_ext_code_size_gas.py | 29 +- .../test_call_one_v_call_suicide.py | 18 +- .../test_call_one_v_call_suicide2.py | 14 +- .../test_call_zero_v_call_suicide.py | 9 +- .../test_extcodesize_to_epmty_paris.py | 15 +- .../test_extcodesize_to_non_existent.py | 15 +- .../stEIP2930/test_address_opcodes.py | 26 +- .../stEIP2930/test_coinbase_t01.py | 16 +- .../stEIP2930/test_coinbase_t2.py | 16 +- .../stEIP2930/test_manual_create.py | 12 +- .../stEIP2930/test_storage_costs.py | 25 +- .../stEIP2930/test_varied_context.py | 34 +- ...zero_value_call_to_non_non_zero_balance.py | 24 +- ..._value_callcode_to_non_non_zero_balance.py | 24 +- .../test_precomps_eip2929_cancun.py | 36 +- .../stRefundTest/test_refund50_1.py | 27 +- ...st_refund_call_a_not_enough_gas_in_call.py | 21 +- .../test_refund_change_non_zero_storage.py | 19 +- .../stRefundTest/test_refund_ff.py | 20 +- .../test_refund_get_ether_back.py | 39 +- .../stRefundTest/test_refund_max.py | 27 +- .../stRefundTest/test_refund_no_oog_1.py | 39 +- .../stRefundTest/test_refund_sstore.py | 45 +- .../stSpecialTest/test_eoa_empty_paris.py | 18 +- .../test_contract_store_clears_success.py | 16 +- ...test_internal_call_store_clears_success.py | 31 +- ..._and_internal_call_store_clears_success.py | 25 +- tests/ported_static/vmTests/test_suicide.py | 28 +- 63 files changed, 2811 insertions(+), 602 deletions(-) create mode 100644 tests/amsterdam/eip8038_state_access_gas_cost_increase/eip_checklist_external_coverage.txt create mode 100644 tests/amsterdam/eip8038_state_access_gas_cost_increase/eip_checklist_not_applicable.txt create mode 100644 tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_refunds_revert.py diff --git a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_2780.py b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_2780.py index f1c7c7a0393..fbc80e6795e 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_2780.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_2780.py @@ -70,9 +70,9 @@ def fn( floor = super_fn(data=data, access_list=access_list) is_self_transfer = recipient_type == RecipientType.SELF if contract_creation: - # CREATE_ACCESS execution gas; TX_CREATE folds in the - # NEW_ACCOUNT state gas, which the floor excludes. - floor += gas_costs.TX_CREATE - gas_costs.NEW_ACCOUNT + # CREATE_ACCESS execution gas only; the floor excludes + # the NEW_ACCOUNT state gas. + floor += gas_costs.CREATE_ACCESS elif not is_self_transfer: floor += gas_costs.COLD_ACCOUNT_ACCESS if sends_value: diff --git a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_8038.py b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_8038.py index ca495d88597..66dc26da392 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_8038.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_8038.py @@ -1,5 +1,5 @@ """ -EIP-8038: State Access Gas Cost Increase. +EIP-8038: State-access gas cost update. Harmonization and increase of state-access gas costs, repricing warm and cold account and storage access, account writes, and the related access @@ -56,6 +56,10 @@ def gas_costs(cls) -> GasCosts: execution_per_auth_base_cost = ( 1_616 + 3_000 + cold_account_access + 2 * warm_access ) + # Derived with the EIP's formula, as in `vm/gas.py`. + refund_storage_clear = ( + (storage_write + cold_storage_access) * 4_800 // 5_000 + ) return replace( parent, @@ -66,13 +70,14 @@ def gas_costs(cls) -> GasCosts: COLD_STORAGE_WRITE=cold_storage_write, ACCOUNT_WRITE=account_write, CALL_VALUE=account_write + 2_300, # ACCOUNT_WRITE + CALL_STIPEND - REFUND_STORAGE_CLEAR=11_616, + REFUND_STORAGE_CLEAR=refund_storage_clear, TX_ACCESS_LIST_ADDRESS=cold_account_access - warm_access, TX_ACCESS_LIST_STORAGE_KEY=cold_storage_access - warm_access, BLOCK_ACCESS_LIST_ITEM=2000, STORAGE_SET=storage_write, OPCODE_CREATE_BASE=create_access, TX_CREATE=create_access, + CREATE_ACCESS=create_access, AUTH_PER_EMPTY_ACCOUNT=account_write + execution_per_auth_base_cost, EXECUTION_PER_AUTH_BASE_COST=execution_per_auth_base_cost, diff --git a/src/ethereum/forks/amsterdam/__init__.py b/src/ethereum/forks/amsterdam/__init__.py index d9e4e630cad..c85948764cc 100644 --- a/src/ethereum/forks/amsterdam/__init__.py +++ b/src/ethereum/forks/amsterdam/__init__.py @@ -15,7 +15,7 @@ - [EIP-7997: Deterministic Factory Contract][EIP-7997] - [EIP-8024: Stack Access Instructions][EIP-8024] - [EIP-8037: State Creation Gas Cost Increase][EIP-8037] -- [EIP-8038: State Access Gas Cost Increase][EIP-8038] +- [EIP-8038: State-access gas cost update][EIP-8038] - [EIP-8246: Remove SELFDESTRUCT balance burn][EIP-8246] - [EIP-8282: Builder Execution Requests][EIP-8282] diff --git a/src/ethereum/forks/amsterdam/vm/gas.py b/src/ethereum/forks/amsterdam/vm/gas.py index 4b9ea2ff0d7..ec1f9890925 100644 --- a/src/ethereum/forks/amsterdam/vm/gas.py +++ b/src/ethereum/forks/amsterdam/vm/gas.py @@ -95,7 +95,6 @@ class GasCosts: CALL_VALUE: Final[ExecutionGas] = ACCOUNT_WRITE + CALL_STIPEND # Contract Creation - CODE_DEPOSIT_PER_BYTE: Final[ExecutionGas] = ExecutionGas(Uint(200)) CODE_INIT_PER_WORD: Final[ExecutionGas] = ExecutionGas(Uint(2)) CREATE_ACCESS: Final[ExecutionGas] = ACCOUNT_WRITE + COLD_ACCOUNT_ACCESS @@ -151,7 +150,6 @@ class GasCosts: # Transactions TX_BASE: Final[ExecutionGas] = ExecutionGas(Uint(12000)) - TX_CREATE: Final[ExecutionGas] = ExecutionGas(Uint(32000)) TX_VALUE_COST: Final[ExecutionGas] = ExecutionGas(Uint(6000)) TX_DATA_TOKEN_STANDARD: Final[ExecutionGas] = ExecutionGas(Uint(4)) TX_DATA_TOKEN_FLOOR: Final[ExecutionGas] = ExecutionGas(Uint(16)) @@ -835,8 +833,10 @@ def calculate_message_call_gas( memory_cost : The amount needed to extend the memory in the current frame. extra_gas : - The amount of gas needed for transferring value + creating a new - account inside a message call. + The call's own execution charge (access, value transfer and + delegation resolution) that the forwarding budget must cover. + Account creation is charged in state gas separately; `CALL` + charges this itself and passes zero here. call_stipend : The amount of stipend provided to a message call to execute code while transferring value (ETH). diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index 432a3500baa..b5089bac2a0 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -97,10 +97,9 @@ def sstore(evm: Evm) -> None: else: gas_cost += GasCosts.WARM_ACCESS - # Gas must cover the access cost before the state access below - # records the slot read in the Block Access List. Post-repricing the - # access cost can exceed the stipend, so the EIP-2200 stipend sentry - # (`gas_left > CALL_STIPEND`) is no longer sufficient on its own. + # EIP-2200 stipend sentry, checked before the state access below + # records the slot read in the Block Access List. The `max` guards an + # access cost repriced above the stipend; neither warmth is today. check_gas( evm, max(gas_cost, ExecutionGas(GasCosts.CALL_STIPEND + Uint(1))) ) @@ -118,14 +117,16 @@ def sstore(evm: Evm) -> None: state_gas = StateGas(Uint(0)) - # Write cost: charged on the first change to the slot this transaction. + # Write cost: charged each time the slot moves away from its + # transaction-start value; restoring it refunds the charge below. if original_value == current_value and current_value != new_value: gas_cost += GasCosts.STORAGE_WRITE # Refund Counter Calculation 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 + # Slot non-zero at transaction start is cleared; granted on + # each such clear. evm.gas_meter.refund_counter += GasCosts.REFUND_STORAGE_CLEAR if original_value != 0 and current_value == 0: @@ -133,8 +134,8 @@ def sstore(evm: Evm) -> None: evm.gas_meter.refund_counter -= GasCosts.REFUND_STORAGE_CLEAR if original_value == new_value: - # Slot restored to its original value: refund the STORAGE_WRITE - # charged on the first-time change earlier this transaction. + # Slot restored to its original value: refund the + # STORAGE_WRITE charged when it was moved away. evm.gas_meter.refund_counter += int(GasCosts.STORAGE_WRITE) # STATE GAS diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/__init__.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/__init__.py index a84179b40a4..b3951695089 100644 --- a/tests/amsterdam/eip8038_state_access_gas_cost_increase/__init__.py +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/__init__.py @@ -1,3 +1,3 @@ """ -Tests for [EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). +Tests for [EIP-8038: State-access gas cost update](https://eips.ethereum.org/EIPS/eip-8038). """ diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/eip_checklist_external_coverage.txt b/tests/amsterdam/eip8038_state_access_gas_cost_increase/eip_checklist_external_coverage.txt new file mode 100644 index 00000000000..ad7a2171d9b --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/eip_checklist_external_coverage.txt @@ -0,0 +1,4 @@ +general/code_coverage/eels = Measured with --cov=ethereum.forks.amsterdam while filling this suite: calculate_intrinsic_cost, sload, sstore, balance, extcodesize, extcodecopy, extcodehash, generic_call, callcode, delegatecall, staticcall, generic_create, selfdestruct, resolve_delegated_code_address and set_delegation have no missed EIP-8038 statements; the only unexecuted lines in them are the EIP-214 static-context and EIP-3860 init-code-size raises +general/code_coverage/test_coverage = Measured with --cov=tests.amsterdam.eip8038_state_access_gas_cost_increase --cov-branch: 1713/1716 statements and 69/72 branches covered +general/code_coverage/missed_lines = Three test statements are unreachable by construction: the two unknown-invalidity fallbacks in test_set_code_auth_gas.py and the loop body of gas_costs_before_increase in test_exact_balance_no_fallback.py, since the immediate ancestor fork always differs. Spec-side misses are the EIP-214 static-context raises, the EIP-3860, EIP-3541 and EIP-170 size and prefix checks, the EIP-7702 signature rejections and the StackDepthLimitError that the 63/64 rule makes unreachable, none of which EIP-8038 touches; charge_value_transfer_to_non_alive_account and create_evm's AddressCollision are covered by the EIP-2780 and EIP-8037 suites +gas_refunds_changes/test/cross_functional/calldata_cost = Covered by tests/amsterdam/eip7976_increase_calldata_floor_cost/test_refunds.py::test_gas_refunds_from_data_floor[refund_type_RefundTypes.STORAGE_CLEAR], which reads REFUND_STORAGE_CLEAR from the Amsterdam gas model and pins the refunded gas above, below and exactly at the floor diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/eip_checklist_not_applicable.txt b/tests/amsterdam/eip8038_state_access_gas_cost_increase/eip_checklist_not_applicable.txt new file mode 100644 index 00000000000..c5fbcff940c --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/eip_checklist_not_applicable.txt @@ -0,0 +1,13 @@ +general/code_coverage/second_client = Optional +opcode = EIP reprices existing opcodes without altering their semantics, stack behaviour or memory behaviour; the repricing itself is covered under gas_cost_changes +precompile = EIP does not introduce a precompile +removed_precompile = EIP does not remove a precompile +system_contract = EIP does not introduce a system contract +transaction_type = EIP does not introduce a new transaction type +block_header_field = EIP does not add any new block header fields +block_body_field = EIP does not add any new block body fields +blob_count_changes = EIP does not introduce any blob count changes +execution_layer_request = EIP does not introduce an execution layer request +new_transaction_validity_constraint = EIP modifies the existing intrinsic gas validity constraint rather than introducing a new one; the modified constraint is covered under modified_transaction_validity_constraint +block_level_constraint = EIP introduces no block-level validation constraint; the two-dimensional block gas accounting its state-gas charges feed belongs to EIP-8037 +gas_refunds_changes/test/exceptional_abort/non_revertable = Every EIP-8038 refund is journalled and rolls back with the frame that earned it, so there is no non-revertable refund operation to test; the revertable cases are covered in test_sstore_refunds_revert.py diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/spec.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/spec.py index ca2d8133a21..00f9a4cd710 100644 --- a/tests/amsterdam/eip8038_state_access_gas_cost_increase/spec.py +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/spec.py @@ -12,5 +12,5 @@ class ReferenceSpec: ref_spec_8038 = ReferenceSpec( - "EIPS/eip-8038.md", "fc2322854d047ba1fd6e3ae9e61fb7a915535cb7" + "EIPS/eip-8038.md", "f83531466a3862853424a81d000727b3043ef2cc" ) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_access_list_gas.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_access_list_gas.py index 06eebb69b49..5332bd884c1 100644 --- a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_access_list_gas.py +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_access_list_gas.py @@ -1,5 +1,5 @@ """ -Tests for [EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). +Tests for [EIP-8038: State-access gas cost update](https://eips.ethereum.org/EIPS/eip-8038). Covers the EIP-8038 access-list repricing: @@ -30,6 +30,7 @@ Op, StateTestFiller, Transaction, + TransactionReceipt, ) from execution_testing.checklists import EIPChecklist @@ -74,21 +75,33 @@ def test_access_list_intrinsic_surcharge( duplicate: bool, ) -> None: """ - Assert the per-entry intrinsic access-list surcharge. - - The intrinsic-cost delta from adding the access list, minus the - EIP-7981 floor-token contribution, must equal - ``n_addr * TX_ACCESS_LIST_ADDRESS + n_keys * TX_ACCESS_LIST_STORAGE_KEY``. - A simple value-less transaction then exercises the access list end to - end. + Pin the exact intrinsic cost of each access-list shape. + + The transaction is handed a ``gas_limit`` equal to its intrinsic and + sent to a codeless recipient, so it runs nothing and the receipt + accounts for the intrinsic alone — including the per-entry surcharge + and the EIP-7981 floor tokens the Amsterdam calculator charges on + access-list bytes. Pinning every shape pins the per-entry increments + as the differences between them, duplicate entries included: the + ``duplicate_addr`` shape lists one address twice and is billed twice. """ access_list = _make_access_list(n_addr, n_keys_each, duplicate=duplicate) + entries = access_list if access_list else None + + intrinsic = fork.transaction_intrinsic_cost_calculator()( + access_list=entries + ) + + # A codeless recipient executes nothing, so the whole limit is the + # intrinsic and the receipt pins it exactly. + recipient = pre.fund_eoa(amount=0) - contract = pre.deploy_contract(code=Op.STOP) tx = Transaction( - to=contract, + to=recipient, sender=pre.fund_eoa(), - access_list=access_list if access_list else None, + access_list=entries, + gas_limit=intrinsic, + expected_receipt=TransactionReceipt(cumulative_gas_used=intrinsic), ) state_test(pre=pre, post={}, tx=tx) @@ -102,15 +115,16 @@ def test_access_list_duplicate_address_key_intrinsic_and_warmth( fork: Fork, ) -> None: """ - A duplicated ``(address, storage_key)`` access-list entry is billed - twice intrinsically but warms the slot only once. - - The same ``(contract, slot)`` pair is listed twice. The intrinsic - surcharge (floor tokens isolated as in - ``test_access_list_intrinsic_surcharge``) bills both listings: - ``2 * TX_ACCESS_LIST_ADDRESS + 2 * TX_ACCESS_LIST_STORAGE_KEY``. At - runtime the slot is nonetheless warm on its first ``SLOAD`` - (``WARM_SLOAD``), since warmth is set-membership, not a counter. + A duplicated ``(address, storage_key)`` access-list entry warms the + slot only once. + + The same ``(contract, slot)`` pair is listed twice, yet the slot is + warm on its first ``SLOAD`` and pays ``WARM_SLOAD``, since warmth is + set-membership rather than a counter. That the duplicate is + nonetheless *billed* twice in the intrinsic is pinned by + ``test_access_list_intrinsic_surcharge``'s ``duplicate_addr`` shape; + this test asserts only the warmth, which is all its measurement can + see. """ slot = 0x42 diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_call_gas.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_call_gas.py index 5075c2e26ce..8d742954149 100644 --- a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_call_gas.py +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_call_gas.py @@ -1,5 +1,5 @@ """ -Tests for the EIP-8038 [State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038) +Tests for the EIP-8038 [State-access gas cost update](https://eips.ethereum.org/EIPS/eip-8038) ``CALL``-family execution-gas dimension. Under EIP-8038 the call opcodes are repriced in their *execution* gas @@ -35,6 +35,7 @@ Header, Op, StateTestFiller, + Storage, Transaction, TransactionReceipt, ) @@ -301,7 +302,11 @@ def test_call_value_to_new_account_seam( # block_gas_used = max(block_execution, block_state). The CALL's # NEW_ACCOUNT lands on the state axis; the execution axis is the # access plus value-transfer cost. - tx_execution = intrinsic + caller_code.execution_cost(fork) + tx_execution = ( + intrinsic + + caller_code.execution_cost(fork) + - fork.call_value_stipend() + ) tx_state = caller_code.state_cost(fork) expected_gas_used = max(tx_execution, tx_state) # State must dominate here, proving NEW_ACCOUNT hit the state axis. @@ -311,6 +316,9 @@ def test_call_value_to_new_account_seam( to=caller, sender=pre.fund_eoa(), state_gas_reservoir=new_account_state_gas, + expected_receipt=TransactionReceipt( + cumulative_gas_used=tx_execution + tx_state + ), ) state_test( @@ -427,6 +435,309 @@ def test_call_exact_gas_oog( state_test(env=env, pre=pre, post=post, tx=tx) +@EIPChecklist.GasCostChanges.Test.OutOfGas() +@pytest.mark.parametrize( + "sufficient_gas", [True, False], ids=["sufficient", "insufficient"] +) +def test_call_value_exact_gas_oog( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + sufficient_gas: bool, +) -> None: + """ + Drive a *value-bearing* cold CALL at exactly its gas and one short. + + The existing boundary test forwards no value, so it never exercises + the ``CALL_VALUE`` component of the charge. The inner frame must hold + the whole charge — access plus ``CALL_VALUE`` — before the child is + spawned, even though the stipend inside ``CALL_VALUE`` is handed to + the child and returned unused by a ``STOP`` callee. + """ + # Alive target, so no account creation lands on the state axis. + target = pre.deploy_contract(Op.STOP, balance=1) + + inner_call = Op.CALL.with_metadata( + address_warm=False, value_transfer=True + )( + gas=0, + address=target, + value=1, + args_offset=0, + args_size=0, + ret_offset=0, + ret_size=0, + ) + inner_code = inner_call + Op.STOP + inner = pre.deploy_contract(inner_code, balance=1) + + inner_gas_exact = inner_code.gas_cost(fork) + if not sufficient_gas: + inner_gas_exact -= 1 + + storage = Storage() + caller_code = Op.SSTORE( + storage.store_next(1 if sufficient_gas else 0, "inner_result"), + Op.CALL(gas=inner_gas_exact, address=inner), + ) + caller = pre.deploy_contract(caller_code) + + tx = Transaction(to=caller, sender=pre.fund_eoa()) + + post = { + caller: Account(storage=storage), + # The value moves only when the inner frame could pay in full. + target: Account(balance=2 if sufficient_gas else 1), + } + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize( + "target_alive", [True, False], ids=["alive_target", "empty_target"] +) +def test_call_value_insufficient_balance_preflight( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + target_alive: bool, +) -> None: + """ + A value CALL the caller cannot fund keeps the charge and returns the + grant. + + The preflight aborts without spawning a child, so the access and + ``ACCOUNT_WRITE`` charges stand while the child's grant — including + the value stipend — comes back untouched. The measured consumption is + therefore the charge minus the stipend, exactly as for a callee that + returns it unused. With an empty target the account-creation charge is + levied and then refilled, so the state axis nets to zero either way. + """ + if target_alive: + target = pre.deploy_contract(Op.STOP, balance=1) + else: + # Empty target: the preflight charges the creation and refills it. + target = pre.fund_eoa(amount=0) + + measured_code = Op.CALL.with_metadata( + address_warm=False, value_transfer=True + )( + gas=0, + address=target, + value=1, + args_offset=0, + args_size=0, + ret_offset=0, + ret_size=0, + ) + own_cold = Op.CALL(address_warm=False, value_transfer=True) + + # Balance 0: the transfer can never be funded, so the preflight at + # `system.py`'s `insufficient_balance` arm is taken. + measure_address = _measure_call( + pre, fork, measured_code, own_cold, balance=0 + ) + + measured_gas = own_cold.gas_cost(fork) - fork.call_value_stipend() + + tx = Transaction( + to=measure_address, + sender=pre.fund_eoa(), + state_gas_reservoir=0, + ) + + post = { + measure_address: Account(storage={0: measured_gas}, balance=0), + # No value moved. The empty target is never materialised, which + # is the creation charge being refilled rather than kept. + target: Account(balance=1) if target_alive else Account.NONEXISTENT, + } + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_repeated_value_call_to_same_recipient( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, +) -> None: + """ + A second value CALL to the same recipient pays ``ACCOUNT_WRITE`` + again. + + Nothing tracks account writes per recipient within a transaction, so + only the *access* half goes warm on the repeat: the second call is + charged ``WARM_ACCESS + CALL_VALUE``, not ``WARM_ACCESS`` alone. + """ + recipient = pre.deploy_contract(Op.STOP, balance=1) + + value_call = Op.CALL.with_metadata( + address_warm=False, value_transfer=True + )( + gas=0, + address=recipient, + value=1, + args_offset=0, + args_size=0, + ret_offset=0, + ret_size=0, + ) + # Second call: the recipient is warm from the first, but the write + # component is charged afresh. + measured_code = Op.CALL.with_metadata( + address_warm=True, value_transfer=True + )( + gas=0, + address=recipient, + value=1, + args_offset=0, + args_size=0, + ret_offset=0, + ret_size=0, + ) + cost_metadata = Op.CALL(address_warm=True, value_transfer=True) + + overhead_cost = measured_code.gas_cost(fork) - cost_metadata.gas_cost(fork) + caller_code = Op.POP(value_call) + CodeGasMeasure( + code=measured_code, + overhead_cost=overhead_cost, + extra_stack_items=1, + ) + caller = pre.deploy_contract(code=caller_code, balance=2) + + # The STOP recipient returns the stipend both times. + measured_gas = cost_metadata.gas_cost(fork) - fork.call_value_stipend() + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + state_gas_reservoir=0, + ) + + post = { + caller: Account(storage={0: measured_gas}, balance=0), + # Both transfers landed. + recipient: Account(balance=3), + } + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_call_value_to_precompile_creates_leaf( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Pin both gas dimensions of a value CALL that creates a precompile leaf. + + The header witnesses state gas and the receipt also witnesses execution + gas, including the warm access and the precompile's empty-input cost. + """ + identity_precompile = Address(4) + + intrinsic = fork.transaction_intrinsic_cost_calculator()() + + call = Op.CALL.with_metadata( + address_warm=True, value_transfer=True, account_new=True + )( + gas=0, + address=identity_precompile, + value=1, + args_offset=0, + args_size=0, + ret_offset=0, + ret_size=0, + ) + caller_code = Op.POP(call) + Op.STOP + caller = pre.deploy_contract(code=caller_code, balance=1) + + new_account_state_gas = call.state_cost(fork) + + tx_execution = ( + intrinsic + + caller_code.execution_cost(fork) + - fork.call_value_stipend() + + fork.gas_costs().PRECOMPILE_IDENTITY_BASE + ) + tx_state = caller_code.state_cost(fork) + expected_gas_used = max(tx_execution, tx_state) + # The state axis must dominate, or the header would not witness the + # creation charge at all. + assert expected_gas_used == new_account_state_gas + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + state_gas_reservoir=new_account_state_gas, + expected_receipt=TransactionReceipt( + cumulative_gas_used=tx_execution + tx_state + ), + ) + + state_test( + pre=pre, + # A real account now exists at the precompile address. + post={identity_precompile: Account(balance=1)}, + tx=tx, + blockchain_test_header_verify=Header(gas_used=expected_gas_used), + ) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_call_value_to_self( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + A value CALL to self is warm and alive, yet still pays + ``CALL_VALUE``. + + Sending to ``ADDRESS`` moves nothing and creates nothing, but the + write component is charged all the same. Unlike the ``STOP``-callee + cases, the stipend here is *consumed* rather than returned: the child + re-enters the caller's own code, cannot afford the same charge out of + the stipend alone, and halts out of gas — so the receipt pins the + whole charge including the stipend. + """ + call = Op.CALL.with_metadata(address_warm=True, value_transfer=True)( + gas=0, + address=Op.ADDRESS, + value=1, + args_offset=0, + args_size=0, + ret_offset=0, + ret_size=0, + ) + caller_code = Op.POP(call) + Op.STOP + caller = pre.deploy_contract(code=caller_code, balance=1) + + intrinsic = fork.transaction_intrinsic_cost_calculator()() + # Self is alive, so no creation charge on either axis. + assert call.state_cost(fork) == 0 + expected_gas_used = intrinsic + caller_code.gas_cost(fork) + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + state_gas_reservoir=0, + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used + ), + ) + + state_test( + pre=pre, + # The self-send is a no-op: the balance stays where it was. + post={caller: Account(balance=1)}, + tx=tx, + ) + + @EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() @pytest.mark.with_all_call_opcodes() def test_call_self_is_warm( @@ -703,3 +1014,113 @@ def test_call_value_stipend_is_usable( # 1 when the stipend funded the callee's work, 0 when it ran out. post = {caller: Account(storage={0: 1 if value else 0})} state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize( + "first_call_fails", [True, False], ids=["child_fails", "child_succeeds"] +) +def test_failed_call_refills_creation_state_gas( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + first_call_fails: bool, +) -> None: + """ + A value ``CALL`` whose child frame errors refills the + account-creation state gas it charged. + + ``CALL`` charges the account-creation state gas itself, before the + child runs, whenever it moves value to an account that is not alive. + It then resolves that charge by the state's fate: a child that errors + rolls the transfer back, leaving no account created, and the charge + is credited straight back to the calling frame. This is the + ``CALL``-family twin of + ``test_create_gas.py::test_failed_initcode_refills_creation_state_gas``. + + Reaching the failing arm needs a target that is *not alive* yet can + still fail. An ordinary empty account cannot: having no code, its + child frame halts immediately. A precompile can, being + EIP-161-empty until the transfer lands while still running code that + can run out of gas. ``ECRECOVER`` is the one whose cost exceeds the + value-transfer stipend, so forwarding nothing starves it; every + cheaper precompile would run to completion on the stipend alone. + + The factory is given a reservoir sized for exactly *one* creation and + makes two value calls, the second wrapped in ``CodeGasMeasure`` + against a fresh account. When the first call's child fails, its + charge is credited back and the second call draws the reservoir, so + the measured *execution* cost is the opcode's own. When the first + call succeeds, the reservoir is gone and the second call spills the + account-creation gas into ``gas_left``, so the measured cost is the + opcode's full two-dimensional ``gas_cost``. The two arms differ by + exactly the charge under test. + """ + ecrecover = Address(1) + + # Forward nothing and the stipend alone must leave ECRECOVER short; + # forward its cost and the call completes. Both derived, so a + # repricing of either moves with the fork. + ecrecover_cost = fork.gas_costs().PRECOMPILE_ECRECOVER + assert fork.call_value_stipend() < ecrecover_cost, ( + "ECRECOVER is no longer starved by the stipend alone; pick a " + "precompile whose cost still exceeds it" + ) + forwarded = 0 if first_call_fails else ecrecover_cost + + # The measured second call: cold target, value-bearing, creating the + # account. Its state component is the charge under test. + measured_bare = Op.CALL( + address_warm=False, value_transfer=True, account_new=True + ) + fresh_target = pre.fund_eoa(amount=0) + measured_call = Op.CALL(gas=0, address=fresh_target, value=1) + + # The fresh recipient is codeless, so it executes nothing and hands + # the forwarded value-call stipend straight back; the measured + # consumption is the charged cost less that stipend, as in + # `test_call_value_alive_target_gas`. + expected_measured = ( + measured_bare.execution_cost(fork) + if first_call_fails + else measured_bare.gas_cost(fork) + ) - fork.call_value_stipend() + + # The measured window's only overhead is the seven operand pushes; + # a metadata-free `Op.CALL` carries no value-transfer or + # account-creation component to subtract. + arg_pushes = 7 * Op.PUSH1(0).execution_cost(fork) + + storage = Storage() + factory_code = Op.POP( + Op.CALL(gas=forwarded, address=ecrecover, value=1) + ) + CodeGasMeasure( + code=measured_call, + overhead_cost=arg_pushes, + extra_stack_items=1, + sstore_key=storage.store_next( + expected_measured, "second_call_execution_gas" + ), + ) + factory = pre.deploy_contract(code=factory_code, balance=2) + + tx = Transaction( + to=factory, + sender=pre.fund_eoa(), + state_gas_reservoir=measured_bare.state_cost(fork), + ) + + # A starved precompile rolls its transfer back, so the precompile + # address stays empty and is pruned; a funded one keeps the wei. The + # second call lands either way -- in the spilling arm because + # execution gas covers the charge -- which is what makes the measured + # value, not the post-state, the discriminator. + post = { + factory: Account(storage=storage), + fresh_target: Account(balance=1), + ecrecover: Account.NONEXISTENT + if first_call_fails + else Account(balance=1), + } + state_test(env=env, pre=pre, post=post, tx=tx) 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 3484dccd7e4..f74d039b48a 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 @@ -1,5 +1,5 @@ """ -Tests for the EIP-8038 [State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038) +Tests for the EIP-8038 [State-access gas cost update](https://eips.ethereum.org/EIPS/eip-8038) ``CREATE``/``CREATE2`` execution-gas dimension. Under EIP-8038 the contract-creation opcodes are repriced in their @@ -371,6 +371,7 @@ def test_create_tx_gas_boundary( ) +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() @pytest.mark.with_all_create_opcodes() @pytest.mark.parametrize( "abort_mode", @@ -452,6 +453,7 @@ def test_aborted_create_does_not_warm_address( state_test(pre=pre, post=post, tx=tx) +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() @pytest.mark.pre_alloc_mutable def test_create2_to_occupied_address( state_test: StateTestFiller, @@ -459,15 +461,17 @@ def test_create2_to_occupied_address( fork: Fork, ) -> None: """ - Verify ``CREATE2`` to an occupied address creates nothing and refunds. + Verify ``CREATE2`` to an occupied address creates nothing. When ``CREATE2`` targets an address that is not deployable (here an already-deployed contract, whose ``code_hash`` is non-empty), the creation aborts after the account-access charge: the opcode pushes - ``0``, bumps the factory's nonce, charges the message gas to the - execution dimension, and refunds the ``NEW_ACCOUNT`` *state* gas so no - net account-creation charge lands. No child frame runs, so the - occupied contract's code and storage are left untouched. + ``0``, bumps the factory's nonce, and consumes the withheld child + gas grant. No child frame runs, so the occupied contract's code and + storage are left untouched. + + This test asserts the *state* effects only; the gas outcome is + pinned by ``test_create_collision_consumes_child_grant``. """ # Initcode the factory passes to CREATE2; were the target free it # would deposit a single STOP. The salt is fixed so the collision @@ -532,3 +536,279 @@ def test_create2_to_occupied_address( ), } state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.OutOfGas() +@pytest.mark.with_all_create_opcodes() +@pytest.mark.parametrize( + "abort_mode", + [ + pytest.param("insufficient_balance", id="insufficient_balance"), + pytest.param("nonce_overflow", id="nonce_overflow"), + ], +) +@pytest.mark.parametrize( + "sufficient_gas", [True, False], ids=["sufficient", "insufficient"] +) +def test_aborted_create_gas_boundary( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + create_opcode: Op, + abort_mode: str, + sufficient_gas: bool, +) -> None: + """ + A preflight-aborted ``CREATE`` is charged ``CREATE_ACCESS`` and + nothing beyond it. + + The abort (insufficient balance for the endowment, or a nonce at + ``2**64 - 1``) is decided *before* the destination access, so the + account-creation state gas is never charged and no child gas grant + is withheld. The whole operation therefore costs exactly the + opcode's own execution charge. + + Forwarding precisely that much succeeds; one gas less makes the + opcode itself run out. The boundary is two-sided, so it pins + ``CREATE_ACCESS`` exactly rather than bounding it from one side. + Zero-size init code keeps the charge free of memory-expansion and + EIP-3860 word costs (and, for ``CREATE2``, of keccak word costs), + leaving ``CREATE_ACCESS`` as the whole of it. + """ + # A non-zero endowment is what the insufficient-balance arm starves; + # the nonce-overflow arm funds it so the balance check passes and the + # nonce is the sole reason for the abort. + endowment = 1 + create_call = ( + Op.CREATE2(value=endowment, offset=0, size=0, salt=0) + if create_opcode == Op.CREATE2 + else Op.CREATE(value=endowment, offset=0, size=0) + ) + + # Everything the child frame spends: the operand pushes plus the + # opcode's execution charge. Nothing follows the CREATE, so the code + # runs off its end into an implicit STOP at zero gas. + forwarded = create_call.execution_cost(fork) + if not sufficient_gas: + forwarded -= 1 + + nonce = 2**64 - 1 if abort_mode == "nonce_overflow" else 1 + balance = 0 if abort_mode == "insufficient_balance" else endowment + factory = pre.deploy_contract( + code=create_call, nonce=nonce, balance=balance + ) + + storage = Storage() + caller = pre.deploy_contract( + code=Op.SSTORE( + storage.store_next(1 if sufficient_gas else 0, "call_result"), + Op.CALL(gas=forwarded, address=factory), + ), + ) + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + state_gas_reservoir=0, + ) + + # The abort returns before the nonce increment, so the factory is + # left exactly as deployed in both arms. + post = { + caller: Account(storage=storage), + factory: Account(nonce=nonce, balance=balance), + } + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.OutOfGas() +@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize("trailing_gas", [0, 1], ids=["exact", "one_too_many"]) +def test_create_collision_consumes_child_grant( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + trailing_gas: int, +) -> None: + """ + A ``CREATE2`` collision consumes the withheld child gas grant and + charges no state gas. + + The collision is detected *after* the child grant is withheld, and + the aborting branch returns without ever restoring it, so the + frame loses the all-but-one-64th share outright. With ``R`` gas left + when the grant is withheld, exactly ``R // 64`` survives the + collision. + + A budget of ``64 * n`` is forwarded past the opcode so that ``n`` + gas remains, and the factory then burns ``n`` gas in ``JUMPDEST``s + (one gas each, so the budget is spent at the finest granularity + available). Spending exactly ``n`` succeeds; one more runs out. + Without the grant being consumed the frame would still hold the + full ``64 * n``, so this fails loudly if the burn stops happening. + + It also pins the *absence* of a state-gas charge. A collision target + is necessarily alive — ``account_deployable`` is false only for a + non-zero nonce or non-empty code, either of which makes the account + non-empty — so ``NEW_ACCOUNT`` is never charged on this path and + there is correspondingly nothing to refund. The transaction runs + with a zero reservoir and a budget far below ``NEW_ACCOUNT``, so a + spurious charge would spill into execution gas and abort the frame. + """ + # Zero-size init code: the CREATE2 address derivation hashes the + # empty string, so the collision address needs no memory setup and + # the opcode charge carries no keccak or EIP-3860 word cost. + salt = 0 + create_call = Op.CREATE2(value=0, offset=0, size=0, salt=salt) + + # Gas left when the grant is withheld, chosen as a multiple of 64 so + # the surviving share is exact rather than rounded. + surviving_gas = 10 + budget = 64 * surviving_gas + + factory_code = create_call + Op.JUMPDEST * (surviving_gas + trailing_gas) + factory = pre.deploy_contract(code=factory_code) + + collision_address = compute_create_address( + address=factory, + salt=salt, + initcode=b"", + opcode=Op.CREATE2, + ) + + # Pre-occupy the derived address so the creation collides. Non-empty + # code is what makes it non-deployable; `address=` hard-codes the + # occupant there and requires `pre_alloc_mutable`. + occupant_code = Op.SSTORE(0, 0x42) + Op.STOP + pre.deploy_contract(code=occupant_code, nonce=1, address=collision_address) + + sufficient_gas = trailing_gas == 0 + storage = Storage() + caller = pre.deploy_contract( + code=Op.SSTORE( + storage.store_next(1 if sufficient_gas else 0, "call_result"), + Op.CALL( + gas=create_call.execution_cost(fork) + budget, + address=factory, + ), + ), + ) + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + state_gas_reservoir=0, + ) + + # The collision bumps the factory's nonce (it happens on the + # aborting branch, after the deployability check) and leaves the + # occupant untouched, its own initcode never having run. The + # out-of-gas arm reverts the frame, so the nonce bump is rolled + # back with it. + post = { + caller: Account(storage=storage), + factory: Account(nonce=2 if sufficient_gas else 1), + collision_address: Account(code=occupant_code, storage={}), + } + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize( + "first_initcode_reverts", [True, False], ids=["reverts", "succeeds"] +) +def test_failed_initcode_refills_creation_state_gas( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + first_initcode_reverts: bool, +) -> None: + """ + A failed init code refills the account-creation state gas, so one + creation's worth of reservoir funds a failed creation and a later + successful one. + + ``CREATE`` charges the account-creation state gas on the + destination access, before the child runs, and resolves it by the + state's fate afterwards: a child that errors gets the charge + credited straight back. This is the one ``CREATE`` failure path that + moves state gas, and the credit is observable as the absence of a + spill. + + The factory is given a reservoir sized for exactly *one* creation + and performs two ``CREATE``s, the second wrapped in + ``CodeGasMeasure``. When the first init code reverts, its charge is + credited back and the second creation draws the reservoir, so the + measured *execution* cost is the bare opcode's. When the first + init code succeeds, the reservoir is gone and the second creation + spills the account-creation gas into ``gas_left``, so the measured + cost is the opcode's full two-dimensional ``gas_cost``. The two arms + therefore differ by exactly the charge under test. + """ + # The first creation's init code, five bytes either way so both arms + # run identical code with identical memory. `REVERT` is used rather + # than an invalid opcode so the child returns its unused execution + # gas and the measurement below sees only the state-gas effect. + first_initcode = bytes(Op.REVERT(0, 0)) if first_initcode_reverts else b"" + initcode_len = 5 + assert len(first_initcode) <= initcode_len + first_initcode = first_initcode.ljust(initcode_len, b"\x00") + initcode_word = int.from_bytes(first_initcode, "big") << ( + 256 - 8 * initcode_len + ) + + # The second creation uses zero-size init code, so its child halts + # immediately, deposits empty code and returns its whole grant. That + # keeps the measured window the CREATE opcode's own charge. + measured_bare = Op.CREATE(init_code_size=0) + measured_call = Op.CREATE(value=0, offset=0, size=0) + arg_pushes = 3 * Op.PUSH1(0).execution_cost(fork) + + # Reservoir funded, so no spill; reservoir spent, so the whole + # account-creation charge lands on the execution dimension. + expected_measured = ( + measured_bare.execution_cost(fork) + if first_initcode_reverts + else measured_bare.gas_cost(fork) + ) + + storage = Storage() + factory_code = ( + Op.MSTORE(0, initcode_word) + + Op.POP(Op.CREATE(value=0, offset=0, size=initcode_len)) + + CodeGasMeasure( + code=measured_call, + overhead_cost=arg_pushes, + extra_stack_items=1, + sstore_key=storage.store_next( + expected_measured, "second_create_execution_gas" + ), + ) + ) + factory = pre.deploy_contract(code=factory_code) + + # `CREATE` derives from the factory's nonce at the time of the call, + # and the increment survives a failed child, so the two addresses are + # the same in both arms. + first_address = compute_create_address(address=factory, nonce=1) + second_address = compute_create_address(address=factory, nonce=2) + + tx = Transaction( + to=factory, + sender=pre.fund_eoa(), + state_gas_reservoir=fork.create_state_gas(code_size=0), + ) + + # A reverted creation leaves nothing behind; a successful one + # deposits the empty code its init code returned. The second + # creation succeeds in both arms — in the spilling arm because + # execution gas can cover the charge — which is what makes the + # measured value, not the post-state, the discriminator. + post = { + factory: Account(nonce=3, storage=storage), + first_address: Account.NONEXISTENT + if first_initcode_reverts + else Account(nonce=1, code=b""), + second_address: Account(nonce=1, code=b""), + } + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_eip_mainnet.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_eip_mainnet.py index 667cedbb638..036852600fa 100644 --- a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_eip_mainnet.py +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_eip_mainnet.py @@ -1,6 +1,6 @@ """ Mainnet-marked happy-path smoke tests for -[EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). +[EIP-8038: State-access gas cost update](https://eips.ethereum.org/EIPS/eip-8038). One minimal success per repriced dimension (no boundaries, no exact magnitudes): a state slot is written, a value-bearing cold ``CALL`` @@ -230,15 +230,18 @@ def test_existing_authority_refund( pre: Alloc, ) -> None: """ - Re-authorizing an already-delegated authority applies the - existing-authority refund and re-points the delegation; the tx - succeeds with the new designation installed. + Re-authorizing an already-delegated authority re-points the + delegation; the tx succeeds with the new designation installed and + the authority's nonce bumped. """ old_target = pre.deploy_contract(code=Op.STOP) new_target = pre.deploy_contract(code=Op.STOP) - # Authority already carries a delegation, so the new authorization - # triggers REFUND_AUTH_PER_EXISTING_ACCOUNT. + # The authority already carries a delegation, so its leaf exists and + # no account-creation component applies; the osaka existing-authority + # refund is gone. The exact charges are pinned by the EIP-2780 + # suite's `test_authorization_charges.py`; this test only checks the + # delegation is re-pointed on a mainnet-marked path. auth_signer = pre.fund_eoa(delegation=old_target) authorization_list = [ diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_exact_balance_no_fallback.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_exact_balance_no_fallback.py index 98263945731..ee8e7b00f9c 100644 --- a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_exact_balance_no_fallback.py +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_exact_balance_no_fallback.py @@ -1,6 +1,6 @@ """ No-silent-fallback exact-balance tests for -[EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). +[EIP-8038: State-access gas cost update](https://eips.ethereum.org/EIPS/eip-8038). Each test funds the sender with *exactly* ``gas_limit * gas_price`` and sets ``gas_limit`` one gas below the spec-correct Amsterdam intrinsic for diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_ext_code_opcodes_gas.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_ext_code_opcodes_gas.py index 5470eeb0436..a823a05bbff 100644 --- a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_ext_code_opcodes_gas.py +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_ext_code_opcodes_gas.py @@ -1,5 +1,5 @@ """ -Tests for [EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). +Tests for [EIP-8038: State-access gas cost update](https://eips.ethereum.org/EIPS/eip-8038). Covers the EIP-8038 ``EXT*`` "double-read" surcharge: ``EXTCODESIZE`` and ``EXTCODECOPY`` perform two database reads (the account leaf and then the @@ -39,34 +39,29 @@ # - executable: builds the runnable opcode targeting ``target`` # - cost_metadata: builds the metadata-only opcode for gas computation # - extra_stack_items: stack items left by the opcode (for CodeGasMeasure) -# - code_read_surcharge: whether EIP-8038 adds the extra WARM_ACCESS read EXT_OPCODES = [ pytest.param( lambda target: Op.EXTCODESIZE(target), lambda warm: Op.EXTCODESIZE(address_warm=warm), 1, - True, id="EXTCODESIZE", ), pytest.param( lambda target: Op.EXTCODECOPY(target, 0, 0, 0), lambda warm: Op.EXTCODECOPY(address_warm=warm), 0, - True, id="EXTCODECOPY", ), pytest.param( lambda target: Op.EXTCODEHASH(target), lambda warm: Op.EXTCODEHASH(address_warm=warm), 1, - False, id="EXTCODEHASH", ), pytest.param( lambda target: Op.BALANCE(target), lambda warm: Op.BALANCE(address_warm=warm), 1, - False, id="BALANCE", ), ] @@ -75,7 +70,7 @@ @EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() @pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) @pytest.mark.parametrize( - "executable,cost_metadata,extra_stack_items,code_read_surcharge", + "executable,cost_metadata,extra_stack_items", EXT_OPCODES, ) def test_ext_code_opcode_gas( @@ -87,7 +82,6 @@ def test_ext_code_opcode_gas( executable: Callable[[object], Bytecode], cost_metadata: Callable[[bool], Bytecode], extra_stack_items: int, - code_read_surcharge: bool, ) -> None: """ Measure the exact gas of an external-code/account-access opcode and @@ -97,8 +91,6 @@ def test_ext_code_opcode_gas( more than ``BALANCE``/``EXTCODEHASH`` at equal warmth (the second, code-reading database access). """ - del code_read_surcharge # encoded in `cost_metadata` - target = pre.deploy_contract(Op.STOP) measured_code = executable(target) @@ -137,6 +129,118 @@ def test_ext_code_opcode_gas( state_test(env=env, pre=pre, post=post, tx=tx) +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) +@pytest.mark.parametrize( + "target_kind", ["funded_eoa", "delegated"], ids=["funded_eoa", "delegated"] +) +@pytest.mark.parametrize( + "executable,cost_metadata,extra_stack_items", + EXT_OPCODES, +) +def test_ext_code_opcode_gas_by_target_kind( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + warm: bool, + target_kind: str, + executable: Callable[[object], Bytecode], + cost_metadata: Callable[[bool], Bytecode], + extra_stack_items: int, +) -> None: + """ + The ``EXT*`` charge does not depend on what occupies the target. + + A funded but codeless EOA and an EIP-7702 delegated account cost the + same as a code-bearing contract at equal warmth. The delegated case + is the load-bearing one: these opcodes read the account's own code, + which for a delegated account is the designator itself, so they + resolve no delegation and pay a *single* account access — unlike the + call opcodes, which pay one for the target leaf and another for the + delegation leaf. + """ + if target_kind == "funded_eoa": + # Exists and has a balance, but holds no code — distinct from the + # non-existent target the empty-account tests cover. + target = pre.fund_eoa(amount=1) + else: + delegate = pre.deploy_contract(Op.STOP) + target = pre.fund_eoa(amount=0, delegation=delegate) + + measured_code = executable(target) + overhead_cost = measured_code.gas_cost(fork) - cost_metadata( + False + ).gas_cost(fork) + + code_gas_measure = CodeGasMeasure( + code=measured_code, + overhead_cost=overhead_cost, + extra_stack_items=extra_stack_items, + ) + measure_address = pre.deploy_contract(code=code_gas_measure) + + # One access at the target's warmth plus, for the code-reading + # opcodes, the surcharge. No second access for the delegation. + expected_gas = cost_metadata(warm).gas_cost(fork) + + tx = Transaction( + to=measure_address, + sender=pre.fund_eoa(), + access_list=[AccessList(address=target, storage_keys=[])] + if warm + else None, + ) + + post = {measure_address: Account(storage={0: expected_gas})} + state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize( + "executable,cost_metadata,extra_stack_items", + EXT_OPCODES, +) +def test_ext_code_opcode_gas_precompile_target( + state_test: StateTestFiller, + env: Environment, + pre: Alloc, + fork: Fork, + executable: Callable[[object], Bytecode], + cost_metadata: Callable[[bool], Bytecode], + extra_stack_items: int, +) -> None: + """ + An ``EXT*`` read of a precompile pays the warm access cost. + + Precompiles are in the accessed set from the start of the + transaction, so no access list is needed and no cold surcharge + applies. The code-read surcharge still does, even though a precompile + holds no code to read — it is unconditional. + """ + identity_precompile = Address(4) + + measured_code = executable(identity_precompile) + overhead_cost = measured_code.gas_cost(fork) - cost_metadata( + False + ).gas_cost(fork) + + code_gas_measure = CodeGasMeasure( + code=measured_code, + overhead_cost=overhead_cost, + extra_stack_items=extra_stack_items, + ) + measure_address = pre.deploy_contract(code=code_gas_measure) + + # Pre-warmed on entry, so the warm arm is the only reachable one. + expected_gas = cost_metadata(True).gas_cost(fork) + + tx = Transaction(to=measure_address, sender=pre.fund_eoa()) + + post = {measure_address: Account(storage={0: expected_gas})} + state_test(env=env, pre=pre, post=post, tx=tx) + + @EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() @pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) @pytest.mark.parametrize( diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_fork_transition.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_fork_transition.py index 00294a928c5..e8f01c93b15 100644 --- a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_fork_transition.py +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_fork_transition.py @@ -1,6 +1,6 @@ """ Fork-transition tests for -[EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). +[EIP-8038: State-access gas cost update](https://eips.ethereum.org/EIPS/eip-8038). "Same operation, different gas" across the Amsterdam boundary. A block at ``timestamp=14_999`` runs under the pre-fork (parent) schedule; a @@ -9,27 +9,33 @@ fork (``bytecode.gas_cost`` / ``execution_cost`` / ``refund``) — nothing is hardcoded. -Two proof styles are used: - -* Account-access dimensions that are pure execution gas (``BALANCE`` cold - access and the ``EXT*`` code-read surcharge) are measured exactly with - ``CodeGasMeasure`` in each regime and asserted against the derived - cost. -* Repricings that the runtime opcode model cannot isolate without - state-gas confounders (``CALL`` with value, ``CREATE``, - ``SELFDESTRUCT`` to a fresh beneficiary, ``SSTORE`` first change) are - exercised in both blocks to prove the operation still runs in each - regime, with the ``SSTORE`` execution/state split and clear refund - compared across forks via the bytecode's own cost methods. -* The authorization intrinsic rise is proven behaviourally: a tx whose - ``gas_limit`` equals the old auth intrinsic is valid before the fork - and rejected with ``INTRINSIC_GAS_TOO_LOW`` after. +Four proof styles are used: + +* Opcode costs that are pure execution gas (``BALANCE`` cold access, the + ``EXT*`` code-read surcharge and the ``CREATE`` base) are measured + exactly with ``CodeGasMeasure`` in each regime and asserted against + the derived cost. +* Charges that cannot be measured from inside the frame that pays them + (a value-bearing ``CALL``, ``SELFDESTRUCT`` to a fresh beneficiary, + the ``SSTORE`` clear refund) are pinned per block through the + transaction receipt's ``cumulative_gas_used``, which sums both gas + dimensions and so pins a charge that the fork moves between them. +* Intrinsic repricings are pinned by setting ``gas_limit`` to the + regime's exact minimum and pinning the receipt, and by straddling + validity: one ``gas_limit`` that is sufficient before the fork and + ``INTRINSIC_GAS_TOO_LOW`` after (access lists), or the reverse + (authorizations, whose intrinsic falls). +* Model-level repricings the runtime cannot isolate (the ``SSTORE`` + execution/state split) are compared across the two schedules via the + bytecode's own cost methods while the operation is exercised in both + blocks. """ from typing import List import pytest from execution_testing import ( + AccessList, Account, Address, Alloc, @@ -39,10 +45,13 @@ Bytecode, CodeGasMeasure, Fork, + GasConsumer, Op, Storage, Transaction, TransactionException, + TransactionReceipt, + compute_create_address, ) from execution_testing.checklists import EIPChecklist @@ -86,6 +95,8 @@ def transition_blocks( pre: Alloc, *, value: int = 0, + before_gas_used: int | None = None, + after_gas_used: int | None = None, ) -> List[Block]: """ Return the two blocks that straddle the Amsterdam activation. @@ -95,23 +106,71 @@ def transition_blocks( transaction from a fresh sender to its respective ``to`` target, forwarding ``value`` so a value-bearing operation is exercised in both regimes. + + ``*_gas_used`` pins the regime's transaction receipt. A single + transaction per block makes ``cumulative_gas_used`` that + transaction's own gas, which sums both gas dimensions — unlike the + block header's ``gas_used``, where the larger dimension masks the + other. """ - return [ - Block( - timestamp=BEFORE_TS, - txs=[ - Transaction(to=before_to, value=value, sender=pre.fund_eoa()), - ], - ), - Block( - timestamp=AFTER_TS, + + def block(timestamp: int, to: Address, gas_used: int | None) -> Block: + receipt = ( + TransactionReceipt(cumulative_gas_used=gas_used) + if gas_used is not None + else None + ) + return Block( + timestamp=timestamp, txs=[ - Transaction(to=after_to, value=value, sender=pre.fund_eoa()), + Transaction( + to=to, + value=value, + sender=pre.fund_eoa(), + expected_receipt=receipt, + ), ], - ), + ) + + return [ + block(BEFORE_TS, before_to, before_gas_used), + block(AFTER_TS, after_to, after_gas_used), ] +def _cumulative_gas_used(code: Bytecode, fork: Fork) -> int: + """ + Return the receipt ``cumulative_gas_used`` for a block whose single + transaction's execution is exactly ``code`` at ``fork``. + + Gross gas is the intrinsic plus the code's own cost; the applied + refund is capped at the EIP-3529 quotient of the gross. + """ + intrinsic = fork.transaction_intrinsic_cost_calculator()( + return_cost_deducted_prior_execution=True + ) + gross = intrinsic + code.execution_cost(fork) + code.state_cost(fork) + return gross - min(gross // fork.max_refund_quotient(), code.refund(fork)) + + +def _access_list(pre: Alloc) -> List[AccessList]: + """ + Return an access list large enough that its own repricing dominates + the fall in the transaction base cost across the boundary. + """ + return [ + AccessList(address=pre.fund_eoa(amount=0), storage_keys=[0, 1]) + for _ in range(2) + ] + + +def _minimum_gas_limit(regime: Fork, access_list: List[AccessList]) -> int: + """Return the regime's intrinsic cost of an access-list transaction.""" + return regime.transaction_intrinsic_cost_calculator()( + access_list=access_list + ) + + @EIPChecklist.GasCostChanges.Test.ForkTransition.Before() @EIPChecklist.GasCostChanges.Test.ForkTransition.After() def test_cold_account_access_at_transition( @@ -157,6 +216,56 @@ def test_cold_account_access_at_transition( blockchain_test(pre=pre, blocks=blocks, post=post) +@EIPChecklist.GasCostChanges.Test.ForkTransition.Before() +@EIPChecklist.GasCostChanges.Test.ForkTransition.After() +def test_same_account_accessed_at_both_prices( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + One account, accessed on both sides of the boundary, is cold and + charged its regime's price each time. + + ``test_cold_account_access_at_transition`` gives each block its own + fresh target; here a *single* target is accessed in both blocks. The + warm set does not survive the transaction that built it, so the + post-fork access is still cold and pays the raised + ``COLD_ACCOUNT_ACCESS`` rather than ``WARM_ACCESS``. The two + measuring contracts carry byte-identical code, so the only thing + that differs between the two measurements is the schedule. + """ + before = fork.fork_at(timestamp=BEFORE_TS) + after = fork.fork_at(timestamp=AFTER_TS) + + cold_balance = Op.BALANCE.with_metadata(address_warm=False) + cost_before = cold_balance.gas_cost(before) + cost_after = cold_balance.gas_cost(after) + warm_cost_after = Op.BALANCE.with_metadata(address_warm=True).gas_cost( + after + ) + assert cost_after > cost_before + # Leaked warmth would show up as this much cheaper post-fork. + assert warm_cost_after < cost_after + + target = pre.deploy_contract(code=Op.STOP) + + measure_before = _measure_contract( + pre, Op.BALANCE(target), cost_before, before + ) + measure_after = _measure_contract( + pre, Op.BALANCE(target), cost_after, after + ) + + blocks = transition_blocks(measure_before, measure_after, pre) + + post = { + measure_before: Account(storage={0: cost_before}), + measure_after: Account(storage={0: cost_after}), + } + blockchain_test(pre=pre, blocks=blocks, post=post) + + @EIPChecklist.GasCostChanges.Test.ForkTransition.Before() @EIPChecklist.GasCostChanges.Test.ForkTransition.After() def test_ext_code_surcharge_at_transition( @@ -219,35 +328,70 @@ def test_call_value_cost_at_transition( ) -> None: """ ``CALL_VALUE`` rises across the boundary, becoming - ``ACCOUNT_WRITE + CALL_STIPEND``. The constant transition - is asserted from the derived schedules while a value-bearing ``CALL`` - is exercised in both blocks to prove it still succeeds in each - regime. + ``ACCOUNT_WRITE + CALL_STIPEND``, and each regime's charge is pinned + exactly through its receipt. + + The callee is an existing contract, so the value transfer creates no + account and the whole charge stays on the execution axis in both + regimes. The callee forwards nothing beyond the value stipend and + halts without using it, so the gas consumed is the ``CALL``'s charge + less the returned stipend. """ - callee_before = pre.deploy_contract(code=Op.STOP, balance=0) - callee_after = pre.deploy_contract(code=Op.STOP, balance=0) + before = fork.fork_at(timestamp=BEFORE_TS) + after = fork.fork_at(timestamp=AFTER_TS) - storage_before = Storage() - caller_before = pre.deploy_contract( - code=Op.SSTORE( - storage_before.store_next(1), - Op.CALL(gas=100_000, address=callee_before, value=1), - ), + # Cold, existing, value-receiving callee: the value-transfer charge + # applies but the account-creation charge does not. + call = Op.CALL.with_metadata( + address_warm=False, value_transfer=True, account_new=False ) - storage_after = Storage() - caller_after = pre.deploy_contract( - code=Op.SSTORE( - storage_after.store_next(1), - Op.CALL(gas=100_000, address=callee_after, value=1), - ), + assert call.gas_cost(after) > call.gas_cost(before) + # No account is created, so nothing lands on the post-fork state axis + # and the receipt alone pins the whole charge. + assert call.state_cost(before) == 0 + assert call.state_cost(after) == 0 + + def caller_code(callee: Address) -> Bytecode: + return ( + Op.POP( + call( + gas=0, + address=callee, + value=1, + args_offset=0, + args_size=0, + ret_offset=0, + ret_size=0, + ) + ) + + Op.STOP + ) + + callee_before = pre.deploy_contract(code=Op.STOP) + callee_after = pre.deploy_contract(code=Op.STOP) + code_before = caller_code(callee_before) + code_after = caller_code(callee_after) + + # The caller holds the wei it forwards, so the transaction itself + # carries no value and its intrinsic stays the plain one. + caller_before = pre.deploy_contract(code=code_before, balance=1) + caller_after = pre.deploy_contract(code=code_after, balance=1) + + def gas_used(code: Bytecode, regime: Fork) -> int: + return _cumulative_gas_used(code, regime) - regime.call_value_stipend() + + blocks = transition_blocks( + caller_before, + caller_after, + pre, + before_gas_used=gas_used(code_before, before), + after_gas_used=gas_used(code_after, after), ) - blocks = transition_blocks(caller_before, caller_after, pre, value=1) - post = { - caller_before: Account(storage=storage_before), + caller_before: Account(balance=0), callee_before: Account(balance=1), - caller_after: Account(storage=storage_after), + caller_after: Account(balance=0), callee_after: Account(balance=1), } blockchain_test(pre=pre, blocks=blocks, post=post) @@ -263,41 +407,75 @@ def test_create_base_cost_at_transition( """ The ``CREATE`` execution base cost changes across the boundary (``OPCODE_CREATE_BASE`` is redefined as - ``ACCOUNT_WRITE + COLD_ACCOUNT_ACCESS``). The constant transition is - asserted from the derived schedules and a ``CREATE`` is exercised in - both blocks to prove it still deploys. + ``ACCOUNT_WRITE + COLD_ACCOUNT_ACCESS``) and the account-creation + charge moves onto the state axis, where the pre-fork schedule has no + axis at all. Each block measures one ``CREATE`` and asserts its + regime's execution cost exactly. + + The init code is zero-length, so no memory is touched, no per-init-word + charge applies and empty code is deposited: the measured value is the + opcode's own execution cost with no child-frame gas folded in. """ - init_code = Op.STOP - init_word = int.from_bytes(bytes(init_code), "big") << ( - 256 - 8 * len(init_code) - ) + before = fork.fork_at(timestamp=BEFORE_TS) + after = fork.fork_at(timestamp=AFTER_TS) - storage_before = Storage() - factory_before = pre.deploy_contract( - code=( - Op.MSTORE(0, init_word) - + Op.SSTORE( - storage_before.store_next(True), - Op.GT(Op.CREATE(0, 0, len(init_code)), 0), - ) + create = Op.CREATE.with_metadata(init_code_size=0, account_new=True) + execution_before = create.execution_cost(before) + execution_after = create.execution_cost(after) + # The creation charge is execution gas before the fork and state gas + # after it, so what the measurement sees falls even though the + # operation's total cost rises. + assert execution_after < execution_before + assert create.state_cost(before) == 0 + assert create.state_cost(after) > 0 + assert create.gas_cost(after) > create.gas_cost(before) + + def factory(regime: Fork) -> Address: + """Deploy a factory that stores its ``CREATE``'s own gas.""" + return pre.deploy_contract( + code=CodeGasMeasure( + code=Op.CREATE(value=0, offset=0, size=0), + # Strip the three argument pushes; the CREATE leaves its + # result on the stack. + overhead_cost=3 * Op.PUSH1(0).execution_cost(regime), + extra_stack_items=1, + ), + ) + + factory_before = factory(before) + factory_after = factory(after) + + blocks = [ + Block( + timestamp=BEFORE_TS, + txs=[Transaction(to=factory_before, sender=pre.fund_eoa())], ), - ) - storage_after = Storage() - factory_after = pre.deploy_contract( - code=( - Op.MSTORE(0, init_word) - + Op.SSTORE( - storage_after.store_next(True), - Op.GT(Op.CREATE(0, 0, len(init_code)), 0), - ) + Block( + timestamp=AFTER_TS, + txs=[ + Transaction( + to=factory_after, + sender=pre.fund_eoa(), + # The reservoir funds the post-fork account-creation + # state gas so it does not spill into the gas the + # measurement reads. The pre-fork block has no state + # gas dimension, so it takes no reservoir. + state_gas_reservoir=create.state_cost(after), + ), + ], ), - ) - - blocks = transition_blocks(factory_before, factory_after, pre) + ] post = { - factory_before: Account(storage=storage_before), - factory_after: Account(storage=storage_after), + factory_before: Account(storage={0: execution_before}), + factory_after: Account(storage={0: execution_after}), + # Each factory's first CREATE deploys empty code at nonce 1. + compute_create_address(address=factory_before, nonce=1): Account( + nonce=1, code=b"", balance=0 + ), + compute_create_address(address=factory_after, nonce=1): Account( + nonce=1, code=b"", balance=0 + ), } blockchain_test(pre=pre, blocks=blocks, post=post) @@ -311,28 +489,54 @@ def test_selfdestruct_account_write_at_transition( ) -> None: """ ``SELFDESTRUCT`` gains an ``ACCOUNT_WRITE`` charge when it sends a - positive balance to an empty account, which is a new EIP-8038 - parameter. The constant transition is - asserted from the derived schedules and a value-bearing - ``SELFDESTRUCT`` to a fresh beneficiary is exercised in both blocks - to prove it still runs. + positive balance to an empty account, while the beneficiary-creation + charge it already paid moves from execution gas onto the state axis. + + ``SELFDESTRUCT`` halts its own frame, so the charge is pinned from + outside, through the receipt. The receipt sums the two gas dimensions + (unlike the block header, which takes their maximum and would let the + dominating creation state gas mask the execution side), so one pin + covers both the added ``ACCOUNT_WRITE`` and the relocated creation + charge. """ + before = fork.fork_at(timestamp=BEFORE_TS) + after = fork.fork_at(timestamp=AFTER_TS) + # Fresh empty beneficiaries so the positive-balance-to-empty branch # that adds ACCOUNT_WRITE is taken in each regime. beneficiary_before = pre.fund_eoa(amount=0) beneficiary_after = pre.fund_eoa(amount=0) - suicidal_before = pre.deploy_contract( - code=Op.SELFDESTRUCT(beneficiary_before), - balance=1, + selfdestruct = Op.SELFDESTRUCT.with_metadata( + address_warm=False, account_new=True ) - suicidal_after = pre.deploy_contract( - code=Op.SELFDESTRUCT(beneficiary_after), - balance=1, + code_before = selfdestruct(beneficiary_before) + code_after = selfdestruct(beneficiary_after) + + # Before the fork the creation charge is execution gas and there is no + # state axis; after it, the creation charge is state gas while the + # execution charge gains ACCOUNT_WRITE. + assert code_before.state_cost(before) == 0 + assert code_after.state_cost(after) > 0 + assert code_after.execution_cost(after) > Op.SELFDESTRUCT.with_metadata( + address_warm=False, account_new=False + ).execution_cost(after) + # EIP-6780 keeps the pre-existing originator alive, so no state-gas + # refund muddies either receipt. + assert code_before.refund(before) == 0 + assert code_after.refund(after) == 0 + + suicidal_before = pre.deploy_contract(code=code_before, balance=1) + suicidal_after = pre.deploy_contract(code=code_after, balance=1) + + blocks = transition_blocks( + suicidal_before, + suicidal_after, + pre, + before_gas_used=_cumulative_gas_used(code_before, before), + after_gas_used=_cumulative_gas_used(code_after, after), ) - blocks = transition_blocks(suicidal_before, suicidal_after, pre) - post = { beneficiary_before: Account(balance=1), beneficiary_after: Account(balance=1), @@ -355,14 +559,15 @@ def test_sstore_write_cost_at_transition( flat execution charge (``COLD_STORAGE_ACCESS + STORAGE_SET``) with no state-gas dimension. After the fork the charge splits: the execution portion drops to ``COLD_STORAGE_ACCESS + STORAGE_WRITE`` while the - bulk moves into the new state-gas dimension, and the clear refund - rises. Every magnitude is derived from the two schedules; nothing is - hardcoded. - - The transition is asserted at the derived-constant level (the - runtime opcode cost cannot isolate the execution portion without the - state-gas confounder) and a zero-to-nonzero ``SSTORE`` is exercised - in both blocks to prove it still sets the slot in each regime. + bulk moves into the new state-gas dimension. Every magnitude is + derived from the two schedules; nothing is hardcoded. + + The split itself is asserted at the derived-constant level, since the + runtime opcode cost cannot isolate the execution portion from the + state-gas one. The receipt then pins each regime's total, which sums + both dimensions and so holds the relocated charge to its exact size. + The clear refund is exercised on-chain by + ``test_storage_clear_refund_at_transition``. """ before = fork.fork_at(timestamp=BEFORE_TS) after = fork.fork_at(timestamp=AFTER_TS) @@ -370,41 +575,30 @@ def test_sstore_write_cost_at_transition( # First-change (zero -> nonzero, cold) SSTORE in each regime. sstore = Op.SSTORE(new_value=1) - execution_before = sstore.execution_cost(before) - execution_after = sstore.execution_cost(after) - state_before = sstore.state_cost(before) - state_after = sstore.state_cost(after) - total_before = sstore.gas_cost(before) - total_after = sstore.gas_cost(after) - # The repricing changes the execution charge, introduces the state # dimension, and therefore moves the total. - assert execution_after != execution_before - assert state_before == 0 - assert state_after > 0 - assert total_after != total_before - - # The storage-clear refund also rises across the boundary. - clear_sstore = Op.SSTORE.with_metadata( - original_value=1, current_value=1, new_value=0 - ) - refund_before = clear_sstore.refund(before) - refund_after = clear_sstore.refund(after) - assert refund_after > refund_before + assert sstore.execution_cost(after) != sstore.execution_cost(before) + assert sstore.state_cost(before) == 0 + assert sstore.state_cost(after) > 0 + assert sstore.gas_cost(after) != sstore.gas_cost(before) # Exercise the zero-to-nonzero SSTORE in both regimes; the slot ends # set in each block. storage_before = Storage() - contract_before = pre.deploy_contract( - code=Op.SSTORE(storage_before.store_next(1), 1), - ) + code_before = Op.SSTORE(storage_before.store_next(1), 1) + contract_before = pre.deploy_contract(code=code_before) storage_after = Storage() - contract_after = pre.deploy_contract( - code=Op.SSTORE(storage_after.store_next(1), 1), + code_after = Op.SSTORE(storage_after.store_next(1), 1) + contract_after = pre.deploy_contract(code=code_after) + + blocks = transition_blocks( + contract_before, + contract_after, + pre, + before_gas_used=_cumulative_gas_used(code_before, before), + after_gas_used=_cumulative_gas_used(code_after, after), ) - blocks = transition_blocks(contract_before, contract_after, pre) - post = { contract_before: Account(storage=storage_before), contract_after: Account(storage=storage_after), @@ -412,6 +606,210 @@ def test_sstore_write_cost_at_transition( blockchain_test(pre=pre, blocks=blocks, post=post) +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation() +@EIPChecklist.GasCostChanges.Test.ForkTransition.Before() +@EIPChecklist.GasCostChanges.Test.ForkTransition.After() +def test_storage_clear_refund_at_transition( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + ``REFUND_STORAGE_CLEAR`` rises across the boundary, and a slot is + actually cleared on each side so the granted refund is observed + rather than derived. + + Each block clears a slot whose original value is non-zero — so no + state-creation refund participates and the whole adjustment belongs + to EIP-8038 — and burns enough unrelated gas that the EIP-3529 + quotient cap does not bind. The regime's full grant is therefore + visible in ``cumulative_gas_used``. + """ + before = fork.fork_at(timestamp=BEFORE_TS) + after = fork.fork_at(timestamp=AFTER_TS) + + clear = Op.SSTORE.with_metadata( + key_warm=False, + original_value=1, + current_value=1, + new_value=0, + )(0, 0) + assert clear.refund(after) > clear.refund(before) + + def clear_code(regime: Fork) -> Bytecode: + return clear + GasConsumer(gas=clear.refund(regime) * 5, fork=regime) + + def gas_used(code: Bytecode, regime: Fork) -> int: + expected = _cumulative_gas_used(code, regime) + intrinsic = regime.transaction_intrinsic_cost_calculator()( + return_cost_deducted_prior_execution=True + ) + gross = intrinsic + code.execution_cost(regime) + # The burn keeps the cap clear of the grant in both regimes, so + # the observed gas carries the whole refund. + assert gross // regime.max_refund_quotient() > code.refund(regime) + assert expected == gross - code.refund(regime) + return expected + + code_before = clear_code(before) + code_after = clear_code(after) + + contract_before = pre.deploy_contract(code=code_before, storage={0: 1}) + contract_after = pre.deploy_contract(code=code_after, storage={0: 1}) + + blocks = transition_blocks( + contract_before, + contract_after, + pre, + before_gas_used=gas_used(code_before, before), + after_gas_used=gas_used(code_after, after), + ) + + post = { + contract_before: Account(storage={0: 0}), + contract_after: Account(storage={0: 0}), + } + blockchain_test(pre=pre, blocks=blocks, post=post) + + +@EIPChecklist.GasCostChanges.Test.ForkTransition.Before() +@EIPChecklist.GasCostChanges.Test.ForkTransition.After() +def test_access_list_intrinsic_at_transition( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + The per-entry access-list intrinsic surcharge rises across the + boundary, and each regime's total intrinsic is pinned exactly. + + Each block sends the same access-list transaction with ``gas_limit`` + set to that regime's minimum valid value, to a recipient with no + code. Nothing is left to execute, so the transaction consumes + precisely its intrinsic and the receipt pins it: a client pricing + access-list entries at the other regime's constants either rejects + the transaction or consumes a different amount. + + The rise combines EIP-8038's access-cost increase with EIP-7981's + per-entry data surcharge, charged in execution gas. The exact receipt + pin checks the resulting access-list cost. + """ + before = fork.fork_at(timestamp=BEFORE_TS) + after = fork.fork_at(timestamp=AFTER_TS) + access_list = _access_list(pre) + + def surcharge(regime: Fork) -> int: + calculator = regime.transaction_intrinsic_cost_calculator() + return calculator( + access_list=access_list, + return_cost_deducted_prior_execution=True, + ) - calculator(return_cost_deducted_prior_execution=True) + + # The access list itself got more expensive, independently of the + # transaction base cost moving the other way under EIP-2780. + assert surcharge(after) > surcharge(before) + + recipient = pre.fund_eoa(amount=0) + + def block(timestamp: int, regime: Fork) -> Block: + gas_limit = _minimum_gas_limit(regime, access_list) + return Block( + timestamp=timestamp, + txs=[ + Transaction( + to=recipient, + access_list=access_list, + gas_limit=gas_limit, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=gas_limit + ), + ), + ], + ) + + blocks = [block(BEFORE_TS, before), block(AFTER_TS, after)] + blockchain_test(pre=pre, blocks=blocks, post={}) + + +@EIPChecklist.GasCostChanges.Test.ForkTransition.Before() +@EIPChecklist.GasCostChanges.Test.ForkTransition.After() +@EIPChecklist.ModifiedTransactionValidityConstraint.Test.ForkTransition.AcceptedBeforeFork() +@EIPChecklist.ModifiedTransactionValidityConstraint.Test.ForkTransition.RejectedBeforeFork() +@EIPChecklist.ModifiedTransactionValidityConstraint.Test.ForkTransition.AcceptedAfterFork() +@EIPChecklist.ModifiedTransactionValidityConstraint.Test.ForkTransition.RejectedAfterFork() +@pytest.mark.exception_test +def test_access_list_intrinsic_straddles_validity( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + One ``gas_limit`` straddles validity at the boundary: it covers the + pre-fork intrinsic of an access-list transaction and falls short of + the post-fork one. + + For this access list the raised per-entry surcharge outweighs the + EIP-2780 fall in the base cost, so the same transaction that is + valid before the fork is rejected with ``INTRINSIC_GAS_TOO_LOW`` + after it. Off-by-one limits on either side pin the constraint in + each regime, so all four accept/reject arms are exercised. EIP-7981's + per-entry data surcharge, charged in execution gas, also contributes + to the rise; the off-by-one limits pin the resulting access-list cost. + """ + before = fork.fork_at(timestamp=BEFORE_TS) + after = fork.fork_at(timestamp=AFTER_TS) + access_list = _access_list(pre) + + intrinsic_before = _minimum_gas_limit(before, access_list) + intrinsic_after = _minimum_gas_limit(after, access_list) + # The straddle only exists because this access list's surcharge rises + # by more than the EIP-2780 base cost falls. + assert intrinsic_after > intrinsic_before + + recipient = pre.fund_eoa(amount=0) + + def make_tx( + gas_limit: int, error: TransactionException | None = None + ) -> Transaction: + return Transaction( + to=recipient, + access_list=access_list, + gas_limit=gas_limit, + sender=pre.fund_eoa(), + error=error, + # Accepted arms are handed exactly their regime's intrinsic + # and the recipient runs no code, so the whole limit is + # consumed and the receipt pins it. + expected_receipt=None + if error + else TransactionReceipt(cumulative_gas_used=gas_limit), + ) + + too_low = TransactionException.INTRINSIC_GAS_TOO_LOW + blocks = [ + # Rejected before the fork: one gas below the pre-fork intrinsic. + Block( + timestamp=BEFORE_TS, + txs=[make_tx(intrinsic_before - 1, error=too_low)], + exception=too_low, + ), + # Accepted before the fork: the exact pre-fork intrinsic. + Block(timestamp=BEFORE_TS, txs=[make_tx(intrinsic_before)]), + # Rejected after the fork: the very limit the previous block + # accepted — the straddle itself. + Block( + timestamp=AFTER_TS, + txs=[make_tx(intrinsic_before, error=too_low)], + exception=too_low, + ), + # Accepted after the fork: the exact post-fork intrinsic. + Block(timestamp=AFTER_TS, txs=[make_tx(intrinsic_after)]), + ] + + blockchain_test(pre=pre, blocks=blocks, post={}) + + @EIPChecklist.GasCostChanges.Test.ForkTransition.Before() @EIPChecklist.GasCostChanges.Test.ForkTransition.After() @pytest.mark.exception_test diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_selfdestruct_gas.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_selfdestruct_gas.py index 16eaf7dea65..bb682ee207f 100644 --- a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_selfdestruct_gas.py +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_selfdestruct_gas.py @@ -1,5 +1,5 @@ """ -Tests for the EIP-8038 [State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038) +Tests for the EIP-8038 [State-access gas cost update](https://eips.ethereum.org/EIPS/eip-8038) ``SELFDESTRUCT`` execution-gas dimension. Under EIP-8038 ``SELFDESTRUCT`` is charged, in its *execution* gas @@ -132,6 +132,135 @@ def test_selfdestruct_new_beneficiary_execution_gas( ) +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) +def test_selfdestruct_account_write_receipt_pin( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + warm: bool, +) -> None: + """ + Pin the whole charge of a value-bearing sweep to an empty + beneficiary. + + The destructor is the transaction's top frame, so the receipt — which + sums both gas dimensions, unlike the block header, where the + dominating creation state gas masks the execution side — pins the + execution charge (``OPCODE_SELFDESTRUCT_BASE``, the cold surcharge + when cold, and ``ACCOUNT_WRITE``) together with the beneficiary's + ``GAS_NEW_ACCOUNT``. + """ + beneficiary = pre.fund_eoa(amount=0) # empty: takes the net-new branch + + destructor_code = _destructor_code( + beneficiary, warm=warm, account_new=True + ) + destructor = pre.deploy_contract(code=destructor_code, balance=1) + + access_list = ( + [AccessList(address=beneficiary, storage_keys=[])] if warm else None + ) + intrinsic = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list + ) + # EIP-6780 keeps the pre-deployed destructor alive, so no state-gas + # refund muddies the receipt: it is the plain sum of both dimensions. + assert destructor_code.refund(fork) == 0 + state_gas = destructor_code.state_cost(fork) + expected_gas_used = ( + intrinsic + destructor_code.execution_cost(fork) + state_gas + ) + + tx = Transaction( + to=destructor, + sender=pre.fund_eoa(), + access_list=access_list, + state_gas_reservoir=state_gas, + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used + ), + ) + + state_test( + pre=pre, + post={ + destructor: Account(balance=0, code=destructor_code), + beneficiary: Account(balance=1), + }, + tx=tx, + ) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_two_selfdestructs_to_same_beneficiary( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Two sweeps into one beneficiary in a single transaction charge + ``ACCOUNT_WRITE`` once. + + The first sweep creates the beneficiary; by the second it is alive and + warm, so only ``OPCODE_SELFDESTRUCT_BASE`` is charged and no second + ``GAS_NEW_ACCOUNT`` arises. Nothing tracks writes per account — the + second sweep is cheap purely because the leaf now exists. + """ + beneficiary = pre.fund_eoa(amount=0) + + first_code = _destructor_code(beneficiary, warm=False, account_new=True) + first = pre.deploy_contract(code=first_code, balance=1) + # By the second sweep the beneficiary holds the first sweep's balance + # (alive) and has already been accessed (warm). + second_code = _destructor_code(beneficiary, warm=True, account_new=False) + second = pre.deploy_contract(code=second_code, balance=1) + assert second_code.state_cost(fork) == 0 + + # Both destructors are cold at the caller's CALLs and neither call + # carries value, so the default call metadata prices them correctly. + caller_code = ( + Op.POP(Op.CALL(gas=Op.GAS, address=first)) + + Op.POP(Op.CALL(gas=Op.GAS, address=second)) + + Op.STOP + ) + caller = pre.deploy_contract(code=caller_code) + + intrinsic = fork.transaction_intrinsic_cost_calculator()() + + # SELFDESTRUCT halts its frame successfully, so each child returns its + # unused grant and consumes exactly its own execution cost. + state_gas = first_code.state_cost(fork) + expected_gas_used = ( + intrinsic + + caller_code.execution_cost(fork) + + first_code.execution_cost(fork) + + second_code.execution_cost(fork) + + state_gas + ) + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + state_gas_reservoir=state_gas, + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used + ), + ) + + state_test( + pre=pre, + # EIP-6780: neither pre-deployed destructor is deleted, but both + # balances transfer into the single beneficiary. + post={ + first: Account(balance=0, code=first_code), + second: Account(balance=0, code=second_code), + beneficiary: Account(balance=2), + }, + tx=tx, + ) + + @EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() @pytest.mark.parametrize("warm", [False, True], ids=["cold", "warm"]) def test_selfdestruct_alive_beneficiary_no_account_write( @@ -404,6 +533,61 @@ def test_selfdestruct_self_or_precompile_beneficiary( ) +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +def test_selfdestruct_value_to_precompile_beneficiary( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + A value-bearing sweep to a precompile pays ``ACCOUNT_WRITE`` and + materialises the leaf. + + A precompile is in the accessed set from the start of the + transaction, so the access is free — but it holds no state entry and + is therefore dead under EIP-161. A positive balance sent to one takes + the net-new branch: ``ACCOUNT_WRITE`` on the execution axis and + ``GAS_NEW_ACCOUNT`` on the state axis, leaving a real account at the + precompile address carrying the swept balance. The zero-value arm of + ``test_selfdestruct_self_or_precompile_beneficiary`` documents + avoiding exactly this path. + """ + identity_precompile = Address(4) + + # Warm (pre-warmed precompile) yet account-creating, the one + # combination no other case in this module reaches. + destructor_code = _destructor_code( + identity_precompile, warm=True, account_new=True + ) + destructor = pre.deploy_contract(code=destructor_code, balance=1) + + intrinsic = fork.transaction_intrinsic_cost_calculator()() + assert destructor_code.refund(fork) == 0 + state_gas = destructor_code.state_cost(fork) + expected_gas_used = ( + intrinsic + destructor_code.execution_cost(fork) + state_gas + ) + + tx = Transaction( + to=destructor, + sender=pre.fund_eoa(), + state_gas_reservoir=state_gas, + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used + ), + ) + + state_test( + pre=pre, + post={ + destructor: Account(balance=0, code=destructor_code), + # The sweep creates a real account at the precompile address. + identity_precompile: Account(balance=1), + }, + tx=tx, + ) + + @EIPChecklist.GasCostChanges.Test.OutOfGas() @pytest.mark.parametrize( "sufficient_gas", [True, False], ids=["sufficient", "insufficient"] diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_set_code_auth_gas.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_set_code_auth_gas.py index 37dc515322f..caf24923bab 100644 --- a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_set_code_auth_gas.py +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_set_code_auth_gas.py @@ -1,6 +1,6 @@ """ Tests for the EIP-7702 authorization *execution*-gas repricing under -[EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). +[EIP-8038: State-access gas cost update](https://eips.ethereum.org/EIPS/eip-8038). Under EIP-2780 each EIP-7702 authorization is charged in two parts: a state-independent *execution* base cost paid in the intrinsic, and @@ -633,3 +633,72 @@ def test_many_auths_block_limit( for signer in signers } state_test(env=env, pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@pytest.mark.parametrize( + "delegate_warm", [False, True], ids=["cold_delegate", "warm_delegate"] +) +def test_tx_to_delegated_authority_resolution_gas( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + delegate_warm: bool, +) -> None: + """ + A transaction sent straight to a delegated authority pays the + repriced delegation-resolution access, cold or warm. + + Resolving a delegation designation charges one account access for + the delegation's own leaf, and EIP-8038 reprices it. The top frame + resolves its target's code through a different path from the one the + call opcodes use -- ``resolve_delegated_code_address`` rather than + each opcode's inline delegation surcharge -- so the warm and cold + arms are pinned here as well as in + ``test_call_gas.py::test_call_to_delegated_target_double_access``. + + The transaction target is warm from the start, so the only account + access the top frame pays is the delegation's. The delegated code is + a bare ``STOP``, costing nothing, which leaves the receipt equal to + the intrinsic plus that single access. Warming the delegation leaf + via the access list moves the charge from ``COLD_ACCOUNT_ACCESS`` to + ``WARM_ACCESS``; the access list's own intrinsic cost is taken from + the fork's calculator, so the two arms differ by the repricing + alone. + """ + delegate = pre.deploy_contract(Op.STOP) + authority = pre.fund_eoa(amount=0, delegation=delegate) + + access_list = ( + [AccessList(address=delegate, storage_keys=[])] + if delegate_warm + else [] + ) + + gas_costs = fork.gas_costs() + resolution_cost = ( + gas_costs.WARM_ACCESS + if delegate_warm + else gas_costs.COLD_ACCOUNT_ACCESS + ) + intrinsic = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list + ) + + tx = Transaction( + to=authority, + sender=pre.fund_eoa(), + access_list=access_list or None, + expected_receipt=TransactionReceipt( + cumulative_gas_used=intrinsic + resolution_cost + ), + ) + + # The delegation is only read, never rewritten: the authority keeps + # its designation and the nonce it was funded with. + post = { + authority: Account( + nonce=1, code=Spec7702.delegation_designation(delegate) + ), + } + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_set_code_auth_refunds.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_set_code_auth_refunds.py index 85512357fe6..5dee05d9bff 100644 --- a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_set_code_auth_refunds.py +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_set_code_auth_refunds.py @@ -1,6 +1,6 @@ """ Tests for the EIP-7702 authorization charge on an *existing* authority -leaf under [EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). +leaf under [EIP-8038: State-access gas cost update](https://eips.ethereum.org/EIPS/eip-8038). EIP-8038 originally over-charged every authorization as if it created a new account and *refunded* the difference (``ACCOUNT_WRITE`` on the diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sload_gas.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sload_gas.py index 62aad812e74..4b166d3991a 100644 --- a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sload_gas.py +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sload_gas.py @@ -1,5 +1,5 @@ """ -Tests for [EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). +Tests for [EIP-8038: State-access gas cost update](https://eips.ethereum.org/EIPS/eip-8038). Covers the EIP-8038 ``SLOAD`` repricing: a cold storage slot read costs ``COLD_STORAGE_ACCESS`` and a warm read costs ``WARM_SLOAD``. diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_gas.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_gas.py index a46e5305adc..a4d5ace56b5 100644 --- a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_gas.py +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_gas.py @@ -1,5 +1,5 @@ """ -Tests for [EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). +Tests for [EIP-8038: State-access gas cost update](https://eips.ethereum.org/EIPS/eip-8038). Covers the EIP-8038 ``SSTORE`` *execution* (non-state) gas schedule. The state-creation charge for a zero-to-nonzero write is owned by EIP-8037 @@ -17,11 +17,14 @@ AccessList, Account, Alloc, + BalAccountExpectation, + BlockAccessListExpectation, Bytecode, CodeGasMeasure, Fork, Op, StateTestFiller, + Storage, Transaction, ) from execution_testing.checklists import EIPChecklist @@ -40,17 +43,30 @@ # the slot state at the measured write. A clean slot (current == original) # is ``_cold`` or access-list ``_warm``; a dirty slot (current != original) # is ``_dirty`` and has necessarily been warmed by the prior in-frame SSTORE. +# No-op writes (new == current) have no row in the EIP's cases table; their +# access-only cost falls out of the three-component formula and is pinned +# here alongside the listed rows. SSTORE_ROWS = [ pytest.param(False, 0, 0, 1, id="00x_cold"), pytest.param(True, 0, 0, 1, id="00x_warm"), pytest.param(True, 0, 1, 0, id="0x0_dirty"), + pytest.param(True, 0, 1, 2, id="0xy_dirty"), + pytest.param(False, 1, 1, 0, id="xx0_cold"), pytest.param(True, 1, 1, 0, id="xx0_warm"), pytest.param(False, 1, 1, 2, id="xxy_cold"), pytest.param(True, 1, 1, 2, id="xxy_warm"), pytest.param(True, 1, 2, 3, id="xyz_dirty"), pytest.param(True, 1, 2, 1, id="xyx_dirty"), + pytest.param(True, 1, 2, 0, id="xy0_dirty"), + pytest.param(True, 1, 0, 1, id="x0x_dirty"), + pytest.param(True, 1, 0, 2, id="x0y_dirty"), + pytest.param(True, 1, 0, 0, id="x00_dirty"), pytest.param(True, 1, 1, 1, id="xxx_warm"), pytest.param(False, 1, 1, 1, id="xxx_cold"), + pytest.param(False, 0, 0, 0, id="000_cold"), + pytest.param(True, 0, 0, 0, id="000_warm"), + pytest.param(True, 0, 1, 1, id="0xx_dirty"), + pytest.param(True, 1, 2, 2, id="xyy_dirty"), ] @@ -143,6 +159,90 @@ def test_sstore_execution_gas( state_test(pre=pre, post=post, tx=tx) +@EIPChecklist.GasCostChanges.Test.OutOfGas() +@pytest.mark.parametrize("key_warm", [False, True], ids=["cold", "warm"]) +@pytest.mark.parametrize( + "sufficient_gas", [True, False], ids=["sufficient", "insufficient"] +) +def test_sstore_stipend_sentry_boundary( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + key_warm: bool, + sufficient_gas: bool, +) -> None: + """ + The EIP-2200 stipend sentry, not the slot's access cost, sets the + minimum gas an ``SSTORE`` needs. + + The measured write is a *no-op* (``new == current``), so it is + charged the access cost alone and nothing else, ``WARM_ACCESS`` or + ``COLD_STORAGE_ACCESS``, both under ``CALL_STIPEND``. The sentry + nevertheless demands + more than the stipend before any state is touched, so the boundary + sits at ``CALL_STIPEND + 1`` in both warmths rather than at the + cost actually charged. Pinning both warmths shows the floor does not + move with the access cost. + """ + slot = 0x42 + + # No-op write: original == current == new, so only the access cost is + # charged and no state gas or refund arises. + sstore_noop = Op.SSTORE.with_metadata( + key_warm=key_warm, original_value=1, current_value=1, new_value=1 + ) + child_code = sstore_noop(slot, 1) + child = pre.deploy_contract(code=child_code, storage={slot: 1}) + + # Everything the child spends before reaching the SSTORE itself. + operand_pushes = child_code.execution_cost( + fork + ) - sstore_noop.execution_cost(fork) + + # The sentry requires strictly more than the stipend to remain, so + # the child needs its pushes plus CALL_STIPEND + 1 — regardless of + # the access cost it will actually be charged. + forwarded = operand_pushes + fork.call_value_stipend() + if sufficient_gas: + forwarded += 1 + + storage = Storage() + caller_code = Op.SSTORE( + storage.store_next(1 if sufficient_gas else 0, "sstore_result"), + Op.CALL(gas=forwarded, address=child), + ) + caller = pre.deploy_contract(code=caller_code) + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + access_list=[AccessList(address=child, storage_keys=[slot])] + if key_warm + else None, + state_gas_reservoir=0, + ) + + # The no-op leaves the slot at its original value either way, so the + # CALL's success flag is what separates the two arms. + post = { + caller: Account(storage=storage), + child: Account(storage={slot: 1}), + } + state_test( + pre=pre, + post=post, + tx=tx, + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + child: BalAccountExpectation( + storage_reads=[slot] if sufficient_gas else [], + storage_changes=[], + ) + } + ), + ) + + @EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() def test_sstore_cold_then_warm_same_slot( state_test: StateTestFiller, diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_refunds.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_refunds.py index 8c60c8d5036..ee818e8961b 100644 --- a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_refunds.py +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_refunds.py @@ -1,15 +1,17 @@ """ -Tests for [EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). +Tests for [EIP-8038: State-access gas cost update](https://eips.ethereum.org/EIPS/eip-8038). -The headline mechanism of the pinned spec version ``a8862ae`` is the +The headline mechanism of the spec is the ``SSTORE`` clear-refund *reversal*: ``refund_counter`` is decremented by ``REFUND_STORAGE_CLEAR`` when a slot's original value is non-zero, its current value is zero and the new value is non-zero (a slot cleared -earlier in the same transaction is restored). The spec reverses the clear -refund "so that clearing and then restoring a slot within the same -transaction is never net-profitable", closing the ``x -> 0 -> x`` round -trip; this reversal is exercised by -``test_sstore_clear_then_reset_nets_zero``. +earlier in the same transaction is set to a non-zero value again). The +spec reverses the clear refund "so that clearing and then restoring a +slot within the same transaction is never net-profitable". The reversal +fires on any non-zero rewrite: ``test_sstore_clear_then_reset_nets_zero`` +covers the ``x -> 0 -> y`` path and +``test_sstore_clear_then_restore_original`` the ``x -> 0 -> x`` round +trip, where the reversal coincides with the ``STORAGE_WRITE`` refund. This module covers the EIP-8038 *execution* ``SSTORE`` refund schedule via the transaction receipt's ``cumulative_gas_used``: @@ -198,6 +200,69 @@ def test_sstore_clear_then_reset_nets_zero( ) +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation() +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation.Under() +def test_sstore_clear_then_rewrite_zero_grants_refund_once( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Rewriting zero over an already-cleared slot grants no second refund. + + The first ``SSTORE`` clears the slot and grants + ``REFUND_STORAGE_CLEAR``. The second writes zero again: the new value + equals the current value, so every refund rule is skipped and the + write charges only its access cost. An implementation that keyed the + clear grant off the original and new values alone, without comparing + the new value to the current one, would grant the refund twice. The + receipt pins exactly one grant. + + Enough unrelated gas is burned that the EIP-3529 quotient cap stays + clear of *two* grants; were the cap to bind at or below a single + grant, a double grant would be capped back to the same figure and + the test would not see it. + """ + clear = Op.SSTORE.with_metadata( + key_warm=False, + original_value=1, + current_value=1, + new_value=0, + )(0, 0) + rewrite_zero = Op.SSTORE.with_metadata( + key_warm=True, + original_value=1, + current_value=0, + new_value=0, + )(0, 0) + burn = GasConsumer(gas=clear.refund(fork) * 15, fork=fork) + code = clear + rewrite_zero + burn + + contract = pre.deploy_contract(code=code, storage={0: 1}) + + # The redundant zero write adds nothing to the refund counter. + assert code.refund(fork) == clear.refund(fork) + expected_cumulative = _cumulative_gas_used(code, fork) + + # Keep the cap clear of two grants so a spurious one would show. + intrinsic = fork.transaction_intrinsic_cost_calculator()( + return_cost_deducted_prior_execution=True + ) + gross = intrinsic + code.execution_cost(fork) + assert gross // 5 > clear.refund(fork) * 2 + + tx = Transaction( + to=contract, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative + ), + ) + + post = {contract: Account(storage={0: 0})} + state_test(pre=pre, post=post, tx=tx) + + @EIPChecklist.GasRefundsChanges.Test.RefundCalculation() @EIPChecklist.GasRefundsChanges.Test.RefundCalculation.Under() def test_sstore_restore_nonzero_refunds_write( @@ -252,7 +317,208 @@ def test_sstore_restore_nonzero_refunds_write( @EIPChecklist.GasRefundsChanges.Test.RefundCalculation() -@EIPChecklist.GasRefundsChanges.Test.RefundCalculation.Exact() +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation.Under() +def test_sstore_clear_then_restore_original( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Clearing then restoring a slot's original value nets one write refund. + + The ``x -> 0 -> x`` round trip: the clear grants + ``REFUND_STORAGE_CLEAR``; the restore both reverses that grant and + refunds ``STORAGE_WRITE`` (the only row where the two adjustments + coincide). The net refund equals a pure restore's write refund, so + the round trip pays access costs plus nothing net for the write. + """ + code = Op.SSTORE.with_metadata( + key_warm=False, + original_value=1, + current_value=1, + new_value=0, + )(0, 0) + Op.SSTORE.with_metadata( + key_warm=True, + original_value=1, + current_value=0, + new_value=1, + )(0, 1) + burn = GasConsumer(gas=code.refund(fork) * 5, fork=fork) + code += burn + + contract = pre.deploy_contract(code=code, storage={0: 1}) + + # The clear grant and its reversal cancel; only the STORAGE_WRITE + # refund of the restore survives, exactly as for a plain non-zero + # restore (the (x, y, x) row). + restore_refund = Op.SSTORE.with_metadata( + key_warm=True, + original_value=1, + current_value=2, + new_value=1, + ).refund(fork) + assert code.refund(fork) == restore_refund + expected_cumulative = _cumulative_gas_used(code, fork) + intrinsic = fork.transaction_intrinsic_cost_calculator()( + return_cost_deducted_prior_execution=True + ) + gross = intrinsic + code.execution_cost(fork) + assert gross // 5 > restore_refund + assert expected_cumulative == gross - restore_refund + + tx = Transaction( + to=contract, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative + ), + ) + + post = {contract: Account(storage={0: 1})} + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation() +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation.Under() +def test_sstore_write_charged_each_move_away( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + ``STORAGE_WRITE`` is charged by each write that moves the slot away + from its transaction-start value, not once per transaction. + + The ``x -> y -> x -> z`` sequence charges the write cost twice (the + first and third writes each find ``current == original``) with one + refund between them, so the net refund is exactly one write refund + while the gross gas carries both charges. + """ + code = ( + Op.SSTORE.with_metadata( + key_warm=False, + original_value=1, + current_value=1, + new_value=2, + )(0, 2) + + Op.SSTORE.with_metadata( + key_warm=True, + original_value=1, + current_value=2, + new_value=1, + )(0, 1) + + Op.SSTORE.with_metadata( + key_warm=True, + original_value=1, + current_value=1, + new_value=3, + )(0, 3) + ) + burn = GasConsumer(gas=code.refund(fork) * 5, fork=fork) + code += burn + + contract = pre.deploy_contract(code=code, storage={0: 1}) + + # One restore refund survives; the second move-away is charged in + # full again with no further refund. + restore_refund = Op.SSTORE.with_metadata( + key_warm=True, + original_value=1, + current_value=2, + new_value=1, + ).refund(fork) + assert code.refund(fork) == restore_refund + expected_cumulative = _cumulative_gas_used(code, fork) + intrinsic = fork.transaction_intrinsic_cost_calculator()( + return_cost_deducted_prior_execution=True + ) + gross = intrinsic + code.execution_cost(fork) + assert gross // 5 > restore_refund + assert expected_cumulative == gross - restore_refund + + tx = Transaction( + to=contract, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative + ), + ) + + post = {contract: Account(storage={0: 3})} + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation() +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation.Under() +def test_sstore_clear_refund_granted_twice( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + The clear refund is granted on each clear of a non-zero slot, not + once per transaction. + + The ``x -> 0 -> y -> 0`` sequence grants ``REFUND_STORAGE_CLEAR``, + reverses it on the non-zero rewrite, then grants it again on the + second clear, netting exactly one grant. + """ + code = ( + Op.SSTORE.with_metadata( + key_warm=False, + original_value=1, + current_value=1, + new_value=0, + )(0, 0) + + Op.SSTORE.with_metadata( + key_warm=True, + original_value=1, + current_value=0, + new_value=2, + )(0, 2) + + Op.SSTORE.with_metadata( + key_warm=True, + original_value=1, + current_value=2, + new_value=0, + )(0, 0) + ) + burn = GasConsumer(gas=code.refund(fork) * 5, fork=fork) + code += burn + + contract = pre.deploy_contract(code=code, storage={0: 1}) + + # Grant, reversal, grant: the net refund is one clear grant, the + # same as a single clear of a non-zero-original slot. + single_clear_refund = Op.SSTORE.with_metadata( + key_warm=True, + original_value=1, + current_value=1, + new_value=0, + ).refund(fork) + assert code.refund(fork) == single_clear_refund + expected_cumulative = _cumulative_gas_used(code, fork) + intrinsic = fork.transaction_intrinsic_cost_calculator()( + return_cost_deducted_prior_execution=True + ) + gross = intrinsic + code.execution_cost(fork) + assert gross // 5 > single_clear_refund + assert expected_cumulative == gross - single_clear_refund + + tx = Transaction( + to=contract, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative + ), + ) + + post = {contract: Account(storage={0: 0})} + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation() +@EIPChecklist.GasRefundsChanges.Test.RefundCalculation.Over() @pytest.mark.parametrize("num_clears", [1, 8, 32]) def test_sstore_refund_quotient_cap( state_test: StateTestFiller, diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_refunds_revert.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_refunds_revert.py new file mode 100644 index 00000000000..22ff2aa98ed --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_refunds_revert.py @@ -0,0 +1,291 @@ +""" +Tests for [EIP-8038: State-access gas cost update](https://eips.ethereum.org/EIPS/eip-8038). + +Covers ``SSTORE`` refund and charge accounting across frames that fail. +A frame's refund-counter adjustments are discarded when the frame +reverts or exceptionally halts, while the gas it consumed stays +consumed; and a rollback restores ``current == original``, so a later +write to the same slot is priced as a first change again. These +semantics are inherited from EIP-2200/EIP-3529 but become far more +consequential under EIP-8038's repriced ``STORAGE_WRITE`` and +``REFUND_STORAGE_CLEAR``. + +All failing frames run under ``DELEGATECALL`` so every touched slot +belongs to the outer contract and rollback is observable in its +post-state. All slots have non-zero originals so the EIP-8037 +state-creation dimension never participates; every expectation is an +exact receipt ``cumulative_gas_used`` pin. +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Bytecode, + Fork, + Op, + StateTestFiller, + Transaction, + TransactionReceipt, +) +from execution_testing import Macros as Om +from execution_testing.checklists import EIPChecklist + +from .spec import ref_spec_8038 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8038.git_path +REFERENCE_SPEC_VERSION = ref_spec_8038.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + +# Alias for the deep checklist path so decorator lines stay within +# the line limit; the marks are applied at runtime, so the checklist +# scanner resolves them identically. +_REVERTABLE = EIPChecklist.GasRefundsChanges.Test.ExceptionalAbort.Revertable + +DATA_SLOT = 0x42 + +# Gas forwarded to a child frame that exceptionally halts: the child +# consumes exactly this grant, making the receipt exact by construction. +# Sized to comfortably cover a cold first-change SSTORE. +CHILD_GAS = 30_000 + +# Headroom on top of the expected consumption so the 63/64 withhold +# never truncates the requested child grant. +GAS_LIMIT_MARGIN = 50_000 + + +def _intrinsic(fork: Fork) -> int: + """Return the intrinsic gas deducted prior to execution.""" + return fork.transaction_intrinsic_cost_calculator()( + return_cost_deducted_prior_execution=True + ) + + +def _clear_refund(fork: Fork) -> int: + """Return the refund granted by clearing a non-zero-original slot.""" + return Op.SSTORE.with_metadata( + key_warm=False, + original_value=1, + current_value=1, + new_value=0, + ).refund(fork) + + +@_REVERTABLE() +@_REVERTABLE.Revert() +@_REVERTABLE.OutOfGas() +@_REVERTABLE.InvalidOpcode() +@pytest.mark.parametrize( + "failure,returns_unused_gas", + [ + pytest.param(Op.REVERT(0, 0), True, id="revert"), + pytest.param(Om.OOG, False, id="out_of_gas"), + pytest.param(Op.INVALID, False, id="invalid_opcode"), + ], +) +def test_sstore_clear_refund_discarded_on_frame_failure( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + failure: Bytecode, + returns_unused_gas: bool, +) -> None: + """ + A clear refund accrued in a failing frame is discarded. + + The child clears a non-zero-original slot (accruing + ``REFUND_STORAGE_CLEAR``) and then fails; the receipt shows the full + gross gas with no refund applied, and the slot rolls back. + """ + child_code = Op.SSTORE(DATA_SLOT, 0) + failure + child = pre.deploy_contract(code=child_code) + + caller_code = Op.POP(Op.DELEGATECALL(gas=CHILD_GAS, address=child)) + caller = pre.deploy_contract(code=caller_code, storage={DATA_SLOT: 1}) + + # A REVERT returns the child's unused gas, so the child consumes + # only what it executed; an exceptional halt consumes the whole + # grant. + child_consumed = ( + child_code.execution_cost(fork) if returns_unused_gas else CHILD_GAS + ) + + # The discarded refund must be non-trivial for the pin to have + # teeth: kept erroneously, it would lower the receipt. + assert _clear_refund(fork) > 0 + expected_cumulative = ( + _intrinsic(fork) + caller_code.execution_cost(fork) + child_consumed + ) + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + gas_limit=expected_cumulative + GAS_LIMIT_MARGIN, + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative + ), + ) + + # The clear rolls back with the failing frame. + post = {caller: Account(storage={DATA_SLOT: 1})} + state_test(pre=pre, post=post, tx=tx) + + +@_REVERTABLE() +@_REVERTABLE.UpperRevert() +def test_sstore_clear_refund_discarded_on_upper_frame_revert( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + A refund merged from a successful child dies with a reverting parent. + + The inner frame clears the slot and returns successfully, merging + its clear refund into the middle frame; the middle frame then + reverts, discarding the merged refund along with the state change. + """ + inner_code = Op.SSTORE(DATA_SLOT, 0) + Op.STOP + inner = pre.deploy_contract(code=inner_code) + + middle_code = Op.POP( + Op.DELEGATECALL(gas=Op.GAS, address=inner) + ) + Op.REVERT(0, 0) + middle = pre.deploy_contract(code=middle_code) + + outer_code = Op.POP(Op.DELEGATECALL(gas=Op.GAS, address=middle)) + outer = pre.deploy_contract(code=outer_code, storage={DATA_SLOT: 1}) + + # Both inner (STOP) and middle (REVERT) return their unused gas, so + # each frame consumes exactly its executed cost. + assert _clear_refund(fork) > 0 + expected_cumulative = ( + _intrinsic(fork) + + outer_code.execution_cost(fork) + + middle_code.execution_cost(fork) + + inner_code.execution_cost(fork) + ) + + tx = Transaction( + to=outer, + sender=pre.fund_eoa(), + gas_limit=expected_cumulative + GAS_LIMIT_MARGIN, + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative + ), + ) + + post = {outer: Account(storage={DATA_SLOT: 1})} + state_test(pre=pre, post=post, tx=tx) + + +@_REVERTABLE() +@_REVERTABLE.Revert() +def test_sstore_restore_refund_discarded_on_revert( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + The write-restore refund is discarded with its reverting frame. + + The child changes the slot and restores its original value + (accruing the ``STORAGE_WRITE`` refund of refund rule 3), then + reverts. The slot ends the transaction at its original value, yet + the write cost burned in the child stays consumed and the refund is + gone — the receipt shows the full gross gas. + """ + # Metadata on both writes: the first is a cold first change, the + # second a warm restore, so the child's modeled execution cost and + # net refund match what actually runs. + child_code = ( + Op.SSTORE.with_metadata( + key_warm=False, + original_value=1, + current_value=1, + new_value=2, + )(DATA_SLOT, 2) + + Op.SSTORE.with_metadata( + key_warm=True, + original_value=1, + current_value=2, + new_value=1, + )(DATA_SLOT, 1) + + Op.REVERT(0, 0) + ) + child = pre.deploy_contract(code=child_code) + + caller_code = Op.POP(Op.DELEGATECALL(gas=Op.GAS, address=child)) + caller = pre.deploy_contract(code=caller_code, storage={DATA_SLOT: 1}) + + # The child's own accounting nets a STORAGE_WRITE refund; it must + # be non-trivial, and none of it may survive the revert. + assert child_code.refund(fork) > 0 + + expected_cumulative = ( + _intrinsic(fork) + + caller_code.execution_cost(fork) + + child_code.execution_cost(fork) + ) + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + gas_limit=expected_cumulative + GAS_LIMIT_MARGIN, + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative + ), + ) + + post = {caller: Account(storage={DATA_SLOT: 1})} + state_test(pre=pre, post=post, tx=tx) + + +@EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() +@_REVERTABLE.InvalidOpcode() +def test_sstore_write_recharged_after_frame_rollback( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + A rollback re-arms the first-change ``STORAGE_WRITE`` charge. + + The child pays cold access plus ``STORAGE_WRITE`` moving the slot + away from its original, then exceptionally halts: the write and the + slot's warmth roll back, but the gas stays consumed. The outer + frame's own write to the same slot then finds ``current == + original`` on a cold slot again and pays the full first-change + price a second time. + """ + child_code = Op.SSTORE(DATA_SLOT, 2) + Op.INVALID + child = pre.deploy_contract(code=child_code) + + # The outer write is priced as a cold first change even though the + # child already wrote (and warmed) the slot: both rolled back. + caller_code = Op.POP( + Op.DELEGATECALL(gas=CHILD_GAS, address=child) + ) + Op.SSTORE.with_metadata( + key_warm=False, + original_value=1, + current_value=1, + new_value=2, + )(DATA_SLOT, 2) + caller = pre.deploy_contract(code=caller_code, storage={DATA_SLOT: 1}) + + expected_cumulative = ( + _intrinsic(fork) + caller_code.execution_cost(fork) + CHILD_GAS + ) + + tx = Transaction( + to=caller, + sender=pre.fund_eoa(), + gas_limit=expected_cumulative + GAS_LIMIT_MARGIN, + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_cumulative + ), + ) + + post = {caller: Account(storage={DATA_SLOT: 2})} + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_transient_storage_regression.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_transient_storage_regression.py index afac3738334..119d479edae 100644 --- a/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_transient_storage_regression.py +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/test_transient_storage_regression.py @@ -1,5 +1,5 @@ """ -Tests for [EIP-8038: State Access Gas Cost Increase](https://eips.ethereum.org/EIPS/eip-8038). +Tests for [EIP-8038: State-access gas cost update](https://eips.ethereum.org/EIPS/eip-8038). Regression guard: EIP-8038 reprices persistent storage and account access but must NOT touch transient storage. ``TLOAD`` and ``TSTORE`` diff --git a/tests/ported_static/stCreate2/test_create2_oo_gafter_init_code.py b/tests/ported_static/stCreate2/test_create2_oo_gafter_init_code.py index 24a1fdd0c09..cf7c1c043f7 100644 --- a/tests/ported_static/stCreate2/test_create2_oo_gafter_init_code.py +++ b/tests/ported_static/stCreate2/test_create2_oo_gafter_init_code.py @@ -25,7 +25,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op from tests.ported_static.post_state_resolution import ( @@ -131,7 +131,7 @@ def test_create2_oo_gafter_init_code( # EIP-8037/8038 change the CREATE2 dispatch in two ways that the # budget must absorb before the init code RETURNs: the new # `create_state_gas()` spills into regular gas (empty reservoir), - # and `OPCODE_CREATE_BASE` drops from its Cancun value of 32000. + # and `OPCODE_CREATE_BASE` drops from its Cancun value. # Their sum is the net extra the dispatch consumes from the budget. # The deposit step then changes too: its regular `CODE_DEPOSIT_PER_BYTE # * 5` portion is now covered by the state-gas reservoir credited at @@ -142,7 +142,7 @@ def test_create2_oo_gafter_init_code( # already carried in their 1000-gas gap. Every term is 0 # pre-EIP-8037, so the original Cancun behavior is preserved. gas_costs = fork.gas_costs() - _cancun_create_base = 32000 + _cancun_create_base = Cancun.gas_costs().OPCODE_CREATE_BASE _deploy_size = 5 _oog_lift = 0 if fork.is_eip_enabled(8037): @@ -154,13 +154,12 @@ def test_create2_oo_gafter_init_code( - gas_costs.CODE_DEPOSIT_PER_BYTE * _deploy_size ) # EIP-2780 reshapes the tx intrinsic for non-self non-value txs: - # ``TX_BASE`` drops to 12_000 and an explicit - # ``COLD_ACCOUNT_ACCESS`` (3_000) recipient charge is added. The - # original test was built against Cancun's flat ``TX_BASE`` of - # 21_000, so shift the budget by the intrinsic delta to keep the - # straddle landing at the same RETURN point. + # ``TX_BASE`` drops and an explicit ``COLD_ACCOUNT_ACCESS`` + # recipient charge is added. The original test was built against + # Cancun's flat ``TX_BASE``, so shift the budget by the intrinsic + # delta to keep the straddle landing at the same RETURN point. intrinsic = fork.transaction_intrinsic_cost_calculator()() - _oog_lift += intrinsic - 21_000 + _oog_lift += intrinsic - Cancun.transaction_intrinsic_cost_calculator()() tx_gas = [54000 + _oog_lift, 55000 + _oog_lift] tx = Transaction( 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 10f8677ff4c..47c0a044d6e 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 @@ -12,11 +12,10 @@ @manually-enhanced: Do not overwrite. The post-state records the measured cost of accessing the create address after a failed CREATE, -which is a cold account access. EIP-8038 reprices a cold account -access from 2 600 to 3 000, so each such measurement gains 400 at -Amsterdam. Derive that delta from the fork's gas model so it is -exactly 0 pre-EIP-8037 and tracks parameter changes; do not hardcode -the Amsterdam value. +which is a cold account access. EIP-8038 raises `COLD_ACCOUNT_ACCESS`, +so each such measurement gains that delta at Amsterdam. Derive that +delta from the fork's gas model so it is exactly 0 pre-EIP-8037 and +tracks parameter changes; do not hardcode the Amsterdam value. """ from typing import NamedTuple diff --git a/tests/ported_static/stCreateTest/test_create_oo_gafter_init_code.py b/tests/ported_static/stCreateTest/test_create_oo_gafter_init_code.py index df76c260c3e..072647c04ec 100644 --- a/tests/ported_static/stCreateTest/test_create_oo_gafter_init_code.py +++ b/tests/ported_static/stCreateTest/test_create_oo_gafter_init_code.py @@ -25,7 +25,7 @@ Transaction, compute_create_address, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op from tests.ported_static.post_state_resolution import ( @@ -127,7 +127,7 @@ def test_create_oo_gafter_init_code( # EIP-8037/8038 change the CREATE dispatch in two ways that the # budget must absorb before the init code RETURNs: the new # `create_state_gas()` spills into regular gas (empty reservoir), - # and `OPCODE_CREATE_BASE` drops from its Cancun value of 32000. + # and `OPCODE_CREATE_BASE` drops from its Cancun value. # Their sum is the net extra the dispatch consumes from the budget. # The deposit step then changes too: its regular `CODE_DEPOSIT_PER_BYTE # * 5` portion is now covered by the state-gas reservoir credited at @@ -138,7 +138,7 @@ def test_create_oo_gafter_init_code( # already carried in their 1000-gas gap. Every term is 0 # pre-EIP-8037, so the original Cancun behavior is preserved. gas_costs = fork.gas_costs() - _cancun_create_base = 32000 + _cancun_create_base = Cancun.gas_costs().OPCODE_CREATE_BASE _deploy_size = 5 _oog_lift = 0 if fork.is_eip_enabled(8037): @@ -150,13 +150,12 @@ def test_create_oo_gafter_init_code( - gas_costs.CODE_DEPOSIT_PER_BYTE * _deploy_size ) # EIP-2780 reshapes the tx intrinsic for non-self non-value txs: - # ``TX_BASE`` drops to 12_000 and an explicit - # ``COLD_ACCOUNT_ACCESS`` (3_000) recipient charge is added. The - # original test was built against Cancun's flat ``TX_BASE`` of - # 21_000, so shift the budget by the intrinsic delta to keep the - # straddle landing at the same RETURN point. + # ``TX_BASE`` drops and an explicit ``COLD_ACCOUNT_ACCESS`` + # recipient charge is added. The original test was built against + # Cancun's flat ``TX_BASE``, so shift the budget by the intrinsic + # delta to keep the straddle landing at the same RETURN point. intrinsic = fork.transaction_intrinsic_cost_calculator()() - _oog_lift += intrinsic - 21_000 + _oog_lift += intrinsic - Cancun.transaction_intrinsic_cost_calculator()() tx_gas = [54000 + _oog_lift, 55000 + _oog_lift] tx = Transaction( diff --git a/tests/ported_static/stEIP1153_transientStorage/test_14_revert_after_nested_staticcall.py b/tests/ported_static/stEIP1153_transientStorage/test_14_revert_after_nested_staticcall.py index 739a986e972..f70d5b93b84 100644 --- a/tests/ported_static/stEIP1153_transientStorage/test_14_revert_after_nested_staticcall.py +++ b/tests/ported_static/stEIP1153_transientStorage/test_14_revert_after_nested_staticcall.py @@ -25,7 +25,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -51,15 +51,14 @@ def test_14_revert_after_nested_staticcall( # cold zero -> nonzero set) and clears slot 1's cold marker; sum the # per-slot increases so the original budget stays sufficient. Each # term is exactly 0 before EIP-8037. - def _sstore_delta(cancun_cost: int, **metadata: int) -> int: + def _sstore_delta(**metadata: int) -> int: op = Op.SSTORE.with_metadata(**metadata) - return op.gas_cost(fork) - cancun_cost + return op.gas_cost(fork) - op.gas_cost(Cancun) cold_set_delta = _sstore_delta( - 22100, key_warm=False, original_value=0, current_value=0, new_value=10 + key_warm=False, original_value=0, current_value=0, new_value=10 ) cold_clear_delta = _sstore_delta( - 5000, key_warm=False, original_value=65535, current_value=65535, diff --git a/tests/ported_static/stEIP150Specific/test_suicide_to_existing_contract.py b/tests/ported_static/stEIP150Specific/test_suicide_to_existing_contract.py index 15bacfb9c84..1c633d227f0 100644 --- a/tests/ported_static/stEIP150Specific/test_suicide_to_existing_contract.py +++ b/tests/ported_static/stEIP150Specific/test_suicide_to_existing_contract.py @@ -9,7 +9,7 @@ SELFDESTRUCTs back to its (warm, alive) caller. EIP-8038 reprices the cold account access of that CALL; the beneficiary is warm so the SELFDESTRUCT is unchanged. The delta is therefore the fork's -`COLD_ACCOUNT_ACCESS - 2600`, exactly 0 before EIP-8038. +`COLD_ACCOUNT_ACCESS` less Cancun's, exactly 0 before EIP-8038. """ import pytest @@ -22,7 +22,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -41,7 +41,10 @@ def test_suicide_to_existing_contract( ) -> None: """Test_suicide_to_existing_contract.""" # EIP-8038 cold account access reprice; 0 before EIP-8038. - cold_account_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 + cold_account_delta = ( + fork.gas_costs().COLD_ACCOUNT_ACCESS + - Cancun.gas_costs().COLD_ACCOUNT_ACCESS + ) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) diff --git a/tests/ported_static/stEIP150Specific/test_suicide_to_not_existing_contract.py b/tests/ported_static/stEIP150Specific/test_suicide_to_not_existing_contract.py index 6dfee984b84..bb0b2091776 100644 --- a/tests/ported_static/stEIP150Specific/test_suicide_to_not_existing_contract.py +++ b/tests/ported_static/stEIP150Specific/test_suicide_to_not_existing_contract.py @@ -10,7 +10,7 @@ EIP-8038 reprices both the CALL's cold account access and the SELFDESTRUCT beneficiary's cold access; no value is sent so there is no new-account write. The delta is therefore twice the fork's -`COLD_ACCOUNT_ACCESS - 2600`, exactly 0 before EIP-8038. +`COLD_ACCOUNT_ACCESS` less Cancun's, exactly 0 before EIP-8038. """ import pytest @@ -23,7 +23,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -44,7 +44,10 @@ def test_suicide_to_not_existing_contract( # EIP-8038 cold account access reprice; 0 before EIP-8038. Charged # twice: once for the CALL target, once for the cold SELFDESTRUCT # beneficiary. - cold_account_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 + cold_account_delta = ( + fork.gas_costs().COLD_ACCOUNT_ACCESS + - Cancun.gas_costs().COLD_ACCOUNT_ACCESS + ) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_eip2929.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_eip2929.py index a15a8101267..b05c534d505 100644 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_eip2929.py +++ b/tests/ported_static/stEIP150singleCodeGasPrices/test_eip2929.py @@ -35,7 +35,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op from tests.ported_static.post_state_resolution import ( @@ -859,8 +859,12 @@ def test_eip2929( # EIP-8038 access-repricing component deltas (each 0 pre-EIP-8037). gas_costs = fork.gas_costs() eip_active = fork.is_eip_enabled(8037) - cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 - cold_storage_delta = gas_costs.COLD_STORAGE_ACCESS - 2100 + cold_account_delta = ( + gas_costs.COLD_ACCOUNT_ACCESS - Cancun.gas_costs().COLD_ACCOUNT_ACCESS + ) + cold_storage_delta = ( + gas_costs.COLD_STORAGE_ACCESS - Cancun.gas_costs().COLD_STORAGE_ACCESS + ) # EIP-8038 charges an extra warm access for an EXTCODE* code read, # on every access (cold adds it on top of the cold account cost, # warm pays it as a second warm access). @@ -868,16 +872,17 @@ def test_eip2929( cold_code_read_delta = cold_account_delta + extra_code_read warm_code_read_delta = extra_code_read - def _sstore_delta(cancun_cost: int, **metadata: int) -> int: - return Op.SSTORE.with_metadata(**metadata).gas_cost(fork) - cancun_cost + def _sstore_delta(**metadata: int) -> int: + sstore = Op.SSTORE.with_metadata(**metadata) + return sstore.gas_cost(fork) - sstore.gas_cost(Cancun) # SSTORE 24743 -> 5 (existing nonzero slot changed to a new nonzero # value): cold first write vs warm subsequent write. cold_sstore_write_delta = _sstore_delta( - 5000, key_warm=False, original_value=1, current_value=1, new_value=2 + key_warm=False, original_value=1, current_value=1, new_value=2 ) warm_sstore_write_delta = _sstore_delta( - 2900, key_warm=True, original_value=1, current_value=1, new_value=2 + key_warm=True, original_value=1, current_value=1, new_value=2 ) # Operation opcodes (from the calldata oper words). diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_eip2929_minus_ff.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_eip2929_minus_ff.py index c27ec8bd484..f660ef28975 100644 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_eip2929_minus_ff.py +++ b/tests/ported_static/stEIP150singleCodeGasPrices/test_eip2929_minus_ff.py @@ -7,11 +7,10 @@ @manually-enhanced: Do not overwrite. The first expect-entry (the `simple`/NOP case) measures a `CALL` into a contract that `SELFDESTRUCT`s with an as-yet-untouched (cold) beneficiary. EIP-8038 -reprices the cold account access (`COLD_ACCOUNT_ACCESS`, 2600 -> 3000), -so that cost shifts by `COLD_ACCOUNT_ACCESS - 2600`, derived from the -fork's own constant so it is exactly 0 pre-EIP-8038. The other entry -pre-warms the beneficiary, so its cost is unchanged. Do not hardcode -the Amsterdam number. +raises `COLD_ACCOUNT_ACCESS`, so that cost shifts by the fork's +`COLD_ACCOUNT_ACCESS` less Cancun's, exactly 0 pre-EIP-8038. The other +entry pre-warms the beneficiary, so its cost is unchanged. Do not +hardcode the Amsterdam number. """ import pytest @@ -25,7 +24,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op from tests.ported_static.post_state_resolution import ( @@ -109,8 +108,11 @@ def test_eip2929_minus_ff( v: int, ) -> None: """Ori Pomerantz qbzzt1@gmail.""" - # EIP-8038 cold account repricing (2600 -> 3000); 0 on earlier forks. - cold_account_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 + # EIP-8038 raises COLD_ACCOUNT_ACCESS, 0 on earlier forks. + cold_account_delta = ( + fork.gas_costs().COLD_ACCOUNT_ACCESS + - Cancun.gas_costs().COLD_ACCOUNT_ACCESS + ) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) contract_0 = Address(0x000000000000000000000000000000000000DE57) contract_1 = Address(0x000000000000000000000000000000000000CA11) diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_memory.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_memory.py index 8141758957c..be526129c28 100644 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_memory.py +++ b/tests/ported_static/stEIP150singleCodeGasPrices/test_gas_cost_memory.py @@ -7,11 +7,10 @@ @manually-enhanced: Do not overwrite. The second expect-entry (data 36-48) stores the regular gas of a measured window that includes one extra cold `CALL` to a previously untouched contract relative to its -baseline. EIP-8038 reprices `COLD_ACCOUNT_ACCESS` (2600 -> 3000), so -that net cost shifts by `COLD_ACCOUNT_ACCESS - 2600`, derived from the -fork's own constant so it is exactly 0 pre-EIP-8038. The first entry -measures a difference of two equal-cost operations and is unchanged. -Do not hardcode the Amsterdam number. +baseline. EIP-8038 raises `COLD_ACCOUNT_ACCESS`, so that net cost +shifts by the fork's `COLD_ACCOUNT_ACCESS` less Cancun's, exactly 0 +pre-EIP-8038. The first entry measures a difference of two equal-cost +operations and is unchanged. Do not hardcode the Amsterdam number. """ import pytest @@ -25,7 +24,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op from tests.ported_static.post_state_resolution import ( @@ -505,8 +504,11 @@ def test_gas_cost_memory( v: int, ) -> None: """Ori Pomerantz qbzzt1@gmail.""" - # EIP-8038 cold account repricing (2600 -> 3000); 0 on earlier forks. - cold_account_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 + # EIP-8038 raises COLD_ACCOUNT_ACCESS, 0 on earlier forks. + cold_account_delta = ( + fork.gas_costs().COLD_ACCOUNT_ACCESS + - Cancun.gas_costs().COLD_ACCOUNT_ACCESS + ) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) contract_0 = Address(0x000000000000000000000000000000000000BA5E) contract_1 = Address(0x000000000000000000000000000000000010BA5E) diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_copy_gas.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_copy_gas.py index 4a23ef5d079..6f5b6daac1b 100644 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_copy_gas.py +++ b/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_copy_gas.py @@ -6,14 +6,11 @@ @manually-enhanced: Do not overwrite. This measures the regular gas that a single cold `EXTCODECOPY` consumes via `Op.GAS`. EIP-8038 -reprices the cold account access (`COLD_ACCOUNT_ACCESS`, 2600 -> 3000) -and charges an extra `WARM_ACCESS` for the opcode's second read (the -code). The stored cost therefore shifts by -`(COLD_ACCOUNT_ACCESS - 2600) + WARM_ACCESS`. The cold term comes from -the fork's own constant; the extra warm term is gated on the -`is_eip_enabled(8037)` flag (the registered flag that activates the -repricing at Amsterdam), so the delta is exactly 0 on earlier forks. -Do not hardcode it. +raises the cold account access (`COLD_ACCOUNT_ACCESS`) and charges an +extra `WARM_ACCESS` for the opcode's second read (the code). The +stored cost therefore shifts by the opcode's own cold cost on the fork +less its cost on Cancun, taken from `Op.EXTCODECOPY` metadata so the +delta is exactly 0 on earlier forks. Do not hardcode it. """ import pytest @@ -26,7 +23,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -44,14 +41,12 @@ def test_raw_ext_code_copy_gas( fork: Fork, ) -> None: """Test_raw_ext_code_copy_gas.""" - gas_costs = fork.gas_costs() - # EIP-8038: cold account repricing plus the extra warm access charged - # for the opcode's second read (the code). Both terms are 0 before - # EIP-8038, so the stored cost is unchanged on earlier forks. - cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 - code_read_delta = cold_account_delta + ( - gas_costs.WARM_ACCESS if fork.is_eip_enabled(8037) else 0 - ) + # EIP-8038 delta, 0 before EIP-8038: the cold account reprice plus a + # second WARM_ACCESS for the code read, both carried by the opcode's + # cost metadata. + cold_extcodecopy = Op.EXTCODECOPY.with_metadata(address_warm=False) + cancun_extcodecopy_cost = cold_extcodecopy.gas_cost(Cancun) + code_read_delta = cold_extcodecopy.gas_cost(fork) - cancun_extcodecopy_cost coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_copy_memory_gas.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_copy_memory_gas.py index d16eef9ae8f..3bd60efcf07 100644 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_copy_memory_gas.py +++ b/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_copy_memory_gas.py @@ -6,14 +6,12 @@ @manually-enhanced: Do not overwrite. This measures the regular gas that a single cold `EXTCODECOPY` (with memory expansion) consumes via -`Op.GAS`. EIP-8038 reprices the cold account access -(`COLD_ACCOUNT_ACCESS`, 2600 -> 3000) and charges an extra -`WARM_ACCESS` for the opcode's second read (the code). The stored cost -therefore shifts by `(COLD_ACCOUNT_ACCESS - 2600) + WARM_ACCESS`. The -cold term comes from the fork's own constant; the extra warm term is -gated on the `is_eip_enabled(8037)` flag (the registered flag that -activates the repricing at Amsterdam), so the delta is exactly 0 on -earlier forks. Do not hardcode it. +`Op.GAS`. EIP-8038 raises the cold account access +(`COLD_ACCOUNT_ACCESS`) and charges an extra `WARM_ACCESS` for the +opcode's second read (the code). The stored cost therefore shifts by +the opcode's own cold cost on the fork less its cost on Cancun, taken +from `Op.EXTCODECOPY` metadata so the delta is exactly 0 on earlier +forks. Do not hardcode it. """ import pytest @@ -26,7 +24,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -46,14 +44,12 @@ def test_raw_ext_code_copy_memory_gas( fork: Fork, ) -> None: """Test_raw_ext_code_copy_memory_gas.""" - gas_costs = fork.gas_costs() - # EIP-8038: cold account repricing plus the extra warm access charged - # for the opcode's second read (the code). Both terms are 0 before - # EIP-8038, so the stored cost is unchanged on earlier forks. - cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 - code_read_delta = cold_account_delta + ( - gas_costs.WARM_ACCESS if fork.is_eip_enabled(8037) else 0 - ) + # EIP-8038 delta, 0 before EIP-8038: the cold account reprice plus a + # second WARM_ACCESS for the code read, both carried by the opcode's + # cost metadata. + cold_extcodecopy = Op.EXTCODECOPY.with_metadata(address_warm=False) + cancun_extcodecopy_cost = cold_extcodecopy.gas_cost(Cancun) + code_read_delta = cold_extcodecopy.gas_cost(fork) - cancun_extcodecopy_cost coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) diff --git a/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_size_gas.py b/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_size_gas.py index cc1641db59f..f872bc23128 100644 --- a/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_size_gas.py +++ b/tests/ported_static/stEIP150singleCodeGasPrices/test_raw_ext_code_size_gas.py @@ -6,14 +6,11 @@ @manually-enhanced: Do not overwrite. This measures the regular gas that a single cold `EXTCODESIZE` consumes via `Op.GAS`. EIP-8038 -reprices the cold account access (`COLD_ACCOUNT_ACCESS`, 2600 -> 3000) -and charges an extra `WARM_ACCESS` for the opcode's second read (the -code). The stored cost therefore shifts by -`(COLD_ACCOUNT_ACCESS - 2600) + WARM_ACCESS`. The cold term comes from -the fork's own constant; the extra warm term is gated on the -`is_eip_enabled(8037)` flag (the registered flag that activates the -repricing at Amsterdam), so the delta is exactly 0 on earlier forks. -Do not hardcode it. +raises the cold account access (`COLD_ACCOUNT_ACCESS`) and charges an +extra `WARM_ACCESS` for the opcode's second read (the code). The +stored cost therefore shifts by the opcode's own cold cost on the fork +less its cost on Cancun, taken from `Op.EXTCODESIZE` metadata so the +delta is exactly 0 on earlier forks. Do not hardcode it. """ import pytest @@ -26,7 +23,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -44,14 +41,12 @@ def test_raw_ext_code_size_gas( fork: Fork, ) -> None: """Test_raw_ext_code_size_gas.""" - gas_costs = fork.gas_costs() - # EIP-8038: cold account repricing plus the extra warm access charged - # for the opcode's second read (the code). Both terms are 0 before - # EIP-8038, so the stored cost is unchanged on earlier forks. - cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 - code_read_delta = cold_account_delta + ( - gas_costs.WARM_ACCESS if fork.is_eip_enabled(8037) else 0 - ) + # EIP-8038 delta, 0 before EIP-8038: the cold account reprice plus a + # second WARM_ACCESS for the code read, both carried by the opcode's + # cost metadata. + cold_extcodesize = Op.EXTCODESIZE.with_metadata(address_warm=False) + cancun_extcodesize_cost = cold_extcodesize.gas_cost(Cancun) + code_read_delta = cold_extcodesize.gas_cost(fork) - cancun_extcodesize_cost coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) diff --git a/tests/ported_static/stEIP158Specific/test_call_one_v_call_suicide.py b/tests/ported_static/stEIP158Specific/test_call_one_v_call_suicide.py index b9be146f25c..9d530bbb5fb 100644 --- a/tests/ported_static/stEIP158Specific/test_call_one_v_call_suicide.py +++ b/tests/ported_static/stEIP158Specific/test_call_one_v_call_suicide.py @@ -6,11 +6,11 @@ @manually-enhanced: Do not overwrite. The measured slot captures the regular gas of a CALL with value to a not-yet-accessed contract that -SELFDESTRUCTs to the (alive) caller. EIP-8038 reprices the cold account -access (2600 -> 3000) and the CALL value transfer (9000 -> 10300); the -beneficiary stays alive so there is no new-account write. The delta is -`(COLD_ACCOUNT_ACCESS - 2600) + (CALL_VALUE - 9000)`, exactly 0 before -EIP-8037 and tracks parameter changes; do not hardcode the Amsterdam +SELFDESTRUCTs to the (alive) caller. EIP-8038 raises the cold account +access (`COLD_ACCOUNT_ACCESS`) and the CALL value transfer +(`CALL_VALUE`). The beneficiary stays alive so there is no new-account +write. The delta is each fork constant less Cancun's, exactly 0 before +EIP-8037 and tracks parameter changes. Do not hardcode the Amsterdam value. """ @@ -24,7 +24,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -45,8 +45,10 @@ def test_call_one_v_call_suicide( # EIP-8038 deltas, each 0 before EIP-8037. The CALL pays a cold # account access plus a value transfer; the beneficiary stays alive. gas_costs = fork.gas_costs() - cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 - call_value_delta = gas_costs.CALL_VALUE - 9000 + cold_account_delta = ( + gas_costs.COLD_ACCOUNT_ACCESS - Cancun.gas_costs().COLD_ACCOUNT_ACCESS + ) + call_value_delta = gas_costs.CALL_VALUE - Cancun.gas_costs().CALL_VALUE coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) diff --git a/tests/ported_static/stEIP158Specific/test_call_one_v_call_suicide2.py b/tests/ported_static/stEIP158Specific/test_call_one_v_call_suicide2.py index ad7c1dcc8ed..6771c126946 100644 --- a/tests/ported_static/stEIP158Specific/test_call_one_v_call_suicide2.py +++ b/tests/ported_static/stEIP158Specific/test_call_one_v_call_suicide2.py @@ -9,9 +9,9 @@ SELFDESTRUCTs (with a zero balance) to a cold, alive beneficiary. EIP-8038 reprices the CALL's cold account access and value transfer, plus the SELFDESTRUCT beneficiary's cold access; the beneficiary is -alive so there is no new-account write. The delta is therefore -`2 * (COLD_ACCOUNT_ACCESS - 2600) + (CALL_VALUE - 9000)`, exactly 0 -before EIP-8038. +alive so there is no new-account write. The delta is therefore twice +the `COLD_ACCOUNT_ACCESS` rise plus the `CALL_VALUE` rise, each the +fork's constant less Cancun's, exactly 0 before EIP-8038. """ import pytest @@ -25,7 +25,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -47,8 +47,10 @@ def test_call_one_v_call_suicide2( # account reprice and the value-transfer reprice; the cold # SELFDESTRUCT beneficiary pays a second cold account reprice. gas_costs = fork.gas_costs() - cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 - call_value_delta = gas_costs.CALL_VALUE - 9000 + cold_account_delta = ( + gas_costs.COLD_ACCOUNT_ACCESS - Cancun.gas_costs().COLD_ACCOUNT_ACCESS + ) + call_value_delta = gas_costs.CALL_VALUE - Cancun.gas_costs().CALL_VALUE coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) addr_2 = Address(0xEB201D2887816E041F6E807E804F64F3A7A226FE) sender = EOA( diff --git a/tests/ported_static/stEIP158Specific/test_call_zero_v_call_suicide.py b/tests/ported_static/stEIP158Specific/test_call_zero_v_call_suicide.py index 6dbef1bc4d7..50b67f8c9fc 100644 --- a/tests/ported_static/stEIP158Specific/test_call_zero_v_call_suicide.py +++ b/tests/ported_static/stEIP158Specific/test_call_zero_v_call_suicide.py @@ -9,7 +9,7 @@ SELFDESTRUCTs back to its (warm, alive) caller. EIP-8038 reprices the cold account access of that CALL; the beneficiary is warm so the SELFDESTRUCT is unchanged. The delta is therefore the fork's -`COLD_ACCOUNT_ACCESS - 2600`, exactly 0 before EIP-8038. +`COLD_ACCOUNT_ACCESS` less Cancun's, exactly 0 before EIP-8038. """ import pytest @@ -22,7 +22,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -41,7 +41,10 @@ def test_call_zero_v_call_suicide( ) -> None: """Test_call_zero_v_call_suicide.""" # EIP-8038 cold account access reprice; 0 before EIP-8038. - cold_account_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 + cold_account_delta = ( + fork.gas_costs().COLD_ACCOUNT_ACCESS + - Cancun.gas_costs().COLD_ACCOUNT_ACCESS + ) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) diff --git a/tests/ported_static/stEIP158Specific/test_extcodesize_to_epmty_paris.py b/tests/ported_static/stEIP158Specific/test_extcodesize_to_epmty_paris.py index 8437cc2a5fb..74ecd0ccf5b 100644 --- a/tests/ported_static/stEIP158Specific/test_extcodesize_to_epmty_paris.py +++ b/tests/ported_static/stEIP158Specific/test_extcodesize_to_epmty_paris.py @@ -24,7 +24,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -46,14 +46,17 @@ def test_extcodesize_to_epmty_paris( # cold account reprice plus a second WARM_ACCESS for the code read; # the cold SSTORE-clear (nonzero -> 0) spills its state-gas back # into regular gas. + cold_extcodesize = Op.EXTCODESIZE.with_metadata(address_warm=False) + cancun_extcodesize_cost = cold_extcodesize.gas_cost(Cancun) extcodesize_delta = ( - Op.EXTCODESIZE.with_metadata(address_warm=False).gas_cost(fork) - 2600 + cold_extcodesize.gas_cost(fork) - cancun_extcodesize_cost ) + cold_clear_sstore = Op.SSTORE.with_metadata( + key_warm=False, original_value=1, current_value=1, new_value=0 + ) + cancun_clear_sstore_cost = cold_clear_sstore.gas_cost(Cancun) cold_clear_sstore_delta = ( - Op.SSTORE.with_metadata( - key_warm=False, original_value=1, current_value=1, new_value=0 - ).gas_cost(fork) - - 5000 + cold_clear_sstore.gas_cost(fork) - cancun_clear_sstore_cost ) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) sender = pre.fund_eoa(amount=0xE8D4A51000) diff --git a/tests/ported_static/stEIP158Specific/test_extcodesize_to_non_existent.py b/tests/ported_static/stEIP158Specific/test_extcodesize_to_non_existent.py index 5f597f0b42e..99da35d47d8 100644 --- a/tests/ported_static/stEIP158Specific/test_extcodesize_to_non_existent.py +++ b/tests/ported_static/stEIP158Specific/test_extcodesize_to_non_existent.py @@ -24,7 +24,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -45,14 +45,17 @@ def test_extcodesize_to_non_existent( # EIP-8038 deltas, each 0 before EIP-8038. EXTCODESIZE gains the # cold account reprice plus a second WARM_ACCESS for the code read; # the cold value-unchanged SSTORE gains its own reprice. + cold_extcodesize = Op.EXTCODESIZE.with_metadata(address_warm=False) + cancun_extcodesize_cost = cold_extcodesize.gas_cost(Cancun) extcodesize_delta = ( - Op.EXTCODESIZE.with_metadata(address_warm=False).gas_cost(fork) - 2600 + cold_extcodesize.gas_cost(fork) - cancun_extcodesize_cost ) + cold_noop_sstore = Op.SSTORE.with_metadata( + key_warm=False, original_value=0, current_value=0, new_value=0 + ) + cancun_noop_sstore_cost = cold_noop_sstore.gas_cost(Cancun) cold_noop_sstore_delta = ( - Op.SSTORE.with_metadata( - key_warm=False, original_value=0, current_value=0, new_value=0 - ).gas_cost(fork) - - 2200 + cold_noop_sstore.gas_cost(fork) - cancun_noop_sstore_cost ) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) contract_0 = Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) diff --git a/tests/ported_static/stEIP2930/test_address_opcodes.py b/tests/ported_static/stEIP2930/test_address_opcodes.py index 0ee98b85c8d..9f2fdbd8392 100644 --- a/tests/ported_static/stEIP2930/test_address_opcodes.py +++ b/tests/ported_static/stEIP2930/test_address_opcodes.py @@ -8,7 +8,7 @@ `Op.GAS`, the regular gas of each account-touching opcode (`BALANCE`, `EXTCODESIZE`, `EXTCODEHASH`, `EXTCODECOPY`) on both a first (cold or pre-warmed) and a second (warm) access. EIP-8038 reprices these: cold -`BALANCE`/`EXTCODEHASH` by +`COLD_ACCOUNT_ACCESS - 2600`, while +`BALANCE`/`EXTCODEHASH` by the `COLD_ACCOUNT_ACCESS` raise, while `EXTCODESIZE`/`EXTCODECOPY` carry an extra flat surcharge on both their warm and cold forms. The single Cancun-era literals are therefore split per opcode and per access, each adjusted by that opcode's own warm or @@ -29,7 +29,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op, Opcode from tests.ported_static.post_state_resolution import ( @@ -568,21 +568,21 @@ def test_address_opcodes( data_size=0x20, new_memory_size=0x120, old_memory_size=0x120 ) - def _account_delta(op: Opcode, warm: bool, base: int, **meta: int) -> int: - cost = op.with_metadata(address_warm=warm, **meta).gas_cost(fork) - return cost - base + def _account_delta(op: Opcode, warm: bool, **meta: int) -> int: + priced = op.with_metadata(address_warm=warm, **meta) + return priced.gas_cost(fork) - priced.gas_cost(Cancun) - balance_warm_d = _account_delta(Op.BALANCE, True, 100) - balance_cold_d = _account_delta(Op.BALANCE, False, 2600) - extcodesize_warm_d = _account_delta(Op.EXTCODESIZE, True, 100) - extcodesize_cold_d = _account_delta(Op.EXTCODESIZE, False, 2600) - extcodehash_warm_d = _account_delta(Op.EXTCODEHASH, True, 100) - extcodehash_cold_d = _account_delta(Op.EXTCODEHASH, False, 2600) + balance_warm_d = _account_delta(Op.BALANCE, True) + balance_cold_d = _account_delta(Op.BALANCE, False) + extcodesize_warm_d = _account_delta(Op.EXTCODESIZE, True) + extcodesize_cold_d = _account_delta(Op.EXTCODESIZE, False) + extcodehash_warm_d = _account_delta(Op.EXTCODEHASH, True) + extcodehash_cold_d = _account_delta(Op.EXTCODEHASH, False) extcodecopy_warm_d = _account_delta( - Op.EXTCODECOPY, True, 103, **extcodecopy_meta + Op.EXTCODECOPY, True, **extcodecopy_meta ) extcodecopy_cold_d = _account_delta( - Op.EXTCODECOPY, False, 2603, **extcodecopy_meta + Op.EXTCODECOPY, False, **extcodecopy_meta ) # Slot 0 holds the first access (pre-warmed in the valid cases, cold diff --git a/tests/ported_static/stEIP2930/test_coinbase_t01.py b/tests/ported_static/stEIP2930/test_coinbase_t01.py index af24697b599..cc845f52a37 100644 --- a/tests/ported_static/stEIP2930/test_coinbase_t01.py +++ b/tests/ported_static/stEIP2930/test_coinbase_t01.py @@ -6,11 +6,11 @@ @manually-enhanced: Do not overwrite. The target contract measures, via `Op.GAS`, the regular gas of a `CALL` that transfers value to the warm, -already-existing coinbase. EIP-8038 reprices the value-transfer -component (`CALL_VALUE` 9 000 -> 10 300), so the measurement grows by -`gas_costs.CALL_VALUE - 9000`. That delta is derived from the fork's -own gas model, so it is exactly 0 before EIP-8038 and tracks future -parameter changes; do not hardcode the Amsterdam number. +already-existing coinbase. EIP-8038 raises the value-transfer +component `CALL_VALUE`, so the measurement grows by the `CALL_VALUE` +delta versus Cancun. That delta is derived from the fork's own gas +model, so it is exactly 0 before EIP-8038 and tracks future parameter +changes; do not hardcode the Amsterdam number. """ import pytest @@ -25,7 +25,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op from tests.ported_static.post_state_resolution import ( @@ -124,7 +124,9 @@ def test_coinbase_t01( # coinbase warm and already in state, the measured gas grows by the # `CALL_VALUE` reprice alone. Derived from the fork gas model so it # is 0 before EIP-8038. - call_value_delta = fork.gas_costs().CALL_VALUE - 9000 + call_value_delta = ( + fork.gas_costs().CALL_VALUE - Cancun.gas_costs().CALL_VALUE + ) expect_entries_: list[dict] = [ { diff --git a/tests/ported_static/stEIP2930/test_coinbase_t2.py b/tests/ported_static/stEIP2930/test_coinbase_t2.py index 4a4bbe1a940..99962b6d708 100644 --- a/tests/ported_static/stEIP2930/test_coinbase_t2.py +++ b/tests/ported_static/stEIP2930/test_coinbase_t2.py @@ -6,11 +6,11 @@ @manually-enhanced: Do not overwrite. The target contract measures, via `Op.GAS`, the regular gas of a `CALL` that transfers value to the warm, -already-existing coinbase. EIP-8038 reprices the value-transfer -component (`CALL_VALUE` 9 000 -> 10 300), so the measurement grows by -`gas_costs.CALL_VALUE - 9000`. That delta is derived from the fork's -own gas model, so it is exactly 0 before EIP-8038 and tracks future -parameter changes; do not hardcode the Amsterdam number. +already-existing coinbase. EIP-8038 raises the value-transfer +component `CALL_VALUE`, so the measurement grows by the `CALL_VALUE` +delta versus Cancun. That delta is derived from the fork's own gas +model, so it is exactly 0 before EIP-8038 and tracks future parameter +changes; do not hardcode the Amsterdam number. """ import pytest @@ -25,7 +25,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op from tests.ported_static.post_state_resolution import ( @@ -118,7 +118,9 @@ def test_coinbase_t2( # coinbase warm and already in state, the measured gas grows by the # `CALL_VALUE` reprice alone. Derived from the fork gas model so it # is 0 before EIP-8038. - call_value_delta = fork.gas_costs().CALL_VALUE - 9000 + call_value_delta = ( + fork.gas_costs().CALL_VALUE - Cancun.gas_costs().CALL_VALUE + ) expect_entries_: list[dict] = [ { diff --git a/tests/ported_static/stEIP2930/test_manual_create.py b/tests/ported_static/stEIP2930/test_manual_create.py index 1218f8a5d48..9cdde0b2eb5 100644 --- a/tests/ported_static/stEIP2930/test_manual_create.py +++ b/tests/ported_static/stEIP2930/test_manual_create.py @@ -28,7 +28,7 @@ Transaction, compute_create_address, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op from tests.ported_static.post_state_resolution import ( @@ -95,15 +95,13 @@ def test_manual_create( # EIP-8037 SSTORE-set spill into regular gas (empty reservoir). # Derive the warm and cold fresh-set deltas from the fork's own # gas model so each is exactly 0 pre-EIP-8037. - def _sstore_delta(cancun_cost: int, **metadata: int) -> int: + def _sstore_delta(**metadata: int) -> int: op = Op.SSTORE.with_metadata(**metadata) - return op.gas_cost(fork) - cancun_cost + return op.gas_cost(fork) - op.gas_cost(Cancun) - warm_set_delta = _sstore_delta( - 20000, key_warm=True, current_value=0, new_value=2 - ) + warm_set_delta = _sstore_delta(key_warm=True, current_value=0, new_value=2) cold_set_delta = _sstore_delta( - 22100, key_warm=False, current_value=0, new_value=2 + key_warm=False, current_value=0, new_value=2 ) expect_entries_: list[dict] = [ diff --git a/tests/ported_static/stEIP2930/test_storage_costs.py b/tests/ported_static/stEIP2930/test_storage_costs.py index a75eb0ade75..03f31a6352c 100644 --- a/tests/ported_static/stEIP2930/test_storage_costs.py +++ b/tests/ported_static/stEIP2930/test_storage_costs.py @@ -31,7 +31,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op from tests.ported_static.post_state_resolution import ( @@ -669,28 +669,31 @@ def test_storage_costs( # cost. Every measured access therefore shifts by its # (Amsterdam - Cancun) delta; derive each delta from the fork's own # opcode gas model so it is exactly 0 pre-EIP-8037 and tracks future - # parameter changes. The subtracted Cancun-era pure costs are frozen - # historical values. - def _sstore_delta(cancun_cost: int, **metadata: int) -> int: + # parameter changes. The subtracted baseline is the same access + # priced at Cancun. + def _sstore_delta(**metadata: int) -> int: op = Op.SSTORE.with_metadata(**metadata) - return op.gas_cost(fork) - cancun_cost + return op.gas_cost(fork) - op.gas_cost(Cancun) d_warm_set = _sstore_delta( - 20000, key_warm=True, original_value=0, current_value=0, new_value=2 + key_warm=True, original_value=0, current_value=0, new_value=2 ) d_cold_set = _sstore_delta( - 22100, key_warm=False, original_value=0, current_value=0, new_value=2 + key_warm=False, original_value=0, current_value=0, new_value=2 ) d_warm_write = _sstore_delta( - 2900, key_warm=True, original_value=1, current_value=1, new_value=2 + key_warm=True, original_value=1, current_value=1, new_value=2 ) d_cold_write = _sstore_delta( - 5000, key_warm=False, original_value=1, current_value=1, new_value=2 + key_warm=False, original_value=1, current_value=1, new_value=2 ) d_cold_noop = _sstore_delta( - 2200, key_warm=False, original_value=1, current_value=1, new_value=1 + key_warm=False, original_value=1, current_value=1, new_value=1 + ) + d_cold_read = ( + fork.gas_costs().COLD_STORAGE_ACCESS + - Cancun.gas_costs().COLD_STORAGE_ACCESS ) - d_cold_read = fork.gas_costs().COLD_STORAGE_ACCESS - 2100 expect_entries_: list[dict] = [ # declaredKeyWrite: warm fresh SSTORE-set. diff --git a/tests/ported_static/stEIP2930/test_varied_context.py b/tests/ported_static/stEIP2930/test_varied_context.py index 48aeb6ae16c..f76eec4c469 100644 --- a/tests/ported_static/stEIP2930/test_varied_context.py +++ b/tests/ported_static/stEIP2930/test_varied_context.py @@ -35,7 +35,7 @@ Transaction, compute_create_address, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op from tests.ported_static.post_state_resolution import ( @@ -1165,8 +1165,12 @@ def test_varied_context( _cold_reset = Op.SSTORE.with_metadata( key_warm=False, original_value=1, current_value=1, new_value=2 ) - valid_write_gas = 0xB65 + (_warm_reset.gas_cost(fork) - 2900) - valid_read_gas = 0x1800 + (_cold_reset.gas_cost(fork) - 5000) + valid_write_gas = 0xB65 + ( + _warm_reset.gas_cost(fork) - _warm_reset.gas_cost(Cancun) + ) + valid_read_gas = 0x1800 + ( + _cold_reset.gas_cost(fork) - _cold_reset.gas_cost(Cancun) + ) contract_13 = pre.deploy_contract( # noqa: F841 code=Op.CALL( @@ -1360,26 +1364,30 @@ def test_varied_context( # parameter changes. gas_costs = fork.gas_costs() - def _sstore_delta(cancun_cost: int, **metadata: int) -> int: + def _sstore_delta(**metadata: int) -> int: op = Op.SSTORE.with_metadata(**metadata) - return op.gas_cost(fork) - cancun_cost + return op.gas_cost(fork) - op.gas_cost(Cancun) # Fresh SSTORE-set (state-gas spill dominates), warm vs cold key. - warm_set_delta = _sstore_delta( - 20000, key_warm=True, current_value=0, new_value=2 - ) + warm_set_delta = _sstore_delta(key_warm=True, current_value=0, new_value=2) cold_set_delta = _sstore_delta( - 22100, key_warm=False, current_value=0, new_value=2 + key_warm=False, current_value=0, new_value=2 ) - # CALL value transfer to a non-alive account: the 25 000 NEW_ACCOUNT + # CALL value transfer to a non-alive account: the Cancun NEW_ACCOUNT # base becomes a spilling state-gas charge. new_account_delta = ( - (fork.create_state_gas() - 25000) if fork.is_eip_enabled(8037) else 0 + (fork.create_state_gas() - Cancun.gas_costs().NEW_ACCOUNT) + if fork.is_eip_enabled(8037) + else 0 ) # Cold account access reprice (0 pre-Amsterdam). - cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 + cold_account_delta = ( + gas_costs.COLD_ACCOUNT_ACCESS - Cancun.gas_costs().COLD_ACCOUNT_ACCESS + ) # Cold storage access (SLOAD) reprice (0 pre-Amsterdam). - cold_storage_delta = gas_costs.COLD_STORAGE_ACCESS - 2100 + cold_storage_delta = ( + gas_costs.COLD_STORAGE_ACCESS - Cancun.gas_costs().COLD_STORAGE_ACCESS + ) # SELFDESTRUCT to a non-alive cold beneficiary: new-account spill, # the new ACCOUNT_WRITE charge (0 pre-Amsterdam), and the cold # reprice. diff --git a/tests/ported_static/stNonZeroCallsTest/test_non_zero_value_call_to_non_non_zero_balance.py b/tests/ported_static/stNonZeroCallsTest/test_non_zero_value_call_to_non_non_zero_balance.py index 5df9cd42733..41d7dc91770 100644 --- a/tests/ported_static/stNonZeroCallsTest/test_non_zero_value_call_to_non_non_zero_balance.py +++ b/tests/ported_static/stNonZeroCallsTest/test_non_zero_value_call_to_non_non_zero_balance.py @@ -8,10 +8,9 @@ regular gas of a value-1 CALL to a cold, alive EOA plus the SSTORE storing the (success) result. EIP-8038 reprices the CALL's cold account access and value transfer, and reprices the cold -value-unchanged SSTORE. The delta is therefore -`(COLD_ACCOUNT_ACCESS - 2600) + (CALL_VALUE - 9000)` plus the cold -SSTORE reprice, each derived from the fork and exactly 0 before -EIP-8038. +value-unchanged SSTORE. The delta is therefore the `COLD_ACCOUNT_ACCESS` +and `CALL_VALUE` deltas plus the cold SSTORE reprice, each derived from +the fork and exactly 0 before EIP-8038. """ import pytest @@ -24,7 +23,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -48,13 +47,16 @@ def test_non_zero_value_call_to_non_non_zero_balance( # account reprice and the value-transfer reprice; the cold # value-unchanged SSTORE gains its own reprice. gas_costs = fork.gas_costs() - cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 - call_value_delta = gas_costs.CALL_VALUE - 9000 + cold_account_delta = ( + gas_costs.COLD_ACCOUNT_ACCESS - Cancun.gas_costs().COLD_ACCOUNT_ACCESS + ) + call_value_delta = gas_costs.CALL_VALUE - Cancun.gas_costs().CALL_VALUE + cold_noop_sstore = Op.SSTORE.with_metadata( + key_warm=False, original_value=0, current_value=0, new_value=0 + ) + cancun_cold_noop_sstore = cold_noop_sstore.gas_cost(Cancun) cold_noop_sstore_delta = ( - Op.SSTORE.with_metadata( - key_warm=False, original_value=0, current_value=0, new_value=0 - ).gas_cost(fork) - - 2200 + cold_noop_sstore.gas_cost(fork) - cancun_cold_noop_sstore ) call_measure_delta = ( cold_account_delta + call_value_delta + cold_noop_sstore_delta diff --git a/tests/ported_static/stNonZeroCallsTest/test_non_zero_value_callcode_to_non_non_zero_balance.py b/tests/ported_static/stNonZeroCallsTest/test_non_zero_value_callcode_to_non_non_zero_balance.py index a55087af42b..84f5818f968 100644 --- a/tests/ported_static/stNonZeroCallsTest/test_non_zero_value_callcode_to_non_non_zero_balance.py +++ b/tests/ported_static/stNonZeroCallsTest/test_non_zero_value_callcode_to_non_non_zero_balance.py @@ -8,10 +8,9 @@ regular gas of a value-1 CALLCODE to a cold, alive EOA plus the SSTORE storing the (success) result. EIP-8038 reprices the CALLCODE's cold account access and value transfer, and reprices the cold -value-unchanged SSTORE. The delta is therefore -`(COLD_ACCOUNT_ACCESS - 2600) + (CALL_VALUE - 9000)` plus the cold -SSTORE reprice, each derived from the fork and exactly 0 before -EIP-8038. +value-unchanged SSTORE. The delta is therefore the `COLD_ACCOUNT_ACCESS` +and `CALL_VALUE` deltas plus the cold SSTORE reprice, each derived from +the fork and exactly 0 before EIP-8038. """ import pytest @@ -24,7 +23,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -48,13 +47,16 @@ def test_non_zero_value_callcode_to_non_non_zero_balance( # cold account reprice and the value-transfer reprice; the cold # value-unchanged SSTORE gains its own reprice. gas_costs = fork.gas_costs() - cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 - call_value_delta = gas_costs.CALL_VALUE - 9000 + cold_account_delta = ( + gas_costs.COLD_ACCOUNT_ACCESS - Cancun.gas_costs().COLD_ACCOUNT_ACCESS + ) + call_value_delta = gas_costs.CALL_VALUE - Cancun.gas_costs().CALL_VALUE + cold_noop_sstore = Op.SSTORE.with_metadata( + key_warm=False, original_value=0, current_value=0, new_value=0 + ) + cancun_cold_noop_sstore = cold_noop_sstore.gas_cost(Cancun) cold_noop_sstore_delta = ( - Op.SSTORE.with_metadata( - key_warm=False, original_value=0, current_value=0, new_value=0 - ).gas_cost(fork) - - 2200 + cold_noop_sstore.gas_cost(fork) - cancun_cold_noop_sstore ) call_measure_delta = ( cold_account_delta + call_value_delta + cold_noop_sstore_delta diff --git a/tests/ported_static/stPreCompiledContracts/test_precomps_eip2929_cancun.py b/tests/ported_static/stPreCompiledContracts/test_precomps_eip2929_cancun.py index 1c4a3be1c29..c1f28af5456 100644 --- a/tests/ported_static/stPreCompiledContracts/test_precomps_eip2929_cancun.py +++ b/tests/ported_static/stPreCompiledContracts/test_precomps_eip2929_cancun.py @@ -7,15 +7,14 @@ @manually-enhanced: Do not overwrite. 87 parametrizations of this test measure the regular gas consumed by a CALL with value to an inactive precompile address. EIP-8037 replaces the Cancun-era -CALL_NEW_ACCOUNT cost of 25 000 with a per-new-account state-gas -charge that, with an empty reservoir (the case here), spills back -into regular gas; EIP-8038 also reprices the cold account access -from 2 600 to 3 000. `Op.GAS` therefore reads -`fork.create_state_gas() - 25 000 + COLD_ACCOUNT_ACCESS - 2 600` -extra regular gas compared to Cancun. Derive that delta from the -fork so it is 0 pre-EIP-8037 and tracks parameter changes; bake it -into the two affected `[">=Cancun"]` expect-entries. The third -entry is gated to `["Cancun"]` only and unchanged. +`NEW_ACCOUNT` cost with a per-new-account state-gas charge that, +with an empty reservoir (the case here), spills back into regular +gas; EIP-8038 also raises `COLD_ACCOUNT_ACCESS`. `Op.GAS` therefore +reads the new-account and `COLD_ACCOUNT_ACCESS` deltas as extra +regular gas compared to Cancun. Derive that delta from the fork so +it is 0 pre-EIP-8037 and tracks parameter changes; bake it into the +two affected `[">=Cancun"]` expect-entries. The third entry is gated +to `["Cancun"]` only and unchanged. """ import pytest @@ -29,7 +28,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op from tests.ported_static.post_state_resolution import ( @@ -3595,17 +3594,22 @@ def test_precomps_eip2929_cancun( ) # These measurements isolate repriced components applied per - # expect-entry below: EIP-8037 replaces the 25 000 CALL_NEW_ACCOUNT - # base cost with a state-gas charge that, with an empty reservoir, - # spills back into regular gas; EIP-8038 separately reprices a cold - # account access from 2 600 to 3 000. Derive both from the fork so + # expect-entry below: EIP-8037 replaces the CALL_NEW_ACCOUNT base + # cost with a state-gas charge that, with an empty reservoir, + # spills back into regular gas, and EIP-8038 separately raises the + # cold account access. Derive both from the fork so # they are 0 pre-EIP-8037 and track parameter changes. `new` # entries shift by the account delta, `no` entries by the cold # delta, and `all` entries by both. new_account_delta = ( - (fork.create_state_gas() - 25000) if fork.is_eip_enabled(8037) else 0 + (fork.create_state_gas() - Cancun.gas_costs().NEW_ACCOUNT) + if fork.is_eip_enabled(8037) + else 0 + ) + cold_account_delta = ( + fork.gas_costs().COLD_ACCOUNT_ACCESS + - Cancun.gas_costs().COLD_ACCOUNT_ACCESS ) - cold_account_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 expect_entries_: list[dict] = [ { diff --git a/tests/ported_static/stRefundTest/test_refund50_1.py b/tests/ported_static/stRefundTest/test_refund50_1.py index 1dbb8c5ccb7..ccdcd6c24f9 100644 --- a/tests/ported_static/stRefundTest/test_refund50_1.py +++ b/tests/ported_static/stRefundTest/test_refund50_1.py @@ -7,12 +7,12 @@ @manually-enhanced: Do not overwrite. The post-state asserts the sender balance, which equals its start minus `gas_used * gas_price`. The contract clears five cold storage slots; EIP-8038 raises each cold -SSTORE-clear charge from 5000 to 13000. The EIP-3529 refund cap -(`gas_used // 5`) binds at both forks (the clear refunds far exceed a -fifth of gas used), so the extra charge raises `gas_used` by exactly -four fifths of itself. Derive the per-clear charge delta from the fork -gas model (0 pre-EIP-8037) and subtract `gas_price * 5 * delta * 4 // 5` -from the Cancun balance; do not hardcode the Amsterdam value. +SSTORE-clear charge. The EIP-3529 refund cap (`gas_used // 5`) binds at +both forks (the clear refunds far exceed a fifth of gas used), so the +extra charge raises `gas_used` by exactly four fifths of itself. Derive +the per-clear charge delta from the fork gas model (0 pre-EIP-8037) and +subtract `gas_price * 5 * delta * 4 // 5` from the Cancun balance; do +not hardcode the Amsterdam value. """ import pytest @@ -25,7 +25,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -80,13 +80,14 @@ def test_refund50_1( # EIP-8038 raises each cold SSTORE-clear charge and EIP-2780 # shifts the tx intrinsic. With the EIP-3529 refund cap binding, # gas_used rises by 4/5 of the gross-gas delta. - cold_clear_delta = ( - Op.SSTORE.with_metadata( - key_warm=False, original_value=1, current_value=1, new_value=0 - ).gas_cost(fork) - - 5000 + cold_clear = Op.SSTORE.with_metadata( + key_warm=False, original_value=1, current_value=1, new_value=0 + ) + cold_clear_delta = cold_clear.gas_cost(fork) - cold_clear.gas_cost(Cancun) + intrinsic_delta = ( + fork.transaction_intrinsic_cost_calculator()() + - Cancun.transaction_intrinsic_cost_calculator()() ) - intrinsic_delta = fork.transaction_intrinsic_cost_calculator()() - 21_000 gross_delta = 5 * cold_clear_delta + intrinsic_delta extra_gas_used = gross_delta * 4 // 5 diff --git a/tests/ported_static/stRefundTest/test_refund_call_a_not_enough_gas_in_call.py b/tests/ported_static/stRefundTest/test_refund_call_a_not_enough_gas_in_call.py index eaf1f5a8c97..6f6d993694d 100644 --- a/tests/ported_static/stRefundTest/test_refund_call_a_not_enough_gas_in_call.py +++ b/tests/ported_static/stRefundTest/test_refund_call_a_not_enough_gas_in_call.py @@ -8,9 +8,9 @@ balance, which equals its start minus `gas_used * gas_price`. The inner CALL is starved of gas so its SSTORE clear always reverts (no refund survives); the only surviving repricing is in the outer frame, where -EIP-8038 raises the cold account-access charged by the CALL (2600 -> -3000) and the cold no-op SSTORE of slot 0 (2200 -> 3000). Derive both -deltas from the fork gas model (0 pre-EIP-8037) and subtract +EIP-8038 raises the `COLD_ACCOUNT_ACCESS` charged by the CALL and +the cold no-op SSTORE of slot 0. Derive both deltas from the fork gas +model (0 pre-EIP-8037) and subtract `gas_price * (call_access_delta + outer_sstore_delta)` from the Cancun balance; do not hardcode the Amsterdam value. """ @@ -25,7 +25,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -98,13 +98,14 @@ def test_refund_call_a_not_enough_gas_in_call( # cold account access charged by the CALL and the cold no-op SSTORE # of slot 0 (original == current == new == 0). gas_costs = fork.gas_costs() - call_access_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 - outer_sstore_delta = ( - Op.SSTORE.with_metadata( - key_warm=False, original_value=0, current_value=0, new_value=0 - ).gas_cost(fork) - - 2200 + call_access_delta = ( + gas_costs.COLD_ACCOUNT_ACCESS - Cancun.gas_costs().COLD_ACCOUNT_ACCESS ) + outer_sstore = Op.SSTORE.with_metadata( + key_warm=False, original_value=0, current_value=0, new_value=0 + ) + cancun_outer_sstore = outer_sstore.gas_cost(Cancun) + outer_sstore_delta = outer_sstore.gas_cost(fork) - cancun_outer_sstore gas_used_delta = call_access_delta + outer_sstore_delta post = { diff --git a/tests/ported_static/stRefundTest/test_refund_change_non_zero_storage.py b/tests/ported_static/stRefundTest/test_refund_change_non_zero_storage.py index 800ea8835ea..f4efbd6542e 100644 --- a/tests/ported_static/stRefundTest/test_refund_change_non_zero_storage.py +++ b/tests/ported_static/stRefundTest/test_refund_change_non_zero_storage.py @@ -8,11 +8,10 @@ balance, which equals its start minus `gas_used * gas_price`. The contract resets one warm-after-cold storage slot from a non-zero value to another non-zero value (1 -> 23); EIP-8038 raises this cold -SSTORE-reset charge from 5000 to 13000. There is no storage-clear -refund, so `gas_used` rises by exactly the charge delta. Derive that -delta from the fork gas model (0 pre-EIP-8037) and subtract -`gas_price * delta` from the Cancun balance; do not hardcode the -Amsterdam value. +SSTORE-reset charge. There is no storage-clear refund, so `gas_used` +rises by exactly the charge delta. Derive that delta from the fork gas +model (0 pre-EIP-8037) and subtract `gas_price * delta` from the Cancun +balance; do not hardcode the Amsterdam value. """ import pytest @@ -25,7 +24,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -75,12 +74,10 @@ def test_refund_change_non_zero_storage( # EIP-8038 raises the cold SSTORE-reset charge (non-zero to non-zero); # with no storage-clear refund, gas_used rises by the full delta. - cold_reset_delta = ( - Op.SSTORE.with_metadata( - key_warm=False, original_value=1, current_value=1, new_value=23 - ).gas_cost(fork) - - 5000 + cold_reset = Op.SSTORE.with_metadata( + key_warm=False, original_value=1, current_value=1, new_value=23 ) + cold_reset_delta = cold_reset.gas_cost(fork) - cold_reset.gas_cost(Cancun) post = { target: Account(storage={1: 23}, balance=0xDE0B6B3A764000A), diff --git a/tests/ported_static/stRefundTest/test_refund_ff.py b/tests/ported_static/stRefundTest/test_refund_ff.py index e0535dc9ab8..e5e916767cc 100644 --- a/tests/ported_static/stRefundTest/test_refund_ff.py +++ b/tests/ported_static/stRefundTest/test_refund_ff.py @@ -7,8 +7,8 @@ @manually-enhanced: Do not overwrite. The post-state asserts the sender balance, which equals its start minus `gas_used * gas_price`. The contract self-destructs and sends its (zero) balance to a cold, already -existing beneficiary; EIP-8038 raises the cold account-access surcharge -from 2600 to 3000. No positive balance is moved, so no `ACCOUNT_WRITE` +existing beneficiary; EIP-8038 raises the `COLD_ACCOUNT_ACCESS` +surcharge. No positive balance is moved, so no `ACCOUNT_WRITE` applies and there is no refund, so `gas_used` rises by exactly the SELFDESTRUCT charge delta. Derive that delta from the fork gas model (0 pre-EIP-8037) and subtract `gas_price * delta` from the Cancun @@ -25,7 +25,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -79,16 +79,18 @@ def test_refund_ff( # EIP-8038 raises the cold account-access surcharge applied by # SELFDESTRUCT; with no balance transfer and no refund, gas_used # rises by exactly this charge delta. - selfdestruct_delta = ( - Op.SELFDESTRUCT.with_metadata( - address_warm=False, account_new=False - ).gas_cost(fork) - - 7600 + selfdestruct = Op.SELFDESTRUCT.with_metadata( + address_warm=False, account_new=False ) + cancun_selfdestruct = selfdestruct.gas_cost(Cancun) + selfdestruct_delta = selfdestruct.gas_cost(fork) - cancun_selfdestruct # EIP-2780 lowers the intrinsic for non-self non-value txs; the # delta is negative on Amsterdam, so it reduces ``gas_used`` and # raises the sender balance correspondingly. - intrinsic_delta = fork.transaction_intrinsic_cost_calculator()() - 21_000 + intrinsic_delta = ( + fork.transaction_intrinsic_cost_calculator()() + - Cancun.transaction_intrinsic_cost_calculator()() + ) gas_used_delta = selfdestruct_delta + intrinsic_delta post = {sender: Account(balance=0xE8D4A51000 - 1000 * gas_used_delta)} diff --git a/tests/ported_static/stRefundTest/test_refund_get_ether_back.py b/tests/ported_static/stRefundTest/test_refund_get_ether_back.py index 2411e062abd..8e585e8b0ae 100644 --- a/tests/ported_static/stRefundTest/test_refund_get_ether_back.py +++ b/tests/ported_static/stRefundTest/test_refund_get_ether_back.py @@ -7,14 +7,13 @@ @manually-enhanced: Do not overwrite. The post-state asserts the sender balance, which equals its start minus `gas_used * gas_price`. The contract clears one cold storage slot (1 -> 0); EIP-8038 raises the cold -SSTORE-clear charge from 5000 to 13000 and the storage-clear refund from -4800 to 12480. The EIP-3529 refund cap (`gas_used // 5`) does not bind at -Cancun but does at Amsterdam, so the shift is modeled from the fork gas -model: reconstruct the cap-bounded `gas_used` from the fork-invariant -non-SSTORE gross gas plus the fork SSTORE charge minus the capped refund, -and subtract the same expression evaluated with the pre-repricing Cancun -charges (so the adjustment is exactly 0 pre-EIP-8037). Do not hardcode -the Amsterdam value. +SSTORE-clear charge and `REFUND_STORAGE_CLEAR`. The EIP-3529 refund cap +(`gas_used // 5`) does not bind at Cancun but does at Amsterdam, so the +shift is modeled from the fork gas model: reconstruct the cap-bounded +`gas_used` from the fork-invariant non-SSTORE gross gas plus the fork +SSTORE charge minus the capped refund, and subtract the same expression +evaluated with the pre-repricing Cancun charges (so the adjustment is +exactly 0 pre-EIP-8037). Do not hardcode the Amsterdam value. """ import pytest @@ -27,7 +26,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -88,7 +87,11 @@ def test_refund_get_ether_back( return_cost_deducted_prior_execution=True, ) base_gross = intrinsic + 2 * gas_costs.VERY_LOW - cancun_base_gross = 21_000 + 2 * gas_costs.VERY_LOW + cancun_intrinsic = Cancun.transaction_intrinsic_cost_calculator()( + sends_value=bool(tx.value), + return_cost_deducted_prior_execution=True, + ) + cancun_base_gross = cancun_intrinsic + 2 * Cancun.gas_costs().VERY_LOW def clear_gas_used( sstore_charge: int, clear_refund: int, gross_base: int @@ -96,15 +99,19 @@ def clear_gas_used( gross = gross_base + sstore_charge return gross - min(clear_refund, gross // 5) - sstore_charge = Op.SSTORE.with_metadata( + sstore_clear = Op.SSTORE.with_metadata( key_warm=False, original_value=1, current_value=1, new_value=0 - ).gas_cost(fork) - # Cancun charges 5000 for the clear and refunds 4800; subtracting the - # same model evaluated at those constants and the Cancun base makes - # this exactly 0 before the EIP-8037/8038 repricing. + ) + sstore_charge = sstore_clear.gas_cost(fork) + # Subtracting the same model evaluated at Cancun's charge, refund and + # base makes this exactly 0 before the EIP-8037/8038 repricing. gas_used_delta = clear_gas_used( sstore_charge, gas_costs.REFUND_STORAGE_CLEAR, base_gross - ) - clear_gas_used(5000, 4800, cancun_base_gross) + ) - clear_gas_used( + sstore_clear.gas_cost(Cancun), + Cancun.gas_costs().REFUND_STORAGE_CLEAR, + cancun_base_gross, + ) post = { target: Account(storage={}, balance=0xDE0B6B3A764000A), diff --git a/tests/ported_static/stRefundTest/test_refund_max.py b/tests/ported_static/stRefundTest/test_refund_max.py index ac549958112..20512c38e33 100644 --- a/tests/ported_static/stRefundTest/test_refund_max.py +++ b/tests/ported_static/stRefundTest/test_refund_max.py @@ -7,12 +7,12 @@ @manually-enhanced: Do not overwrite. The post-state asserts the sender balance, which equals its start minus `gas_used * gas_price`. The contract clears eight cold storage slots; EIP-8038 raises each cold -SSTORE-clear charge from 5000 to 13000. The EIP-3529 refund cap -(`gas_used // 5`) binds at both forks (the clear refunds far exceed a -fifth of gas used), so the extra charge raises `gas_used` by exactly -four fifths of itself. Derive the per-clear charge delta from the fork -gas model (0 pre-EIP-8037) and subtract `gas_price * 8 * delta * 4 // 5` -from the Cancun balance; do not hardcode the Amsterdam value. +SSTORE-clear charge. The EIP-3529 refund cap (`gas_used // 5`) binds at +both forks (the clear refunds far exceed a fifth of gas used), so the +extra charge raises `gas_used` by exactly four fifths of itself. Derive +the per-clear charge delta from the fork gas model (0 pre-EIP-8037) and +subtract `gas_price * 8 * delta * 4 // 5` from the Cancun balance; do +not hardcode the Amsterdam value. """ import pytest @@ -25,7 +25,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -110,13 +110,14 @@ def test_refund_max( # EIP-8038 raises each cold SSTORE-clear charge and EIP-2780 # shifts the tx intrinsic. With the EIP-3529 refund cap binding, # gas_used rises by 4/5 of the gross-gas delta. - cold_clear_delta = ( - Op.SSTORE.with_metadata( - key_warm=False, original_value=1, current_value=1, new_value=0 - ).gas_cost(fork) - - 5000 + cold_clear = Op.SSTORE.with_metadata( + key_warm=False, original_value=1, current_value=1, new_value=0 + ) + cold_clear_delta = cold_clear.gas_cost(fork) - cold_clear.gas_cost(Cancun) + intrinsic_delta = ( + fork.transaction_intrinsic_cost_calculator()() + - Cancun.transaction_intrinsic_cost_calculator()() ) - intrinsic_delta = fork.transaction_intrinsic_cost_calculator()() - 21_000 gross_delta = 8 * cold_clear_delta + intrinsic_delta extra_gas_used = gross_delta * 4 // 5 diff --git a/tests/ported_static/stRefundTest/test_refund_no_oog_1.py b/tests/ported_static/stRefundTest/test_refund_no_oog_1.py index b74b450caf3..b4bed796043 100644 --- a/tests/ported_static/stRefundTest/test_refund_no_oog_1.py +++ b/tests/ported_static/stRefundTest/test_refund_no_oog_1.py @@ -6,12 +6,12 @@ @manually-enhanced: Do not overwrite. The transaction supplies exactly enough gas to clear one cold storage slot (1 -> 0) and no more (the "no -out-of-gas" boundary). EIP-8038 raises the cold SSTORE-clear charge from -5000 to 13000, so the gas limit must rise by that charge delta to keep -the slot clearing instead of running out of gas. The asserted sender -balance equals its start minus `gas_used * gas_price`, and `gas_used` -is the gross gas minus the storage-clear refund (capped by EIP-3529 only -at Amsterdam). Both the gas limit bump and the balance shift are derived +out-of-gas" boundary). EIP-8038 raises the cold SSTORE-clear charge, +so the gas limit must rise by that charge delta to keep the slot +clearing instead of running out of gas. The asserted sender balance +equals its start minus `gas_used * gas_price`, and `gas_used` is the +gross gas minus the storage-clear refund (capped by EIP-3529 only at +Amsterdam). Both the gas limit bump and the balance shift are derived from the fork gas model and are exactly 0 pre-EIP-8037; do not hardcode the Amsterdam values. """ @@ -26,7 +26,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -70,17 +70,21 @@ def test_refund_no_oog_1( # the tx intrinsic; bump the gas limit by both deltas so the clear # still lands exactly at the limit (the "no out-of-gas" boundary) # instead of running out of gas. - sstore_charge = Op.SSTORE.with_metadata( + sstore_clear = Op.SSTORE.with_metadata( key_warm=False, original_value=1, current_value=1, new_value=0 - ).gas_cost(fork) - cold_clear_delta = sstore_charge - 5000 + ) + sstore_charge = sstore_clear.gas_cost(fork) + cold_clear_delta = sstore_charge - sstore_clear.gas_cost(Cancun) # ``return_cost_deducted_prior_execution=True`` returns the # upfront-deducted intrinsic only (Prague's calc would otherwise # return ``max(intrinsic, EIP-7623 floor)``). intrinsic = fork.transaction_intrinsic_cost_calculator()( return_cost_deducted_prior_execution=True, ) - intrinsic_delta = intrinsic - 21_000 + cancun_intrinsic = Cancun.transaction_intrinsic_cost_calculator()( + return_cost_deducted_prior_execution=True, + ) + intrinsic_delta = intrinsic - cancun_intrinsic tx = Transaction( sender=sender, @@ -95,18 +99,21 @@ def test_refund_no_oog_1( # two PUSH1s that feed the single SSTORE (STOP is free). gas_costs = fork.gas_costs() base_gross = intrinsic + 2 * gas_costs.VERY_LOW - cancun_base_gross = 21_000 + 2 * gas_costs.VERY_LOW + cancun_base_gross = cancun_intrinsic + 2 * Cancun.gas_costs().VERY_LOW def clear_gas_used(charge: int, clear_refund: int, gross_base: int) -> int: gross = gross_base + charge return gross - min(clear_refund, gross // 5) - # Cancun charges 5000 for the clear and refunds 4800; subtracting the - # same model evaluated at those constants and the Cancun base makes - # this exactly 0 before the EIP-8037/8038 repricing. + # Subtracting the same model evaluated at Cancun's charge, refund and + # base makes this exactly 0 before the EIP-8037/8038 repricing. gas_used_delta = clear_gas_used( sstore_charge, gas_costs.REFUND_STORAGE_CLEAR, base_gross - ) - clear_gas_used(5000, 4800, cancun_base_gross) + ) - clear_gas_used( + sstore_clear.gas_cost(Cancun), + Cancun.gas_costs().REFUND_STORAGE_CLEAR, + cancun_base_gross, + ) post = { target: Account(storage={}), diff --git a/tests/ported_static/stRefundTest/test_refund_sstore.py b/tests/ported_static/stRefundTest/test_refund_sstore.py index 732f909022e..aba98cff2dc 100644 --- a/tests/ported_static/stRefundTest/test_refund_sstore.py +++ b/tests/ported_static/stRefundTest/test_refund_sstore.py @@ -7,14 +7,14 @@ @manually-enhanced: Do not overwrite. The post-state asserts the sender balance, which equals its start minus `gas_used * gas_price`. The contract clears one cold storage slot (non-zero -> 0); EIP-8038 raises -the cold SSTORE-clear charge from 5000 to 13000 and the storage-clear -refund from 4800 to 12480. The EIP-3529 refund cap (`gas_used // 5`) does -not bind at Cancun but does at Amsterdam, so the shift is modeled from -the fork gas model: reconstruct the cap-bounded `gas_used` from the -fork-invariant non-SSTORE gross gas plus the fork SSTORE charge minus the -capped refund, and subtract the same expression evaluated with the -pre-repricing Cancun charges (so the adjustment is exactly 0 -pre-EIP-8037). Do not hardcode the Amsterdam value. +the cold SSTORE-clear charge and `REFUND_STORAGE_CLEAR`. The EIP-3529 +refund cap (`gas_used // 5`) does not bind at Cancun but does at +Amsterdam, so the shift is modeled from the fork gas model: reconstruct +the cap-bounded `gas_used` from the fork-invariant non-SSTORE gross gas +plus the fork SSTORE charge minus the capped refund, and subtract the +same expression evaluated with the pre-repricing Cancun charges (so the +adjustment is exactly 0 pre-EIP-8037). Do not hardcode the Amsterdam +value. """ import pytest @@ -27,7 +27,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -94,10 +94,13 @@ def test_refund_sstore( return_cost_deducted_prior_execution=True, ) base_gross = intrinsic + 2 * gas_costs.VERY_LOW - # Cancun's intrinsic for this tx shape was 21_004 (TX_BASE + - # single zero-byte). Capture it as the baseline so the Cancun - # branch of ``gas_used_delta`` evaluates at the original base. - cancun_base_gross = 21_004 + 2 * gas_costs.VERY_LOW + # Cancun's intrinsic for this tx shape is the baseline, so the + # Cancun branch of ``gas_used_delta`` evaluates at the original base. + cancun_intrinsic = Cancun.transaction_intrinsic_cost_calculator()( + calldata=tx.data, + return_cost_deducted_prior_execution=True, + ) + cancun_base_gross = cancun_intrinsic + 2 * Cancun.gas_costs().VERY_LOW def clear_gas_used( sstore_charge: int, clear_refund: int, gross_base: int @@ -105,15 +108,19 @@ def clear_gas_used( gross = gross_base + sstore_charge return gross - min(clear_refund, gross // 5) - sstore_charge = Op.SSTORE.with_metadata( + sstore_clear = Op.SSTORE.with_metadata( key_warm=False, original_value=24743, current_value=24743, new_value=0 - ).gas_cost(fork) - # Cancun charges 5000 for the clear and refunds 4800; subtracting the - # same model evaluated at those constants and the Cancun base makes - # this exactly 0 before the EIP-8037/8038 repricing. + ) + sstore_charge = sstore_clear.gas_cost(fork) + # Subtracting the same model evaluated at Cancun's charge, refund and + # base makes this exactly 0 before the EIP-8037/8038 repricing. gas_used_delta = clear_gas_used( sstore_charge, gas_costs.REFUND_STORAGE_CLEAR, base_gross - ) - clear_gas_used(5000, 4800, cancun_base_gross) + ) - clear_gas_used( + sstore_clear.gas_cost(Cancun), + Cancun.gas_costs().REFUND_STORAGE_CLEAR, + cancun_base_gross, + ) post = {sender: Account(balance=0xE8D4EE4E00 - 1000 * gas_used_delta)} diff --git a/tests/ported_static/stSpecialTest/test_eoa_empty_paris.py b/tests/ported_static/stSpecialTest/test_eoa_empty_paris.py index 5f1ab7ba4a3..33cb2a06541 100644 --- a/tests/ported_static/stSpecialTest/test_eoa_empty_paris.py +++ b/tests/ported_static/stSpecialTest/test_eoa_empty_paris.py @@ -6,11 +6,11 @@ @manually-enhanced: Do not overwrite. Two measured slots shift under EIP-8038. Slot 0xF1 times a CALL that forwards `value` to the (warm) -origin EOA: when `value` is nonzero it gains the value-transfer -reprice `CALL_VALUE - 9000`; the value-0 cases are unchanged. Slot -0xFF times a value-0 CALL to a cold contract and gains the cold -account reprice `COLD_ACCOUNT_ACCESS - 2600`. Both deltas come from -the fork's own gas model, so each is exactly 0 before EIP-8038. +origin EOA: when `value` is nonzero it gains the `CALL_VALUE` raise. +The value-0 cases are unchanged. Slot 0xFF times a value-0 CALL to a +cold contract and gains the `COLD_ACCOUNT_ACCESS` raise. Both deltas +come from the fork's own gas model, so each is exactly 0 before +EIP-8038. """ import pytest @@ -26,7 +26,7 @@ Transaction, TransactionException, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op from tests.ported_static.post_state_resolution import ( @@ -111,8 +111,10 @@ def test_eoa_empty_paris( # 0xFF's value-0 CALL to a cold contract gains the cold account # reprice. gas_costs = fork.gas_costs() - call_value_delta = gas_costs.CALL_VALUE - 9000 - cold_account_delta = gas_costs.COLD_ACCOUNT_ACCESS - 2600 + call_value_delta = gas_costs.CALL_VALUE - Cancun.gas_costs().CALL_VALUE + cold_account_delta = ( + gas_costs.COLD_ACCOUNT_ACCESS - Cancun.gas_costs().COLD_ACCOUNT_ACCESS + ) coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) contract_0 = Address(0x000000000000000000000000000000000000BAD1) contract_1 = Address(0x000000000000000000000000000000000000BAD2) diff --git a/tests/ported_static/stTransactionTest/test_contract_store_clears_success.py b/tests/ported_static/stTransactionTest/test_contract_store_clears_success.py index a61f620fb02..cd0d48a44a6 100644 --- a/tests/ported_static/stTransactionTest/test_contract_store_clears_success.py +++ b/tests/ported_static/stTransactionTest/test_contract_store_clears_success.py @@ -7,9 +7,9 @@ @manually-enhanced: Do not overwrite. The contract clears 10 cold storage slots (each 12 -> 0) and the transaction sends value alongside, so the asserted post is the cleared storage plus the received value. -EIP-8038 raises the cold SSTORE-clear charge from 5000 to 13000, so the -10 clears no longer fit in the original gas limit and the contract runs -out of gas before clearing the storage or keeping the transfer. Bump the +EIP-8038 raises the cold SSTORE-clear charge, so the 10 clears no +longer fit in the original gas limit and the contract runs out of gas +before clearing the storage or keeping the transfer. Bump the gas limit by the per-clear charge delta times the 10 clears so every clear still lands at Amsterdam. The delta is derived from the fork gas model and is exactly 0 pre-EIP-8037; do not hardcode the Amsterdam @@ -27,7 +27,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -89,12 +89,10 @@ def test_contract_store_clears_success( # EIP-8038 raises the cold SSTORE-clear charge; bump the gas limit by # the per-clear charge delta times the 10 clears so all of them still # land instead of running out of gas before clearing the storage. - cold_clear_delta = ( - Op.SSTORE.with_metadata( - key_warm=False, original_value=1, current_value=1, new_value=0 - ).gas_cost(fork) - - 5000 + cold_clear = Op.SSTORE.with_metadata( + key_warm=False, original_value=1, current_value=1, new_value=0 ) + cold_clear_delta = cold_clear.gas_cost(fork) - cold_clear.gas_cost(Cancun) tx = Transaction( sender=sender, diff --git a/tests/ported_static/stTransactionTest/test_internal_call_store_clears_success.py b/tests/ported_static/stTransactionTest/test_internal_call_store_clears_success.py index e699a350a8d..3e5cdd21703 100644 --- a/tests/ported_static/stTransactionTest/test_internal_call_store_clears_success.py +++ b/tests/ported_static/stTransactionTest/test_internal_call_store_clears_success.py @@ -6,17 +6,16 @@ @manually-enhanced: Do not overwrite. The `target` contract forwards a fixed `CALL` gas budget (0x186A0) to `addr`, which clears 10 cold -storage slots (12 -> 0). EIP-8038 raises the cold SSTORE-clear charge -from 5000 to 13000, so the 10 clears jump from 50000 to 130000 gas and -no longer fit in the forwarded budget or the transaction gas limit: -`addr` runs out of gas, its slots stay set, and the inner value -transfer rolls back, defeating the "store clears success" intent. Both -the inner `CALL` gas argument and the transaction gas limit are raised -by `10 * cold_clear_delta` so all 10 clears still succeed. The per-clear -delta is derived from the fork gas model and is exactly 0 pre-EIP-8037; -do not hardcode the Amsterdam values. The asserted balances are -fork-invariant once the clears land, and the post does not assert the -sender balance, so no balance adjustment is needed. +storage slots (12 -> 0). EIP-8038 raises the cold SSTORE-clear charge, +so the 10 clears no longer fit in the forwarded budget or the +transaction gas limit: `addr` runs out of gas, its slots stay set, and +the inner value transfer rolls back, defeating the "store clears +success" intent. Both the inner `CALL` gas argument and the transaction +gas limit are raised by `10 * cold_clear_delta` so all 10 clears still +succeed. The per-clear delta is derived from the fork gas model and is +exactly 0 pre-EIP-8037; do not hardcode the Amsterdam values. The +asserted balances are fork-invariant once the clears land, and the post +does not assert the sender balance, so no balance adjustment is needed. """ import pytest @@ -29,7 +28,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -65,12 +64,10 @@ def test_internal_call_store_clears_success( # CALL gas and the transaction gas limit by the per-clear delta times # the 10 clears so every clear still lands instead of running out of # gas. The delta is 0 before the EIP-8037/8038 repricing. - cold_clear_delta = ( - Op.SSTORE.with_metadata( - key_warm=False, original_value=1, current_value=1, new_value=0 - ).gas_cost(fork) - - 5000 + cold_clear = Op.SSTORE.with_metadata( + key_warm=False, original_value=1, current_value=1, new_value=0 ) + cold_clear_delta = cold_clear.gas_cost(fork) - cold_clear.gas_cost(Cancun) clears_gas_bump = 10 * cold_clear_delta # Source: lll diff --git a/tests/ported_static/stTransactionTest/test_store_clears_and_internal_call_store_clears_success.py b/tests/ported_static/stTransactionTest/test_store_clears_and_internal_call_store_clears_success.py index 163c044ce66..f283fc03b3f 100644 --- a/tests/ported_static/stTransactionTest/test_store_clears_and_internal_call_store_clears_success.py +++ b/tests/ported_static/stTransactionTest/test_store_clears_and_internal_call_store_clears_success.py @@ -7,15 +7,14 @@ @manually-enhanced: Do not overwrite. The outer contract `target` clears 4 cold storage slots then `CALL`s the inner contract `addr`, which clears 10 cold storage slots; the value transfer and clears must all succeed. -EIP-8037/8038 raise the cold SSTORE-clear charge from 5000 to 13000 at -Amsterdam, so both gas budgets must rise by that charge delta or the inner -frame runs out of gas (clearing only 4 of its 10 slots) and the value -transfer rolls back. The inner `CALL` only forwards a fixed gas amount, so -its budget is bumped by the 10 inner clears; the transaction gas limit is -bumped by all 14 clears (10 inner plus 4 outer) so the outer frame can both -pay its own clears and forward the larger amount. Both bumps are derived -from the fork gas model and are exactly 0 pre-EIP-8037; do not hardcode the -Amsterdam values. +EIP-8037/8038 raise the cold SSTORE-clear charge at Amsterdam, so both gas +budgets must rise by that charge delta or the inner frame runs out of gas +(clearing only 4 of its 10 slots) and the value transfer rolls back. The +inner `CALL` only forwards a fixed gas amount, so its budget is bumped by +the 10 inner clears; the transaction gas limit is bumped by all 14 clears +(10 inner plus 4 outer) so the outer frame can both pay its own clears and +forward the larger amount. Both bumps are derived from the fork gas model +and are exactly 0 pre-EIP-8037; do not hardcode the Amsterdam values. """ import pytest @@ -28,7 +27,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" @@ -53,10 +52,10 @@ def test_store_clears_and_internal_call_store_clears_success( # EIP-8037/8038 raise the cold SSTORE-clear charge; derive the per-clear # delta (0 pre-EIP-8037) so both gas budgets keep every clear landing. - sstore_charge = Op.SSTORE.with_metadata( + cold_clear = Op.SSTORE.with_metadata( key_warm=False, original_value=1, current_value=1, new_value=0 - ).gas_cost(fork) - cold_clear_delta = sstore_charge - 5000 + ) + cold_clear_delta = cold_clear.gas_cost(fork) - cold_clear.gas_cost(Cancun) env = Environment( fee_recipient=coinbase, diff --git a/tests/ported_static/vmTests/test_suicide.py b/tests/ported_static/vmTests/test_suicide.py index 68610592631..753a1d4b92e 100644 --- a/tests/ported_static/vmTests/test_suicide.py +++ b/tests/ported_static/vmTests/test_suicide.py @@ -8,13 +8,13 @@ asserts the sender balance, which equals its start minus `gas_used * gas_price`. The transaction calls a contract that CALLs a cold, existing account (slot 0x1000) before it self-destructs; EIP-8038 -raises the cold account-access surcharge on that CALL from 2600 to 3000. -The SELFDESTRUCT itself is to a warm, non-empty beneficiary (the caller), -so its charge is unchanged, and there is no refund. Derive the -account-access delta from the fork gas model (0 pre-EIP-8037) and -subtract `gas_price * delta` from the Cancun balance; do not hardcode the -Amsterdam value. The `random` and `myself` cases assert only -non-gas-dependent balances and need no adjustment. +raises `COLD_ACCOUNT_ACCESS` on that CALL. The SELFDESTRUCT itself is to +a warm, non-empty beneficiary (the caller), so its charge is unchanged, +and there is no refund. Derive the account-access delta from the fork +gas model (0 pre-EIP-8037) and subtract `gas_price * delta` from the +Cancun balance; do not hardcode the Amsterdam value. The `random` and +`myself` cases assert only non-gas-dependent balances and need no +adjustment. """ import pytest @@ -29,7 +29,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Fork +from execution_testing.forks import Cancun, Fork from execution_testing.vm import Op from tests.ported_static.post_state_resolution import ( @@ -148,12 +148,18 @@ def test_suicide( # The CALL into the self-destructing contract touches a cold, already # existing account; EIP-8038 raises that cold account-access surcharge - # from 2600 to 3000. The SELFDESTRUCT beneficiary is warm and + # (`COLD_ACCOUNT_ACCESS`). The SELFDESTRUCT beneficiary is warm and # non-empty, so its charge is unchanged. EIP-2780 separately reshapes # the tx intrinsic for this non-self non-value call. The sender pays # the combined delta at the base fee (no priority fee). - cold_account_access_delta = fork.gas_costs().COLD_ACCOUNT_ACCESS - 2600 - intrinsic_delta = fork.transaction_intrinsic_cost_calculator()() - 21_000 + cold_account_access_delta = ( + fork.gas_costs().COLD_ACCOUNT_ACCESS + - Cancun.gas_costs().COLD_ACCOUNT_ACCESS + ) + intrinsic_delta = ( + fork.transaction_intrinsic_cost_calculator()() + - Cancun.transaction_intrinsic_cost_calculator()() + ) caller_balance = ( 0x5AF31075D9DE - 10 * cold_account_access_delta - 10 * intrinsic_delta ) From f085d2cb3448c51a9c2aa24feb2f277b6bff94bb Mon Sep 17 00:00:00 2001 From: spencer Date: Wed, 23 Sep 2026 11:33:39 +0200 Subject: [PATCH 09/28] fix(tests): un-skip remaining Amsterdam ported static tests and drop the skip list (Pt. 2d) (#3322) Co-authored-by: marioevz --- .agents/skills/enhance-ported-test/SKILL.md | 55 +- tests/ported_static/amsterdam_skip_list.txt | 96 -- tests/ported_static/conftest.py | 69 - ...test_create2_oo_gafter_init_code_revert.py | 211 ++- ...est_create2_oo_gafter_init_code_revert2.py | 98 -- .../test_create2_oog_from_call_refunds.py | 1428 ++++------------- ...est_create2collision_selfdestructed_oog.py | 258 +-- .../stCreate2/test_create2no_cash.py | 190 +-- ...st_create_message_reverted_oog_in_init2.py | 196 ++- .../test_revert_depth_create2_oog.py | 352 ++-- .../test_revert_depth_create2_oog_berlin.py | 200 --- ...t_revert_depth_create_address_collision.py | 395 +++-- ...t_depth_create_address_collision_berlin.py | 224 --- .../stRefundTest/test_refund50_2.py | 92 +- .../stRefundTest/test_refund50percent_cap.py | 117 +- .../stRefundTest/test_refund_call_a.py | 107 +- .../test_refund_suicide50procent_cap.py | 258 +-- .../stRefundTest/test_refund_tx_to_suicide.py | 90 +- .../test_loop_calls_depth_then_revert.py | 102 +- ...t_loop_delegate_calls_depth_then_revert.py | 100 +- ...t_revert_depth_create_address_collision.py | 335 ++-- .../test_revert_depth_create_oog.py | 293 ++-- ...pcode_in_calls_on_non_empty_return_data.py | 521 ++---- 23 files changed, 2102 insertions(+), 3685 deletions(-) delete mode 100644 tests/ported_static/amsterdam_skip_list.txt delete mode 100644 tests/ported_static/conftest.py delete mode 100644 tests/ported_static/stCreate2/test_create2_oo_gafter_init_code_revert2.py delete mode 100644 tests/ported_static/stCreate2/test_revert_depth_create2_oog_berlin.py delete mode 100644 tests/ported_static/stCreate2/test_revert_depth_create_address_collision_berlin.py diff --git a/.agents/skills/enhance-ported-test/SKILL.md b/.agents/skills/enhance-ported-test/SKILL.md index 1c02d262328..59a402d55e4 100644 --- a/.agents/skills/enhance-ported-test/SKILL.md +++ b/.agents/skills/enhance-ported-test/SKILL.md @@ -331,6 +331,14 @@ verifies anything. Improve coupling and observability: init code of step 0 is the same family, one level down: there the *bytecode* stopped matching the scenario its own Yul comment describes. +**Engine X packing can introduce an intentional collision into another test.** +Two tests that deploy identical creator bytecode derive the same CREATE address. +If one predeploys a collider there and the other requires it absent, their +pre-allocations must not share a packed genesis. Give the collision test a named +`@pytest.mark.pre_alloc_group(...)`, then fill both with +`--generate-all-formats` and verify Engine X matches its ordinary engine sibling. +Validated on the `stRevertTest` CREATE collision and CREATE-OOG pair. + ### 9. Introduce variables that encode relationships Whenever a literal carries intent or two literals are logically linked, lift them into named variables that express the *relationship*, not just the value. E.g. @@ -539,10 +547,17 @@ 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, -and under EIP-8037 the new-account state charge is refunded. Useful to -build always-failing creator frames with predictable consumption. -Validated on `test_revert_depth_create_address_collision`. +EIP-684 path): the withheld child grant is consumed and nothing is +created. Under EIP-8037 a colliding target is alive (it has code or a +nonce), so no new-account state gas is charged there in the first place. +**The burn is only observable if some arm lets the creator survive it**: +size the creator's post-collision slack so that its 1/64 retention cannot +pay the next store but the whole slack could (starved arm), or so the +retention clears the EIP-2200 gate and pays it (covered arm). A creator +that dies in every arm rolls the burn back with itself and the test +passes even when the grant is returned. Validated on both +`test_revert_depth_create_address_collision` files (mutation: returning +the grant on collision flips the starved arms). **Loop-to-depth-1024 cannot replace loop-to-OOG.** With 63/64 attenuation, reaching depth 1024 needs ~e^16 × the terminal gas — no @@ -551,12 +566,36 @@ fixed named budget with per-gas-schedule-era pinned depth counts, each shift explained (±1 frame ≈ 64·ln(cost ratio)). Validated on `test_loop_calls_depth_then_revert`. -**Framework wart: the SSTORE dirty-rewrite composite prices 100 on every -fork**, but Constantinople/Petersburg charge 5,000 for a dirty re-store — -a derived budget that must survive pre-Istanbul forks needs an explicit -headroom constant for it (named, commented). Observed on +**The SSTORE dirty-rewrite composite tracks the pre-Berlin schedules.** +`Op.SSTORE(key_warm=True, original_value=0, current_value=0xFF, +new_value=1).gas_cost(fork)` prices 5,006 on ConstantinopleFix, 806 on +Istanbul and 106 from Berlin, so a derived budget needs no extra +headroom constant for those forks (an earlier note here claimed +otherwise; the padding it prescribed also masked a wrongful new-account +charge on Amsterdam). Checked on `test_revert_depth_create_address_collision`'s ConstantinopleFix sweep. +**A starved arm must still reach the opcode under test.** When a +"RevertDepth" style filler proves that a completed nested CREATE is +rolled back by a later OOG, the starved grant has to cover the CREATE +(and whatever stores precede it) and fall short only on the frame's +last store. A grant of "half the creator's needs" dies at the CREATE's +own charge, so the arm's `NONEXISTENT`/`nonce=1` expectations hold +without anything having been created. Prove the arm reaches the opcode +with a throwaway `raise` at the top of the fork's `generic_create` (or +`generic_call`): every arm must hit it. Validated on +`test_revert_depth_create2_oog` and `test_revert_depth_create_oog`. + +**A "must OOG" budget has to fund the counterfactual.** A test whose +observable is "the frame ran out of gas because of X" (a collision burn, +a failed sub-call) only pins X if the same budget would have *completed* +without X. Size the slack as `victims + what X would have cost had it +gone through + margin`, then guard that X's leavings (the 1/64 +retention) cannot pay the victims. Check it by disabling X in a spec +copy (e.g. `account_deployable` returning True): the test must fail. +Validated on `test_create2collision_selfdestructed_oog`, whose 30k +slack had been below its victims on every fork. + **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 diff --git a/tests/ported_static/amsterdam_skip_list.txt b/tests/ported_static/amsterdam_skip_list.txt deleted file mode 100644 index 2aa957df599..00000000000 --- a/tests/ported_static/amsterdam_skip_list.txt +++ /dev/null @@ -1,96 +0,0 @@ -# Amsterdam ported static skip list. -# -# Test cases in this list are temporarily skipped for the Amsterdam -# fork due to EIP-8037's two-dimensional gas model. Gas limits in the -# underlying ported static tests have not yet been updated to account -# for state gas. -# -# Entries are substring-matched against each pytest nodeid (after -# stripping the fixture-format suffix in conftest.py). -# -# Total entries: 86 - -# stAttackTest (0) - -# stBadOpcode (0) - -# stCallCodes (0) - -# stCallCreateCallCodeTest (0) - -# stCallDelegateCodesCallCodeHomestead (0) - -# stCreate2 (31) -stCreate2/test_create2_oo_gafter_init_code_revert2.py::test_create2_oo_gafter_init_code_revert2[fork_Amsterdam] -stCreate2/test_create2_oog_from_call_refunds.py::test_create2_oog_from_call_refunds[fork_Amsterdam-SStore_CallCode_Refund_NoOoG] -stCreate2/test_create2_oog_from_call_refunds.py::test_create2_oog_from_call_refunds[fork_Amsterdam-SStore_Create2_Refund_NoOoG] -stCreate2/test_create2_oog_from_call_refunds.py::test_create2_oog_from_call_refunds[fork_Amsterdam-SStore_Create_Refund_NoOoG] -stCreate2/test_create2_oog_from_call_refunds.py::test_create2_oog_from_call_refunds[fork_Amsterdam-SStore_DelegateCall_Refund_NoOoG] -stCreate2/test_create2collision_selfdestructed_oog.py::test_create2collision_selfdestructed_oog[fork_Amsterdam-d0] -stCreate2/test_create2collision_selfdestructed_oog.py::test_create2collision_selfdestructed_oog[fork_Amsterdam-d1] -stCreate2/test_create2collision_selfdestructed_oog.py::test_create2collision_selfdestructed_oog[fork_Amsterdam-d2] -stCreate2/test_create2no_cash.py::test_create2no_cash[fork_Amsterdam-d1] -stCreate2/test_create_message_reverted_oog_in_init2.py::test_create_message_reverted_oog_in_init2[fork_Amsterdam--g0] -stCreate2/test_create_message_reverted_oog_in_init2.py::test_create_message_reverted_oog_in_init2[fork_Amsterdam--g1] -stCreate2/test_revert_depth_create2_oog.py::test_revert_depth_create2_oog[fork_Amsterdam-d0-g1-v0] -stCreate2/test_revert_depth_create2_oog.py::test_revert_depth_create2_oog[fork_Amsterdam-d0-g1-v1] -stCreate2/test_revert_depth_create2_oog.py::test_revert_depth_create2_oog[fork_Amsterdam-d1-g1-v0] -stCreate2/test_revert_depth_create2_oog.py::test_revert_depth_create2_oog[fork_Amsterdam-d1-g1-v1] -stCreate2/test_revert_depth_create2_oog_berlin.py::test_revert_depth_create2_oog_berlin[fork_Amsterdam-d0-g1-v0] -stCreate2/test_revert_depth_create2_oog_berlin.py::test_revert_depth_create2_oog_berlin[fork_Amsterdam-d0-g1-v1] -stCreate2/test_revert_depth_create2_oog_berlin.py::test_revert_depth_create2_oog_berlin[fork_Amsterdam-d1-g1-v0] -stCreate2/test_revert_depth_create2_oog_berlin.py::test_revert_depth_create2_oog_berlin[fork_Amsterdam-d1-g1-v1] -stCreate2/test_revert_depth_create_address_collision.py::test_revert_depth_create_address_collision[fork_Amsterdam-d0-g0-v0] -stCreate2/test_revert_depth_create_address_collision.py::test_revert_depth_create_address_collision[fork_Amsterdam-d0-g0-v1] -stCreate2/test_revert_depth_create_address_collision.py::test_revert_depth_create_address_collision[fork_Amsterdam-d1-g0-v0] -stCreate2/test_revert_depth_create_address_collision.py::test_revert_depth_create_address_collision[fork_Amsterdam-d1-g0-v1] -stCreate2/test_revert_depth_create_address_collision.py::test_revert_depth_create_address_collision[fork_Amsterdam-d1-g1-v0] -stCreate2/test_revert_depth_create_address_collision.py::test_revert_depth_create_address_collision[fork_Amsterdam-d1-g1-v1] -stCreate2/test_revert_depth_create_address_collision_berlin.py::test_revert_depth_create_address_collision_berlin[fork_Amsterdam-d0-g0-v0] -stCreate2/test_revert_depth_create_address_collision_berlin.py::test_revert_depth_create_address_collision_berlin[fork_Amsterdam-d0-g0-v1] -stCreate2/test_revert_depth_create_address_collision_berlin.py::test_revert_depth_create_address_collision_berlin[fork_Amsterdam-d1-g0-v0] -stCreate2/test_revert_depth_create_address_collision_berlin.py::test_revert_depth_create_address_collision_berlin[fork_Amsterdam-d1-g0-v1] -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] - -# stDelegatecallTestHomestead (0) - -# stEIP150singleCodeGasPrices (0) - -# stEIP158Specific (0) - -# stHomesteadSpecific (0) - -# stInitCodeTest (0) - -# stMemoryTest (0) - -# stRefundTest (6) -stRefundTest/test_refund50_2.py::test_refund50_2[fork_Amsterdam] -stRefundTest/test_refund50percent_cap.py::test_refund50percent_cap[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] -stRefundTest/test_refund_tx_to_suicide.py::test_refund_tx_to_suicide[fork_Amsterdam] - -# stRevertTest (12) -stRevertTest/test_loop_calls_depth_then_revert.py::test_loop_calls_depth_then_revert[fork_Amsterdam] -stRevertTest/test_loop_delegate_calls_depth_then_revert.py::test_loop_delegate_calls_depth_then_revert[fork_Amsterdam] -stRevertTest/test_revert_depth_create_address_collision.py::test_revert_depth_create_address_collision[fork_Amsterdam-d0-g1-v0] -stRevertTest/test_revert_depth_create_address_collision.py::test_revert_depth_create_address_collision[fork_Amsterdam-d0-g1-v1] -stRevertTest/test_revert_depth_create_oog.py::test_revert_depth_create_oog[fork_Amsterdam-d0-g1-v0] -stRevertTest/test_revert_depth_create_oog.py::test_revert_depth_create_oog[fork_Amsterdam-d0-g1-v1] -stRevertTest/test_revert_depth_create_oog.py::test_revert_depth_create_oog[fork_Amsterdam-d1-g1-v0] -stRevertTest/test_revert_depth_create_oog.py::test_revert_depth_create_oog[fork_Amsterdam-d1-g1-v1] -stRevertTest/test_revert_opcode_in_calls_on_non_empty_return_data.py::test_revert_opcode_in_calls_on_non_empty_return_data[fork_Amsterdam-d0-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-d1-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-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 (0) - -# stSolidityTest (0) - -# stStaticCall (0) - -# stTransactionTest (0) diff --git a/tests/ported_static/conftest.py b/tests/ported_static/conftest.py deleted file mode 100644 index ad1933053d7..00000000000 --- a/tests/ported_static/conftest.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -Conftest for ported static tests. - -Temporarily skip ported static tests that fail on Amsterdam and its -descendant forks due to EIP-8037's two-dimensional gas model. The gas -limits in these ported static test cases have not yet been updated to -account for state gas. - -TODO: Update gas limits in the 3452 failing ported static test cases and -remove this skip list. -""" - -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" -_AMSTERDAM_SKIP_CASES: frozenset[str] = frozenset( - line.strip() - for line in _SKIP_LIST_PATH.read_text().splitlines() - if line.strip() and not line.lstrip().startswith("#") -) - - -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, tokens: tuple[str, ...]) -> str: - """Strip pytest fixture-format suffixes to match the skip list format.""" - for token in tokens: - nodeid = nodeid.replace(token, "") - return nodeid - - -def pytest_collection_modifyitems( - config: pytest.Config, items: list[pytest.Item] -) -> None: - """Skip ported static test cases listed in amsterdam_skip_list.txt.""" - 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 - callspec = getattr(item, "callspec", None) - fork = callspec.params.get("fork") if callspec else None - if fork is None or not fork >= Amsterdam: - continue - # The skip list is written against fork_Amsterdam, but the - # 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, tokens).replace( - f"fork_{fork.name()}", "fork_Amsterdam" - ) - for skip_case in _AMSTERDAM_SKIP_CASES: - if skip_case in normalized: - item.add_marker(skip_marker) - break diff --git a/tests/ported_static/stCreate2/test_create2_oo_gafter_init_code_revert.py b/tests/ported_static/stCreate2/test_create2_oo_gafter_init_code_revert.py index 4784c88b0de..5dc3925df9a 100644 --- a/tests/ported_static/stCreate2/test_create2_oo_gafter_init_code_revert.py +++ b/tests/ported_static/stCreate2/test_create2_oo_gafter_init_code_revert.py @@ -1,101 +1,190 @@ """ -Calls a contract that runs CREATE2 which deploy a code. then after... +Verify CREATE2 code-deposit success and failure inside a reverting frame. +The revert payload exposes the CREATE2 result while all creation effects +are rolled back. Ported from: state_tests/stCreate2/Create2OOGafterInitCodeRevertFiller.json +state_tests/stCreate2/Create2OOGafterInitCodeRevert2Filler.json + +@manually-enhanced: Do not overwrite. Joins the Revert and Revert2 +fillers: they are one scenario differing only in whether the child can +afford its code deposit, so the grant is parametrized. The ported Revert +case stored only the payload's first word, which is identical in both +arms, so it could not tell a successful CREATE2 from a failed one. +The forwarded grant is derived from +fork composites so the child fails exactly at the deposit charge on every +fork. The revert payload now also carries the CREATE2 result, and the +caller stores the call result plus both payload words. """ import pytest from execution_testing import ( - EOA, Account, - Address, Alloc, - Bytes, - Environment, Fork, + Op, StateTestFiller, Transaction, - compute_create_address, + compute_create2_address, ) -from execution_testing.forks import Amsterdam -from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +PAYLOAD_SLOT = 0x1 +CREATE2_RESULT_SLOT = 0x2 +CALL_RESULT_SLOT = 0x3 + +# The init code returns this many memory bytes as the code to deposit. +# The grant is sized so this charge is exactly what the child cannot pay. +DEPOSIT_SIZE = 0x40 + @pytest.mark.ported_from( - ["state_tests/stCreate2/Create2OOGafterInitCodeRevertFiller.json"], + [ + "state_tests/stCreate2/Create2OOGafterInitCodeRevertFiller.json", + "state_tests/stCreate2/Create2OOGafterInitCodeRevert2Filler.json", + ], +) +@pytest.mark.valid_from("Constantinople") +@pytest.mark.parametrize( + "deposit_succeeds", + [ + pytest.param(True, id="deposit_paid"), + pytest.param(False, id="deposit_unaffordable"), + ], ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable def test_create2_oo_gafter_init_code_revert( state_test: StateTestFiller, - fork: Fork, pre: Alloc, + fork: Fork, + deposit_succeeds: bool, ) -> None: - """Calls a contract that runs CREATE2 which deploy a code.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0xC94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) - contract_1 = Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) - sender = EOA( - key=0x45A915E4D060149EB4365960E6A7A45F334393093061116B197E3240065FF2D8 + """A CREATE2 inside a reverting frame, with and without deposit gas.""" + # The child's init code is a bare deposit request. Its metadata + # carries the deposit charge, so `gas_cost` is what the child needs + # to both run and deposit. + initcode = Op.RETURN( + offset=0x0, + size=DEPOSIT_SIZE, + new_memory_size=DEPOSIT_SIZE, + code_deposit_size=DEPOSIT_SIZE, + ) + # The same RETURN priced without the deposit: what the child must + # afford to reach the deposit charge at all. + initcode_run_only = Op.RETURN( + offset=0x0, + size=DEPOSIT_SIZE, + new_memory_size=DEPOSIT_SIZE, + ) + assert len(initcode) <= 0x20, "init code must fit one word" + + # The creator writes the init code into memory (right-aligned in the + # first word), runs CREATE2, appends the CREATE2 result to memory and + # reverts both words back to the caller. + creator_setup = Op.MSTORE( + offset=0x0, + value=int.from_bytes(initcode, "big"), + new_memory_size=0x20, + ) + create2_code = Op.CREATE2( + value=0x0, + offset=0x20 - len(initcode), + size=len(initcode), + salt=0x0, + new_memory_size=0x20, + old_memory_size=0x20, + init_code_size=len(initcode), + ) + creator_tail = ( + Op.PUSH1[0x20] + + Op.MSTORE( + new_memory_size=0x40, + old_memory_size=0x20, + ) + + Op.REVERT(offset=0x0, size=0x40) + ) + creator = pre.deploy_contract( + code=creator_setup + create2_code + creator_tail ) - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=10000000, + # The two arms differ by a single gas: enough to pay the deposit, + # or one short of it. + initcode_cost = initcode.gas_cost(fork) + target_grant = initcode_cost - (0 if deposit_succeeds else 1) + # What the creating frame still holds at the moment CREATE2 runs: + # the child receives 63/64 of it and the creator keeps the rest, so + # the pool has to be a 64th larger than the grant. Round down, since + # one gas more would let the child afford the deposit after all. + gas_at_create2 = target_grant * 64 // 63 + forwarded = ( + creator_setup.gas_cost(fork) + + create2_code.gas_cost(fork) + + gas_at_create2 ) - pre[sender] = Account(balance=0xE8D4A51000) - # Source: lll - # { (MSTORE 0 0x6460016001556000526005601bf3) (CREATE2 0 18 14 0) (REVERT 0 32) } # noqa: E501 - contract_1 = pre.deploy_contract( # noqa: F841 - code=Op.MSTORE(offset=0x0, value=0x6460016001556000526005601BF3) - + Op.POP(Op.CREATE2(value=0x0, offset=0x12, size=0xE, salt=0x0)) - + Op.REVERT(offset=0x0, size=0x20) - + Op.STOP, - nonce=0, - address=Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B), # noqa: E501 + granted = gas_at_create2 - gas_at_create2 // 64 + assert granted >= target_grant, "the child must receive its grant" + if deposit_succeeds: + assert granted >= initcode_cost, "the child must afford the deposit" + else: + # The child must reach the deposit and fail on it alone, not + # earlier in its init code. + assert initcode_run_only.gas_cost(fork) <= granted < initcode_cost, ( + "the child must die at the deposit charge, not before it" + ) + assert gas_at_create2 // 64 >= creator_tail.gas_cost(fork), ( + "the creator's retention must cover its tail" ) - # Source: lll - # { (CALL (GAS) 0xb94f5374fce5edbc8e2a8697c15331677e6ebf0b 0 0 0 0 32) [[ 1 ]] (MLOAD 0) } # noqa: E501 - contract_0 = pre.deploy_contract( # noqa: F841 - code=Op.POP( - Op.CALL( - gas=Op.GAS, - address=contract_1, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x20, - ) + + # The caller forwards the sized grant, then stores the call result + # and both words of the revert payload. The two results are stored + # as `1 + result`, so a zero result is distinguishable from a store + # that never happened. + call_code = Op.CALL(gas=forwarded, address=creator, ret_size=0x40) + caller = pre.deploy_contract( + code=Op.SSTORE(key=CALL_RESULT_SLOT, value=Op.ADD(0x1, call_code)) + + Op.SSTORE(key=PAYLOAD_SLOT, value=Op.MLOAD(offset=0x0)) + + Op.SSTORE( + key=CREATE2_RESULT_SLOT, + value=Op.ADD(0x1, Op.MLOAD(offset=0x20)), ) - + Op.SSTORE(key=0x1, value=Op.MLOAD(offset=0x0)) + Op.STOP, - storage={1: 1}, - nonce=0, - address=Address(0xC94F5374FCE5EDBC8E2A8697C15331677E6EBF0B), # noqa: E501 + storage={PAYLOAD_SLOT: 0x1}, ) tx = Transaction( - sender=sender, - to=contract_0, - data=Bytes(""), - gas_limit=2075000 if fork >= Amsterdam else 75000, + sender=pre.fund_eoa(), + to=caller, + state_gas_reservoir=0, ) + child_address = compute_create2_address(creator, 0, initcode) post = { - contract_0: Account(storage={1: 0x6460016001556000526005601BF3}), - compute_create_address( - address=contract_1, nonce=0 - ): Account.NONEXISTENT, + caller: Account( + storage={ + # The creator reverted, so the call returned 0. + CALL_RESULT_SLOT: 0x1, + # First payload word: the init code the creator staged. + PAYLOAD_SLOT: int.from_bytes(initcode, "big"), + # Second payload word: the CREATE2 result, which is + # the child's address when the deposit was paid and 0 + # when it was not. This is the only observable that + # separates the two arms, since the revert rolls the + # deposit back either way. + CREATE2_RESULT_SLOT: 0x1 + + ( + int.from_bytes(bytes(child_address), "big") + if deposit_succeeds + else 0x0 + ), + } + ), + # Everything inside the creator was rolled back. + creator: Account(nonce=1, storage={}), + # The creator reverted, so the deposit never survives. + child_address: 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/stCreate2/test_create2_oo_gafter_init_code_revert2.py b/tests/ported_static/stCreate2/test_create2_oo_gafter_init_code_revert2.py deleted file mode 100644 index 2b48b4cb0bd..00000000000 --- a/tests/ported_static/stCreate2/test_create2_oo_gafter_init_code_revert2.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Calls a contract that runs CREATE2 which deploy a code. then after... - -Ported from: -state_tests/stCreate2/Create2OOGafterInitCodeRevert2Filler.json -""" - -import pytest -from execution_testing import ( - EOA, - Account, - Address, - Alloc, - Bytes, - Environment, - StateTestFiller, - Transaction, - compute_create_address, -) -from execution_testing.vm import Op - -REFERENCE_SPEC_GIT_PATH = "N/A" -REFERENCE_SPEC_VERSION = "N/A" - - -@pytest.mark.ported_from( - ["state_tests/stCreate2/Create2OOGafterInitCodeRevert2Filler.json"], -) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable -def test_create2_oo_gafter_init_code_revert2( - state_test: StateTestFiller, - pre: Alloc, -) -> None: - """Calls a contract that runs CREATE2 which deploy a code.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0xC94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) - contract_1 = Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) - sender = EOA( - key=0x45A915E4D060149EB4365960E6A7A45F334393093061116B197E3240065FF2D8 - ) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=10000000, - ) - - pre[sender] = Account(balance=0xE8D4A51000) - # Source: lll - # { (MSTORE 0 0x6460016001556000526005601bf3) (CREATE2 0 18 14 0) (REVERT 0 32) } # noqa: E501 - contract_1 = pre.deploy_contract( # noqa: F841 - code=Op.MSTORE(offset=0x0, value=0x6460016001556000526005601BF3) - + Op.POP(Op.CREATE2(value=0x0, offset=0x12, size=0xE, salt=0x0)) - + Op.REVERT(offset=0x0, size=0x20) - + Op.STOP, - nonce=0, - address=Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B), # noqa: E501 - ) - # Source: lll - # { (CALL 33000 0xb94f5374fce5edbc8e2a8697c15331677e6ebf0b 0 0 0 0 32) [[ 1 ]] (MLOAD 0) } # noqa: E501 - contract_0 = pre.deploy_contract( # noqa: F841 - code=Op.POP( - Op.CALL( - gas=0x80E8, - address=contract_1, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x20, - ) - ) - + Op.SSTORE(key=0x1, value=Op.MLOAD(offset=0x0)) - + Op.STOP, - storage={1: 1}, - nonce=0, - address=Address(0xC94F5374FCE5EDBC8E2A8697C15331677E6EBF0B), # noqa: E501 - ) - - tx = Transaction( - sender=sender, - to=contract_0, - data=Bytes(""), - gas_limit=75000, - ) - - post = { - contract_0: Account(storage={1: 0x6460016001556000526005601BF3}), - compute_create_address( - address=contract_1, nonce=0 - ): Account.NONEXISTENT, - } - - state_test(env=env, pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stCreate2/test_create2_oog_from_call_refunds.py b/tests/ported_static/stCreate2/test_create2_oog_from_call_refunds.py index 9efd489bbb9..f6c3f510625 100644 --- a/tests/ported_static/stCreate2/test_create2_oog_from_call_refunds.py +++ b/tests/ported_static/stCreate2/test_create2_oog_from_call_refunds.py @@ -1,1203 +1,323 @@ """ -Test_create2_oog_from_call_refunds. +Verify gas refunds earned inside a CREATE2's init code (storage clears, +via direct stores and CALL/CALLCODE/DELEGATECALL helpers, selfdestructs, +and nested creations) against out-of-gas boundaries: each scenario runs +once completing normally and twice dying, on an oversized code deposit +and on an INVALID that pins the refund bookkeeping. Ported from: state_tests/stCreate2/Create2OOGFromCallRefundsFiller.yml + +@manually-enhanced: Do not overwrite. The SSTORE pairs solc had folded +out of five init codes are restored, so the refunds the OoG arms must +discard are actually earned. The transaction budget and the sender's +funding derive from the fork: the ported 400k regular budget plus the +restored sets and the deepest arm's outstanding EIP-8037 state gas, +guarded to stay below the 5000-byte deposit charge that starves the OoG +arms. """ +from enum import Enum, auto + import pytest from execution_testing import ( - EOA, Account, Address, Alloc, - Bytes, - Environment, - Hash, + Bytecode, + Conditional, + Fork, + 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, + compute_create2_address, + compute_create_address, ) REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +GAS_PRICE = 10 +# The ported budget, proven to cover every NoOoG arm's regular gas. +PORTED_GAS_LIMIT = 400_000 +# The OoG arms return this much memory as code. The deposit charge is +# what must exceed the transaction budget so they starve. +OOG_DEPOSIT_SIZE = 0x1388 +# Init codes return from here, past anything any of them writes to +# memory, so the byte a completing arm deposits is always zero. +DEPOSIT_OFFSET = 0x20 + + +class Refund(Enum): + """How an init code earns the refund the arm is about.""" + + DIRECT = auto() + """Clears a slot it set itself.""" + CALL = auto() + """Has a callee clear the callee's own slot.""" + DELEGATECALL = auto() + """Runs the callee's clear in its own storage.""" + CALLCODE = auto() + """As DELEGATECALL, with its own address as the caller.""" + SELFDESTRUCT = auto() + """Calls a contract that destroys itself.""" + LOGS = auto() + """Calls a contract that only emits logs, earning nothing.""" + CREATE = auto() + """Nests a CREATE that clears a slot.""" + CREATE2 = auto() + """Nests a CREATE2 that clears a slot.""" + + +class Outcome(Enum): + """How the init code ends, which decides whether refunds survive.""" + + COMPLETES = auto() + """Deposits one byte and returns normally.""" + OOG_DEPOSIT = auto() + """Requests a deposit the budget cannot pay for.""" + OOG_INVALID = auto() + """Ends on INVALID, burning whatever gas is left.""" + + +NESTED = (Refund.CREATE, Refund.CREATE2) +"""Arms whose init code creates a further contract.""" + @pytest.mark.ported_from( ["state_tests/stCreate2/Create2OOGFromCallRefundsFiller.yml"], ) @pytest.mark.valid_from("Cancun") @pytest.mark.parametrize( - "d, g, v", - [ - pytest.param( - 0, - 0, - 0, - id="SStore_Refund_NoOoG", - ), - pytest.param( - 1, - 0, - 0, - id="SStore_Refund_OoG", - ), - pytest.param( - 2, - 0, - 0, - id="SStore_Refund_OoG", - ), - pytest.param( - 3, - 0, - 0, - id="SStore_Call_Refund_NoOoG", - ), - pytest.param( - 4, - 0, - 0, - id="SStore_Refund_OoG", - ), - pytest.param( - 5, - 0, - 0, - id="SStore_Refund_OoG", - ), - pytest.param( - 6, - 0, - 0, - id="SStore_DelegateCall_Refund_NoOoG", - ), - pytest.param( - 7, - 0, - 0, - id="SStore_Refund_OoG", - ), - pytest.param( - 8, - 0, - 0, - id="SStore_Refund_OoG", - ), - pytest.param( - 9, - 0, - 0, - id="SStore_CallCode_Refund_NoOoG", - ), - pytest.param( - 10, - 0, - 0, - id="SStore_Refund_OoG", - ), - pytest.param( - 11, - 0, - 0, - id="SStore_Refund_OoG", - ), - pytest.param( - 12, - 0, - 0, - id="SelfDestruct_Refund_NoOoG", - ), - pytest.param( - 13, - 0, - 0, - id="SelfDestruct_Refund_OoG", - ), - pytest.param( - 14, - 0, - 0, - id="SelfDestruct_Refund_OoG", - ), - pytest.param( - 15, - 0, - 0, - id="LogOp_NoOoG", - ), - pytest.param( - 16, - 0, - 0, - id="LogOp_OoG", - ), - pytest.param( - 17, - 0, - 0, - id="LogOp_OoG", - ), - pytest.param( - 18, - 0, - 0, - id="SStore_Create_Refund_NoOoG", - ), - pytest.param( - 19, - 0, - 0, - id="SStore_Create_Refund_OoG", - ), - pytest.param( - 20, - 0, - 0, - id="SStore_Create_Refund_OoG", - ), - pytest.param( - 21, - 0, - 0, - id="SStore_Create2_Refund_NoOoG", - ), - pytest.param( - 22, - 0, - 0, - id="SStore_Create2_Refund_OoG", - ), - pytest.param( - 23, - 0, - 0, - id="SStore_Create2_Refund_OoG", - ), - ], + "outcome", list(Outcome), ids=lambda o: o.name.lower() ) -@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize("refund", list(Refund), ids=lambda r: r.name.lower()) def test_create2_oog_from_call_refunds( state_test: StateTestFiller, pre: Alloc, fork: Fork, - d: int, - g: int, - v: int, + refund: Refund, + outcome: Outcome, ) -> None: - """Test_create2_oog_from_call_refunds.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) - contract_1 = Address(0x000000000000000000000000000000000000001A) - contract_2 = Address(0x000000000000000000000000000000000000001B) - contract_3 = Address(0x000000000000000000000000000000000000001C) - contract_4 = Address(0x000000000000000000000000000000000000002A) - contract_5 = Address(0x000000000000000000000000000000000000002B) - contract_6 = Address(0x000000000000000000000000000000000000002C) - contract_7 = Address(0x000000000000000000000000000000000000003A) - contract_8 = Address(0x000000000000000000000000000000000000003B) - contract_9 = Address(0x000000000000000000000000000000000000003C) - contract_10 = Address(0x000000000000000000000000000000000000004A) - contract_11 = Address(0x000000000000000000000000000000000000004B) - contract_12 = Address(0x000000000000000000000000000000000000004C) - contract_13 = Address(0x000000000000000000000000000000000000005A) - contract_14 = Address(0x000000000000000000000000000000000000005B) - contract_15 = Address(0x000000000000000000000000000000000000005C) - contract_16 = Address(0x000000000000000000000000000000000000006A) - contract_17 = Address(0x000000000000000000000000000000000000006B) - contract_18 = Address(0x000000000000000000000000000000000000006C) - contract_19 = Address(0x000000000000000000000000000000000000007A) - contract_20 = Address(0x000000000000000000000000000000000000007B) - contract_21 = Address(0x000000000000000000000000000000000000007C) - contract_22 = Address(0x000000000000000000000000000000000000008A) - contract_23 = Address(0x000000000000000000000000000000000000008B) - contract_24 = Address(0x000000000000000000000000000000000000008C) - contract_25 = Address(0x00000000000000000000000000000000000C0DEA) - contract_26 = Address(0x00000000000000000000000000000000000C0DED) - contract_27 = Address(0x00000000000000000000000000000000000C0DE0) - contract_28 = Address(0x00000000000000000000000000000000000C0DE1) - sender = EOA( - key=0x45A915E4D060149EB4365960E6A7A45F334393093061116B197E3240065FF2D8 + """Init-code refunds survive only a completing CREATE2.""" + # The entry point takes the init code straight from the + # transaction's calldata. The ported filler instead passed the + # address of a contract holding those bytes and EXTCODECOPYed them, + # which needed a carrier contract deployed per arm. + # + # A failed CREATE2 falls through to INVALID, which burns whatever + # gas is left; that is what makes the refund bookkeeping in the OoG + # arms deterministic. + entry_code = ( + Op.CALLDATACOPY(size=Op.CALLDATASIZE) + + Conditional( + condition=Op.ISZERO( + Op.CREATE2( + value=0x0, offset=0x0, size=Op.CALLDATASIZE, salt=0x0 + ) + ), + if_true=Op.INVALID, + ) + + Op.STOP ) + entry = pre.deploy_contract(code=entry_code) - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, + nested_initcode = ( + Op.SSTORE(key=0x0, value=0x1) + + Op.SSTORE(key=0x0, value=0x0) + + Op.RETURN(offset=0x0, size=0x1) ) - pre[sender] = Account(balance=0x3D0900, nonce=1) - # Source: yul - # berlin - # { - # let init_addr := calldataload(4) - # let init_length := extcodesize(init_addr) - # extcodecopy(init_addr, 0, 0, init_length) - # let created_addr := create2(0, 0, init_length, 0) - # if eq(created_addr, 0) { - # /* This invalid will deplete the remaining gas to make refund check deterministic */ # noqa: E501 - # invalid() - # } - # } - contract_0 = pre.deploy_contract( # noqa: F841 - code=Op.PUSH1[0x0] - + Op.DUP1 * 2 - + Op.CALLDATALOAD(offset=0x4) - + Op.DUP2 - + Op.EXTCODESIZE(address=Op.DUP2) - + Op.SWAP3 - + Op.DUP4 - + Op.SWAP3 - + Op.EXTCODECOPY - + Op.DUP2 - + Op.JUMPI(pc=0x16, condition=Op.EQ(Op.CREATE2, Op.DUP1)) - + Op.STOP - + Op.JUMPDEST - + Op.INVALID, - nonce=1, - address=Address(0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # sstore(1, 1) - # sstore(1, 0) - # return(0, 1) - # } - contract_1 = pre.deploy_contract( # noqa: F841 - code=Op.PUSH1[0x1] - + Op.PUSH1[0x0] - + Op.SSTORE(key=Op.DUP2, value=Op.DUP2) - + Op.SSTORE(key=Op.DUP3, value=Op.DUP1) - + Op.RETURN, - nonce=0, - address=Address(0x000000000000000000000000000000000000001A), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # sstore(1, 1) - # sstore(1, 0) - # return(0, 5000) - # } - contract_2 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE(key=Op.DUP1, value=0x1) - + Op.SSTORE(key=0x1, value=0x0) - + Op.RETURN(offset=0x0, size=0x1388), - nonce=0, - address=Address(0x000000000000000000000000000000000000001B), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # sstore(1, 1) - # sstore(1, 0) - # invalid() - # } - contract_3 = pre.deploy_contract( # noqa: F841 - code=Op.PUSH1[0x1] - + Op.PUSH1[0x0] - + Op.SSTORE(key=Op.DUP2, value=Op.DUP2) - + Op.SWAP1 - + Op.SSTORE - + Op.INVALID, - nonce=0, - address=Address(0x000000000000000000000000000000000000001C), # noqa: E501 - ) - # Source: yul - # berlin - # { - # // Simple SSTORE to zero to get a refund - # sstore(1, 0) - # } - contract_25 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x1, value=0x0) + Op.STOP, - storage={1: 1}, - nonce=1, - address=Address(0x00000000000000000000000000000000000C0DEA), # noqa: E501 - ) - # Source: yul - # berlin - # { - # selfdestruct(origin()) - # } - contract_26 = pre.deploy_contract( # noqa: F841 - code=Op.SELFDESTRUCT(address=Op.ORIGIN), - storage={1: 1}, - nonce=1, - address=Address(0x00000000000000000000000000000000000C0DED), # noqa: E501 - ) - # Source: yul - # berlin - # { - # mstore(0, 0xff) - # log0(0, 32) - # log1(0, 32, 0xfa) - # log2(0, 32, 0xfa, 0xfb) - # log3(0, 32, 0xfa, 0xfb, 0xfc) - # log4(0, 32, 0xfa, 0xfb, 0xfc, 0xfd) - # } - contract_27 = pre.deploy_contract( # noqa: F841 - code=Op.MSTORE(offset=0x0, value=0xFF) - + Op.LOG0(offset=0x0, size=0x20) - + Op.LOG1(offset=0x0, size=0x20, topic_1=0xFA) - + Op.LOG2(offset=0x0, size=0x20, topic_1=0xFA, topic_2=0xFB) - + Op.LOG3( - offset=0x0, size=0x20, topic_1=0xFA, topic_2=0xFB, topic_3=0xFC - ) - + Op.LOG4( - offset=0x0, - size=0x20, - topic_1=0xFA, - topic_2=0xFB, - topic_3=0xFC, - topic_4=0xFD, - ) - + Op.STOP, - storage={1: 1}, - nonce=1, - address=Address(0x00000000000000000000000000000000000C0DE0), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # sstore(0, 0) - # return(0, 1) - # } - contract_28 = pre.deploy_contract( # noqa: F841 - code=Op.PUSH1[0x0] - + Op.SSTORE(key=Op.DUP1, value=Op.DUP1) - + Op.PUSH1[0x1] - + Op.SWAP1 - + Op.RETURN, - nonce=1, - address=Address(0x00000000000000000000000000000000000C0DE1), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # pop(call(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) # noqa: E501 - # return(0, 1) - # let noOpt := msize() - # } - contract_4 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.CALL( - gas=Op.GAS, - address=contract_25, - value=Op.DUP1, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) - + Op.RETURN(offset=0x0, size=0x1), - nonce=0, - address=Address(0x000000000000000000000000000000000000002A), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # pop(call(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) # noqa: E501 - # return(0, 5000) - # } - contract_5 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.CALL( - gas=Op.GAS, - address=contract_25, - value=Op.DUP1, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) - + Op.RETURN(offset=0x0, size=0x1388), - nonce=0, - address=Address(0x000000000000000000000000000000000000002B), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # sstore(1, 1) - # pop(delegatecall(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0)) # noqa: E501 - # invalid() - # } - contract_9 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE(key=Op.DUP1, value=0x1) - + Op.POP( - Op.DELEGATECALL( - gas=Op.GAS, - address=contract_25, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, + body: Bytecode + post: dict[Address, Account | None] = {} + match refund: + case Refund.DIRECT: + # Sets a second slot and clears it itself. + body = ( + Op.SSTORE(key=0x0, value=0x1) + + Op.SSTORE(key=0x1, value=0x1) + + Op.SSTORE(key=0x1, value=0x0) ) - ) - + Op.INVALID, - nonce=0, - address=Address(0x000000000000000000000000000000000000003C), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # pop(call(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) # noqa: E501 - # invalid() - # } - contract_6 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.POP( - Op.CALL( - gas=Op.GAS, - address=contract_25, - value=Op.DUP1, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, + case Refund.CALL | Refund.DELEGATECALL | Refund.CALLCODE: + # The callee clears a slot of its own. + clear_target_code = Op.SSTORE(key=0x1, value=0x0) + Op.STOP + clear_target = pre.deploy_contract( + code=clear_target_code, + storage={1: 1}, ) - ) - + Op.INVALID, - nonce=0, - address=Address(0x000000000000000000000000000000000000002C), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # sstore(1, 1) - # pop(delegatecall(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0)) # noqa: E501 - # return(0, 1) - # let noOpt := msize() - # } - contract_7 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE(key=Op.DUP1, value=0x1) - + Op.DELEGATECALL( - gas=Op.GAS, - address=contract_25, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) - + Op.RETURN(offset=0x0, size=0x1), - nonce=0, - address=Address(0x000000000000000000000000000000000000003A), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # sstore(1, 1) - # pop(callcode(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) # noqa: E501 - # return(0, 5000) - # } - contract_11 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE(key=Op.DUP1, value=0x1) - + Op.CALLCODE( - gas=Op.GAS, - address=contract_25, - value=Op.DUP1, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) - + Op.RETURN(offset=0x0, size=0x1388), - nonce=0, - address=Address(0x000000000000000000000000000000000000004B), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # sstore(1, 1) - # pop(delegatecall(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0)) # noqa: E501 - # return(0, 5000) - # } - contract_8 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE(key=Op.DUP1, value=0x1) - + Op.DELEGATECALL( - gas=Op.GAS, - address=contract_25, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) - + Op.RETURN(offset=0x0, size=0x1388), - nonce=0, - address=Address(0x000000000000000000000000000000000000003B), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # sstore(1, 1) - # pop(callcode(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) # noqa: E501 - # return(0, 1) - # let noOpt := msize() - # } - contract_10 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE(key=Op.DUP1, value=0x1) - + Op.CALLCODE( - gas=Op.GAS, - address=contract_25, - value=Op.DUP1, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) - + Op.RETURN(offset=0x0, size=0x1), - nonce=0, - address=Address(0x000000000000000000000000000000000000004A), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # sstore(1, 1) - # pop(callcode(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) # noqa: E501 - # invalid() - # } - contract_12 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE(key=Op.DUP1, value=0x1) - + Op.POP( - Op.CALLCODE( - gas=Op.GAS, - address=contract_25, - value=Op.DUP1, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, + sstore_code = Op.SSTORE(key=0x0, value=0x1) + if refund == Refund.CALL: + call_opcode = Op.CALL + elif refund == Refund.DELEGATECALL: + sstore_code += Op.SSTORE(key=0x1, value=0x1) + call_opcode = Op.DELEGATECALL + else: + sstore_code += Op.SSTORE(key=0x1, value=0x1) + call_opcode = Op.CALLCODE + body = sstore_code + call_opcode(address=clear_target) + post[clear_target] = Account( + storage={ + 1: int( + refund != Refund.CALL or outcome != Outcome.COMPLETES + ) + } ) - ) - + Op.INVALID, - nonce=0, - address=Address(0x000000000000000000000000000000000000004C), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # pop(call(gas(), 0x00000000000000000000000000000000000c0deD, 0, 0, 0, 0, 0)) # noqa: E501 - # return(0, 5000) - # } - contract_14 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.CALL( - gas=Op.GAS, - address=contract_26, - value=Op.DUP1, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) - + Op.RETURN(offset=0x0, size=0x1388), - nonce=0, - address=Address(0x000000000000000000000000000000000000005B), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # pop(call(gas(), 0x00000000000000000000000000000000000c0deD, 0, 0, 0, 0, 0)) # noqa: E501 - # return(0, 1) - # let noOpt := msize() - # } - contract_13 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.CALL( - gas=Op.GAS, - address=contract_26, - value=Op.DUP1, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) - + Op.RETURN(offset=0x0, size=0x1), - nonce=0, - address=Address(0x000000000000000000000000000000000000005A), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # pop(call(gas(), 0x00000000000000000000000000000000000c0deD, 0, 0, 0, 0, 0)) # noqa: E501 - # invalid() - # } - contract_15 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.POP( - Op.CALL( - gas=Op.GAS, - address=contract_26, - value=Op.DUP1, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, + case Refund.SELFDESTRUCT: + # The callee destroys itself. + selfdestruct_target_code = Op.SELFDESTRUCT(address=Op.ORIGIN) + selfdestruct_target = pre.deploy_contract( + code=selfdestruct_target_code, + storage={1: 1}, ) - ) - + Op.INVALID, - nonce=0, - address=Address(0x000000000000000000000000000000000000005C), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # pop(call(gas(), 0x00000000000000000000000000000000000c0de0, 0, 0, 0, 0, 0)) # noqa: E501 - # return(0, 1) - # let noOpt := msize() - # } - contract_16 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.CALL( - gas=Op.GAS, - address=contract_27, - value=Op.DUP1, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) - + Op.RETURN(offset=0x0, size=0x1), - nonce=0, - address=Address(0x000000000000000000000000000000000000006A), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # pop(call(gas(), 0x00000000000000000000000000000000000c0de0, 0, 0, 0, 0, 0)) # noqa: E501 - # return(0, 5000) - # } - contract_17 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.CALL( - gas=Op.GAS, - address=contract_27, - value=Op.DUP1, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, - ) - + Op.RETURN(offset=0x0, size=0x1388), - nonce=0, - address=Address(0x000000000000000000000000000000000000006B), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # pop(call(gas(), 0x00000000000000000000000000000000000c0de0, 0, 0, 0, 0, 0)) # noqa: E501 - # invalid() - # } - contract_18 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.POP( - Op.CALL( + body = Op.SSTORE(key=0x0, value=0x1) + Op.CALL( + gas=Op.GAS, address=selfdestruct_target + ) + if outcome is Outcome.COMPLETES: + post[selfdestruct_target] = Account(balance=0, nonce=1) + else: + post[selfdestruct_target] = Account( + storage={1: 1}, code=selfdestruct_target_code, nonce=1 + ) + case Refund.LOGS: + # The callee only emits logs, so nothing is refunded. + log_target_code = ( + Op.MSTORE(offset=0x0, value=0xFF) + + Op.LOG0(offset=0x0, size=0x20) + + Op.LOG1(offset=0x0, size=0x20, topic_1=0xFA) + + Op.LOG2(offset=0x0, size=0x20, topic_1=0xFA, topic_2=0xFB) + + Op.LOG3( + offset=0x0, + size=0x20, + topic_1=0xFA, + topic_2=0xFB, + topic_3=0xFC, + ) + + Op.LOG4( + offset=0x0, + size=0x20, + topic_1=0xFA, + topic_2=0xFB, + topic_3=0xFC, + topic_4=0xFD, + ) + + Op.STOP + ) + body = Op.SSTORE(key=0x0, value=0x1) + Op.CALL( gas=Op.GAS, - address=contract_27, - value=Op.DUP1, - args_offset=Op.DUP1, - args_size=Op.DUP1, - ret_offset=Op.DUP1, - ret_size=0x0, + address=pre.deploy_contract( + code=log_target_code, storage={1: 1} + ), ) - ) - + Op.INVALID, - nonce=0, - address=Address(0x000000000000000000000000000000000000006C), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # sstore(1, 1) - # sstore(1, 0) - # let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - # let initcodelength := extcodesize(initcodeaddr) - # extcodecopy(initcodeaddr, 0, 0, initcodelength) - # pop(create2(0, 0, initcodelength, 0)) - # return(add(initcodelength, 1), 5000) - # } - contract_23 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE(key=Op.DUP1, value=0x1) - + Op.SSTORE(key=0x1, value=0x0) - + Op.PUSH2[0x1388] - + Op.PUSH1[0x1] - + Op.PUSH1[0x0] - + Op.PUSH3[0xC0DE1] - + Op.DUP2 - + Op.EXTCODESIZE(address=Op.DUP2) - + Op.SWAP3 - + Op.DUP4 - + Op.SWAP3 - + Op.EXTCODECOPY - + Op.POP( - Op.CREATE2(value=Op.DUP1, offset=Op.DUP2, size=Op.DUP2, salt=0x0) - ) - + Op.ADD - + Op.RETURN, - nonce=0, - address=Address(0x000000000000000000000000000000000000008B), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # sstore(1, 1) - # sstore(1, 0) - # let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - # let initcodelength := extcodesize(initcodeaddr) - # extcodecopy(initcodeaddr, 0, 0, initcodelength) - # pop(create2(0, 0, initcodelength, 0)) - # return(add(initcodelength, 1), 1) - # let noOpt := msize() - # } - contract_22 = pre.deploy_contract( # noqa: F841 - code=Op.PUSH1[0x1] - + Op.PUSH1[0x0] - + Op.SSTORE(key=Op.DUP2, value=Op.DUP2) - + Op.SSTORE(key=Op.DUP3, value=Op.DUP1) - + Op.DUP2 - + Op.SWAP1 - + Op.PUSH3[0xC0DE1] - + Op.EXTCODESIZE(address=Op.DUP1) - + Op.SWAP2 - + Op.DUP3 - + Op.SWAP2 - + Op.DUP2 - + Op.SWAP1 - + Op.EXTCODECOPY - + Op.POP( - Op.CREATE2(value=Op.DUP1, offset=Op.DUP2, size=Op.DUP2, salt=0x0) - ) - + Op.ADD - + Op.RETURN, - nonce=0, - address=Address(0x000000000000000000000000000000000000008A), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # sstore(1, 1) - # sstore(1, 0) - # let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - # let initcodelength := extcodesize(initcodeaddr) - # extcodecopy(initcodeaddr, 0, 0, initcodelength) - # pop(create2(0, 0, initcodelength, 0)) - # invalid() - # } - contract_24 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE(key=Op.DUP1, value=0x1) - + Op.SSTORE(key=0x1, value=0x0) - + Op.PUSH1[0x0] - + Op.DUP1 - + Op.PUSH3[0xC0DE1] - + Op.DUP2 - + Op.EXTCODESIZE(address=Op.DUP2) - + Op.SWAP3 - + Op.DUP4 - + Op.SWAP3 - + Op.EXTCODECOPY - + Op.DUP2 - + Op.DUP1 - + Op.POP(Op.CREATE2) - + Op.INVALID, - nonce=0, - address=Address(0x000000000000000000000000000000000000008C), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # sstore(1, 1) - # sstore(1, 0) - # let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - # let initcodelength := extcodesize(initcodeaddr) - # extcodecopy(initcodeaddr, 0, 0, initcodelength) - # pop(create(0, 0, initcodelength)) - # return(add(initcodelength, 1), 5000) - # } - contract_20 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE(key=Op.DUP1, value=0x1) - + Op.SSTORE(key=0x1, value=0x0) - + Op.PUSH2[0x1388] - + Op.PUSH1[0x1] - + Op.PUSH1[0x0] - + Op.PUSH3[0xC0DE1] - + Op.DUP2 - + Op.EXTCODESIZE(address=Op.DUP2) - + Op.SWAP3 - + Op.DUP4 - + Op.SWAP3 - + Op.EXTCODECOPY - + Op.POP(Op.CREATE(value=Op.DUP1, offset=0x0, size=Op.DUP1)) - + Op.ADD - + Op.RETURN, - nonce=0, - address=Address(0x000000000000000000000000000000000000007B), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # sstore(1, 1) - # sstore(1, 0) - # let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - # let initcodelength := extcodesize(initcodeaddr) - # extcodecopy(initcodeaddr, 0, 0, initcodelength) - # pop(create(0, 0, initcodelength)) - # invalid() - # } - contract_21 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE(key=Op.DUP1, value=0x1) - + Op.SSTORE(key=0x1, value=0x0) - + Op.PUSH1[0x0] - + Op.PUSH3[0xC0DE1] - + Op.DUP2 - + Op.EXTCODESIZE(address=Op.DUP2) - + Op.SWAP3 - + Op.DUP4 - + Op.SWAP3 - + Op.EXTCODECOPY - + Op.PUSH1[0x0] - + Op.DUP1 - + Op.POP(Op.CREATE) - + Op.INVALID, - nonce=0, - address=Address(0x000000000000000000000000000000000000007C), # noqa: E501 - ) - # Source: yul - # berlin - # { - # sstore(0, 1) - # sstore(1, 1) - # sstore(1, 0) - # let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - # let initcodelength := extcodesize(initcodeaddr) - # extcodecopy(initcodeaddr, 0, 0, initcodelength) - # pop(create(0, 0, initcodelength)) - # return(add(initcodelength, 1), 1) - # let noOptimization := msize() - # } - contract_19 = pre.deploy_contract( # noqa: F841 - code=Op.PUSH1[0x1] - + Op.PUSH1[0x0] - + Op.SSTORE(key=Op.DUP2, value=Op.DUP2) - + Op.SSTORE(key=Op.DUP3, value=Op.DUP1) - + Op.DUP2 - + Op.SWAP1 - + Op.PUSH3[0xC0DE1] - + Op.EXTCODESIZE(address=Op.DUP1) - + Op.SWAP2 - + Op.DUP3 - + Op.SWAP2 - + Op.DUP2 - + Op.SWAP1 - + Op.EXTCODECOPY - + Op.POP(Op.CREATE(value=Op.DUP1, offset=0x0, size=Op.DUP1)) - + Op.ADD - + Op.RETURN, - nonce=0, - address=Address(0x000000000000000000000000000000000000007A), # noqa: E501 - ) + case Refund.CREATE | Refund.CREATE2: + nested_carrier = pre.deploy_contract(code=nested_initcode) + nested_size = Op.EXTCODESIZE(address=nested_carrier) + + assert DEPOSIT_OFFSET >= len(nested_initcode), ( + "the deposit offset must clear the copied init code" + ) + body = ( + Op.SSTORE(key=0x0, value=0x1) + + Op.SSTORE(key=0x1, value=0x1) + + Op.SSTORE(key=0x1, value=0x0) + + Op.EXTCODECOPY( + address=nested_carrier, + dest_offset=0x0, + offset=0x0, + size=nested_size, + ) + ) + if refund == Refund.CREATE: + body += Op.CREATE(offset=0x0, size=nested_size) + else: + body += Op.CREATE2(offset=0x0, size=nested_size) + + match outcome: + case Outcome.COMPLETES: + # Deposit a single zero byte and return normally. + ends = Op.RETURN(offset=DEPOSIT_OFFSET, size=0x1) + case Outcome.OOG_DEPOSIT: + # Ask for more code than the budget can pay to deposit. + ends = Op.RETURN( + offset=DEPOSIT_OFFSET, + size=OOG_DEPOSIT_SIZE, + code_deposit_size=OOG_DEPOSIT_SIZE, + ) + case Outcome.OOG_INVALID: + # Burn whatever gas is left. + ends = Op.INVALID + + initcode = body + ends - expect_entries_: list[dict] = [] - if fork.is_eip_enabled(8037): - expect_entries_.append( - { - "indexes": { - "data": [ - 1, - 2, - 4, - 5, - 7, - 8, - 10, - 11, - 13, - 14, - 16, - 17, - 19, - 20, - 22, - 23, - ], - "gas": -1, - "value": -1, - }, - "network": [">=Cancun"], - "result": {sender: Account(nonce=2)}, - } + # The ported budget was sized for the compiled programs, in which + # solc had folded a fresh set out of the deepest arm's two init codes + # (restored here). On top of it, EIP-8037 charges state gas: the + # deepest arm (create inside create2) makes three fresh sets and two + # new accounts and deposits one byte at each depth. All state terms + # are zero before Amsterdam. + fresh_set = Op.SSTORE( + key=0x0, value=0x1, key_warm=False, original_value=0, new_value=1 + ) + new_account_state = Op.CREATE2( + value=0x0, offset=0x0, size=0x0, salt=0x0 + ).state_cost(fork) + # Passing the init code as calldata adds intrinsic gas to the old + # carrier-address setup; derive that allowance from its actual bytes. + intrinsic = fork.transaction_intrinsic_cost_calculator() + calldata_gas = intrinsic(calldata=initcode) - intrinsic() + tx_gas_limit = ( + PORTED_GAS_LIMIT + + 2 * fresh_set.execution_cost(fork) + + 3 * fresh_set.state_cost(fork) + + 2 * new_account_state + + 2 * fork.code_deposit_state_gas(code_size=1) + + calldata_gas + ) + # The budget must stay below the oversized deposit charge so the + # OoG arms keep starving on it on every fork. + if outcome == Outcome.OOG_DEPOSIT: + assert tx_gas_limit < ends.gas_cost(fork), ( + "the OoG arms must stay starved" ) - expect_entries_ += [ - { - "indexes": {"data": [0], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=2), - Address(0xCFB6834F84B9E726F5F8AEF446D585B732ABDD99): Account( - storage={0: 1}, code=bytes.fromhex("00"), nonce=1 - ), - }, - }, - { - "indexes": {"data": [3], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=2), - Address(0xD615C5EAFF84F487CFF253B50DC18517FC8385B0): Account( - storage={0: 1}, code=bytes.fromhex("00"), nonce=1 - ), - }, - }, - { - "indexes": {"data": [6], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=2), - Address(0x0D44B2AD06C5C9F9A86C9EDF8D13FB7D44FE756C): Account( - storage={0: 1}, code=bytes.fromhex("00"), nonce=1 - ), - }, - }, - { - "indexes": {"data": [9], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=2), - Address(0x858EC13538276B49D5ECE2A408C8331CCB79AD89): Account( - storage={0: 1}, code=bytes.fromhex("00"), nonce=1 - ), - }, - }, - { - "indexes": { - "data": [1, 2, 4, 5, 7, 8, 10, 11], - "gas": -1, - "value": -1, - }, - "network": [">=Cancun"], - "result": { - sender: Account(balance=0, nonce=2), - Address( - 0x95E88628C53B5C0E40FF6DE65A3CF8CDC3B477F7 - ): Account.NONEXISTENT, - Address( - 0x66E1CC2616A273450621C8CC5E91D8CFD92494FA - ): Account.NONEXISTENT, - Address( - 0x6175BA9976476425B1CDA8E1DA479768FB429542 - ): Account.NONEXISTENT, - Address( - 0x8DFF0E448F1E078E9B8A7FCF0BF6C291F167AAEF - ): Account.NONEXISTENT, - Address( - 0xA2C4270800A5DBEEA48464E5F2420EFB1747725A - ): Account.NONEXISTENT, - Address( - 0x4D80F1150EE236ADFAAB47C70DF90E757CEF1141 - ): Account.NONEXISTENT, - Address( - 0x0566DC8DABC80FAD3ED9AB2B4309EBFD98894F44 - ): Account.NONEXISTENT, - Address( - 0x55305CC46BDAF1E755A05A771D55CFEC3FEDEF90 - ): Account.NONEXISTENT, - }, - }, - { - "indexes": {"data": [12], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=2), - Address(0xD83E541AA11C5AE1E9C847AA1728D5BC47D32FAF): Account( - storage={0: 1}, code=bytes.fromhex("00"), nonce=1 - ), - contract_26: Account(balance=0, nonce=1), - }, - }, - { - "indexes": {"data": [13, 14], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(balance=0, nonce=2), - Address( - 0x8F6E6C741AC95C1A9109850EA1A3FFC722DC3BF8 - ): Account.NONEXISTENT, - Address( - 0x1F5D187BB3A48DBB2C011D0A6E731AC8131799AD - ): Account.NONEXISTENT, - contract_26: Account( - storage={1: 1}, code=bytes.fromhex("32ff"), nonce=1 - ), - }, - }, - { - "indexes": {"data": [15], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=2), - Address(0x2A2141ED764598D4C5A8B6E036987928D5EC6BEA): Account( - storage={0: 1}, code=bytes.fromhex("00"), nonce=1 - ), - }, - }, - { - "indexes": {"data": [16, 17], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(balance=0, nonce=2), - Address( - 0x74B39291DFC237C0D42FD15457754778F51C6DE8 - ): Account.NONEXISTENT, - Address( - 0x3399C78929EAB89C673A8986FF7CA9CCC49DB454 - ): Account.NONEXISTENT, - }, - }, - { - "indexes": {"data": [18], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=2), - Address(0xDEB7D920F2653A8EDDCFFCA0A77F56FCD788C00A): Account( - storage={0: 1}, code=bytes.fromhex("00"), nonce=2 - ), - Address(0x8109D28DE74BFAC2F298EC019548B8C346E51310): Account( - storage={}, code=bytes.fromhex("00"), nonce=1 - ), - }, - }, - { - "indexes": {"data": [19, 20], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(balance=0, nonce=2), - Address( - 0xF922B2F70110C83F8EC7DF512B41BAC5627E8E59 - ): Account.NONEXISTENT, - Address( - 0x2CA788D22E21134AB1909266ED3B6C352E2A07CB - ): Account.NONEXISTENT, - Address( - 0x398426E736801FE712DF1EF078A3B6CA3C6F063B - ): Account.NONEXISTENT, - Address( - 0xB520686759CED3BC9D8898E02EE41623032FF47F - ): Account.NONEXISTENT, - }, - }, - { - "indexes": {"data": [21], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=2), - Address(0x5A2664B55822AA3C6D9D90FEC18B4C87CDE07D04): Account( - storage={0: 1}, code=bytes.fromhex("00"), nonce=2 - ), - Address(0x442ED1B502544D146E46B5D9849A476AEBD3B8DB): Account( - storage={}, code=bytes.fromhex("00"), nonce=1 - ), - }, - }, - { - "indexes": {"data": [22, 23], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(balance=0, nonce=2), - Address( - 0xDD2C53BFCAF5C1D698A2B21C0908F15F7FBFD635 - ): Account.NONEXISTENT, - Address( - 0x2D556BDBCC37C7A021879A21ABE25D1850D4FD36 - ): Account.NONEXISTENT, - Address( - 0xA99DA4EA490335C986D52B0CC9E3F78B286AC5FC - ): Account.NONEXISTENT, - Address( - 0xB4AB8AB0D363765586925E35C715E342E4AE3C63 - ): Account.NONEXISTENT, - }, - }, - ] - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) + # The exact funding makes the OoG arms' post-state balance zero. + sender = pre.fund_eoa(amount=tx_gas_limit * GAS_PRICE) + + created = compute_create2_address(entry, 0, initcode) + nested_created_address: Address | None = None + if refund in NESTED: + nested_created_address = compute_create_address( + address=created, + nonce=1, + salt=0, + initcode=nested_initcode, + opcode=Op.CREATE if refund == Refund.CREATE else Op.CREATE2, + ) - tx_data = [ - Bytes("693c6139") + Hash(contract_1, left_padding=True), - Bytes("693c6139") + Hash(contract_2, left_padding=True), - Bytes("693c6139") + Hash(contract_3, left_padding=True), - Bytes("693c6139") + Hash(contract_4, left_padding=True), - Bytes("693c6139") + Hash(contract_5, left_padding=True), - Bytes("693c6139") + Hash(contract_6, left_padding=True), - Bytes("693c6139") + Hash(contract_7, left_padding=True), - Bytes("693c6139") + Hash(contract_8, left_padding=True), - Bytes("693c6139") + Hash(contract_9, left_padding=True), - Bytes("693c6139") + Hash(contract_10, left_padding=True), - Bytes("693c6139") + Hash(contract_11, left_padding=True), - Bytes("693c6139") + Hash(contract_12, left_padding=True), - Bytes("693c6139") + Hash(contract_13, left_padding=True), - Bytes("693c6139") + Hash(contract_14, left_padding=True), - Bytes("693c6139") + Hash(contract_15, left_padding=True), - Bytes("693c6139") + Hash(contract_16, left_padding=True), - Bytes("693c6139") + Hash(contract_17, left_padding=True), - Bytes("693c6139") + Hash(contract_18, left_padding=True), - Bytes("693c6139") + Hash(contract_19, left_padding=True), - Bytes("693c6139") + Hash(contract_20, left_padding=True), - Bytes("693c6139") + Hash(contract_21, left_padding=True), - Bytes("693c6139") + Hash(contract_22, left_padding=True), - Bytes("693c6139") + Hash(contract_23, left_padding=True), - Bytes("693c6139") + Hash(contract_24, left_padding=True), - ] - tx_gas = [400000] + if outcome is Outcome.COMPLETES: + # The CREATE2 finished, so the refunds it earned stand and the + # one deposited byte survives. + post[sender] = Account(nonce=1) + post[created] = Account( + storage={0: 1}, code=Op.STOP, nonce=2 if refund in NESTED else 1 + ) + if nested_created_address: + post[nested_created_address] = Account( + storage={}, code=Op.STOP, nonce=1 + ) + else: + # The frame died, so nothing it did survives and the sender is + # charged for the whole budget. + post[sender] = Account(balance=0, nonce=1) + post[created] = Account.NONEXISTENT + if nested_created_address: + post[nested_created_address] = Account.NONEXISTENT tx = Transaction( sender=sender, - to=contract_0, - data=tx_data[d], - gas_limit=tx_gas[g], - nonce=1, - error=_exc, + to=entry, + data=initcode, + gas_limit=tx_gas_limit, + gas_price=GAS_PRICE, ) - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stCreate2/test_create2collision_selfdestructed_oog.py b/tests/ported_static/stCreate2/test_create2collision_selfdestructed_oog.py index 4def59bad89..52bc30cd4fb 100644 --- a/tests/ported_static/stCreate2/test_create2collision_selfdestructed_oog.py +++ b/tests/ported_static/stCreate2/test_create2collision_selfdestructed_oog.py @@ -1,52 +1,64 @@ """ -Collision with address that has been selfdestructed in the same... +Verify a CREATE2 whose target address holds a pre-existing account that +SELFDESTRUCTed earlier in the same transaction: the collision stands +(the account is only emptied, not freed), consuming the child's grant, +and the sized budget then runs the creating init code out of gas so the +whole creation transaction rolls back, including the selfdestruct's +balance transfer. Ported from: state_tests/stCreate2/create2collisionSelfdestructedOOGFiller.json + +@manually-enhanced: Do not overwrite. Collider and beneficiary addresses +are computed instead of hardcoded, and the budget derives from fork +composites: the CREATE2's slack would fund the post-collision stores had +the creation gone through, so the rollback is attributable to the +collision's burned grant on every fork. """ import pytest from execution_testing import ( - EOA, Account, - Address, Alloc, - Environment, + Bytecode, + Fork, StateTestFiller, Transaction, + compute_create2_address, compute_create_address, ) -from execution_testing.forks import Fork from execution_testing.vm import Op REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +COLLIDER_BALANCE = 1 +# Head room on top of the slack the CREATE2 finds when it runs. +SLACK_MARGIN = 5_000 + @pytest.mark.ported_from( ["state_tests/stCreate2/create2collisionSelfdestructedOOGFiller.json"], ) -@pytest.mark.valid_from("Cancun") +@pytest.mark.valid_from("Constantinople") @pytest.mark.parametrize( - "d, g, v", + "inner_initcode", [ + pytest.param(Bytecode(), id="empty_initcode"), pytest.param( - 0, - 0, - 0, - id="d0", - ), - pytest.param( - 1, - 0, - 0, - id="d1", + Op.SSTORE( + key=0x1, + value=0x1, + key_warm=False, + original_value=0, + new_value=1, + ), + id="storing_initcode", ), pytest.param( - 2, - 0, - 0, - id="d2", + Op.MSTORE(offset=0x0, value=0x6001600155, new_memory_size=0x20) + + Op.RETURN(offset=0x1B, size=0x5, code_deposit_size=0x5), + id="depositing_initcode", ), ], ) @@ -55,120 +67,128 @@ def test_create2collision_selfdestructed_oog( state_test: StateTestFiller, pre: Alloc, fork: Fork, - d: int, - g: int, - v: int, + inner_initcode: Bytecode, ) -> None: - """Collision with address that has been selfdestructed in the same...""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0xE2B35478FDD26477CC576DD906E6277761246A3C) - contract_1 = Address(0xAF3ECBA2FE09A4F6C19F16A9D119E44E08C2DA01) - contract_2 = Address(0xEC2C6832D00680ECE8FF9254F81FDAB0A5A2AC50) - sender = EOA( - key=0x45A915E4D060149EB4365960E6A7A45F334393093061116B197E3240065FF2D8 - ) + """A selfdestructed account still collides, and its grant is lost.""" + sender = pre.fund_eoa() + outer_created = compute_create_address(address=sender, nonce=0) - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=1000000, + # The collider occupies the CREATE2 target (which depends on the + # init code below through its hash) and selfdestructs when called. + # Its address is derived from the pre-funded sender, so the pre + # allocation must stay mutable. + beneficiary = pre.nonexistent_account() + collider_work = Op.SELFDESTRUCT( + address=beneficiary, + address_warm=False, + account_new=True, + ) + collider = compute_create2_address(outer_created, 0, inner_initcode) + pre.deploy_contract( + code=collider_work, + balance=COLLIDER_BALANCE, + address=collider, ) - pre[sender] = Account(balance=0xDE0B6B3A7640000) - # Source: lll - # { (SELFDESTRUCT 0x10) } - contract_0 = pre.deploy_contract( # noqa: F841 - code=Op.SELFDESTRUCT(address=0x10) + Op.STOP, - balance=1, - nonce=0, - address=Address(0xE2B35478FDD26477CC576DD906E6277761246A3C), # noqa: E501 + # The outer init code selfdestructs the collider, stages the child's + # init code (never executed: the collision aborts before dispatch) + # and runs the CREATE2 into the collision. The two stores after it + # are the victims the burned grant leaves unaffordable. + inner_bytes = bytes(inner_initcode) + assert len(inner_bytes) <= 0x20, "inner init code must fit one word" + call_code = Op.CALL( + address=collider, + address_warm=False, + value_transfer=False, + account_new=False, + ) + setup = ( + Op.MSTORE( + offset=0x0, + value=int.from_bytes(inner_bytes, "big"), + new_memory_size=0x20, + ) + if inner_bytes + else Bytecode() ) - # Source: lll - # { (SELFDESTRUCT 0x10) } - contract_1 = pre.deploy_contract( # noqa: F841 - code=Op.SELFDESTRUCT(address=0x10) + Op.STOP, - balance=1, - nonce=0, - address=Address(0xAF3ECBA2FE09A4F6C19F16A9D119E44E08C2DA01), # noqa: E501 + create2_code = Op.CREATE2( + value=0x0, + offset=0x20 - len(inner_bytes) if inner_bytes else 0x0, + size=len(inner_bytes), + salt=0x0, + new_memory_size=0x20 if inner_bytes else 0x0, + old_memory_size=0x20 if inner_bytes else 0x0, + init_code_size=len(inner_bytes), + account_new=False, ) - # Source: lll - # { (SELFDESTRUCT 0x10) } - contract_2 = pre.deploy_contract( # noqa: F841 - code=Op.SELFDESTRUCT(address=0x10) + Op.STOP, - balance=1, - nonce=0, - address=Address(0xEC2C6832D00680ECE8FF9254F81FDAB0A5A2AC50), # noqa: E501 + victim_stores = Op.SSTORE( + key=0x0, + value=0x112233, + key_warm=False, + original_value=0, + new_value=0x112233, + ) + Op.SSTORE( + key=0x1, + value=0x112233, + key_warm=False, + original_value=0, + new_value=0x112233, + ) + outer_initcode = ( + Op.POP(call_code) + setup + Op.POP(create2_code) + victim_stores ) - tx_data = [ - Op.POP( - Op.CALL( - gas=0xC350, - address=contract_0, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - ) - + Op.POP(Op.CREATE2(value=0x0, offset=0x0, size=0x0, salt=0x0)) - + Op.SSTORE(key=0x0, value=0x112233) - + Op.STOP, - Op.POP( - Op.CALL( - gas=0xC350, - address=contract_1, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - ) - + Op.MSTORE(offset=0x0, value=0x6001600155) - + Op.POP(Op.CREATE2(value=0x0, offset=0x1B, size=0x5, salt=0x0)) - + Op.SSTORE(key=0x0, value=0x112233) - + Op.STOP, - Op.POP( - Op.CALL( - gas=0xC350, - address=contract_2, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) + # The slack is what the CREATE2 finds when it runs. It would pay for + # the victims even if a client let the creation through (freeing the + # selfdestructed target, so charging it as a new account, and running + # the child), so only the collision's burn can starve them. The + # collider is alive at the CREATE2 (only emptied by its selfdestruct), + # so no new-account state gas is charged there. + new_account_state = Op.CREATE2( + value=0x0, offset=0x0, size=0x0, salt=0x0 + ).state_cost(fork) + slack = ( + victim_stores.gas_cost(fork) + + new_account_state + + inner_initcode.gas_cost(fork) + + SLACK_MARGIN + ) + gas_limit = ( + fork.transaction_intrinsic_cost_calculator()( + calldata=outer_initcode, + contract_creation=True, + return_cost_deducted_prior_execution=True, ) - + Op.MSTORE(offset=0x0, value=0x6460016001556000526005601BF3) - + Op.POP(Op.CREATE2(value=0x0, offset=0x12, size=0xE, salt=0x0)) - + Op.SSTORE(key=0x0, value=0x112233) - + Op.STOP, - ] - tx_gas = [200000] - tx_value = [1] + + fork.transaction_top_frame_state_gas(contract_creation=True) + + call_code.gas_cost(fork) + + collider_work.gas_cost(fork) + + setup.gas_cost(fork) + + create2_code.gas_cost(fork) + + slack + ) + # The collision leaves one 64th of the slack: never the victims. + assert slack // 64 < victim_stores.gas_cost(fork), ( + "the collision's leavings must not afford the victim stores" + ) tx = Transaction( sender=sender, to=None, - data=tx_data[d], - gas_limit=tx_gas[g], - value=tx_value[v], + data=outer_initcode, + gas_limit=gas_limit, ) post = { - contract_0: Account(code=bytes.fromhex("6010ff00"), balance=1), - contract_1: Account(code=bytes.fromhex("6010ff00"), balance=1), - contract_2: Account(code=bytes.fromhex("6010ff00"), balance=1), - Address( - 0x0000000000000000000000000000000000000010 - ): Account.NONEXISTENT, - compute_create_address(address=sender, nonce=0): Account.NONEXISTENT, sender: Account(nonce=1), + # Rolled back wholesale: the collider keeps its code and its + # balance, the beneficiary was never credited, nothing created. + collider: Account( + code=collider_work, + balance=COLLIDER_BALANCE, + nonce=1, + ), + beneficiary: Account.NONEXISTENT, + outer_created: 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/stCreate2/test_create2no_cash.py b/tests/ported_static/stCreate2/test_create2no_cash.py index 587c64889c1..c846b6e2cfb 100644 --- a/tests/ported_static/stCreate2/test_create2no_cash.py +++ b/tests/ported_static/stCreate2/test_create2no_cash.py @@ -1,161 +1,107 @@ """ -Create2 fails with not enough cash (endowment of a new account) +... +Verify CREATE2's endowment balance preflight: a creator one wei short of +the endowment fails without creating (and without a nonce bump), a +one-wei top-up sent with the call makes the same CREATE2 succeed, and in +a static context the CREATE2 faults the whole frame instead. Ported from: state_tests/stCreate2/create2noCashFiller.json + +@manually-enhanced: Do not overwrite. The creation-transaction wrapper +and tuned gas budgets are replaced by a deployed entry contract that +records the call result, and the created account and the creator's +nonce (no bump on the balance preflight) are asserted explicitly. """ import pytest from execution_testing import ( - EOA, Account, - Address, Alloc, - Environment, StateTestFiller, Transaction, + compute_create2_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" +CALL_RESULT_SLOT = 0x0 +CREATE2_ENDOWMENT = 0x65 + @pytest.mark.ported_from( ["state_tests/stCreate2/create2noCashFiller.json"], ) -@pytest.mark.valid_from("Cancun") +@pytest.mark.valid_from("Constantinople") @pytest.mark.parametrize( - "d, g, v", + "opcode, top_up", [ - pytest.param( - 0, - 0, - 0, - id="d0", - ), - pytest.param( - 1, - 0, - 0, - id="d1", - ), - pytest.param( - 2, - 0, - 0, - id="d2", - ), + pytest.param(Op.CALL, 0, id="call_insufficient_balance"), + pytest.param(Op.CALL, 1, id="call_topped_up_balance"), + pytest.param(Op.STATICCALL, 0, id="staticcall_write_protection"), ], ) -@pytest.mark.pre_alloc_mutable def test_create2no_cash( state_test: StateTestFiller, pre: Alloc, - fork: Fork, - d: int, - g: int, - v: int, + opcode: Op, + top_up: int, ) -> None: - """Create2 fails with not enough cash (endowment of a new account) +...""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0xE2B35478FDD26477CC576DD906E6277761246A3C) - sender = EOA( - key=0x45A915E4D060149EB4365960E6A7A45F334393093061116B197E3240065FF2D8 + """A CREATE2 endowment beyond the creator's balance cannot create.""" + # The creator holds one wei less than the endowment it attempts to + # transfer. Only the topped-up arm can afford it. + creator = pre.deploy_contract( + code=Op.POP( + Op.CREATE2(value=CREATE2_ENDOWMENT, offset=0x0, size=0x0, salt=0x0) + ) + + Op.STOP, + balance=CREATE2_ENDOWMENT - 1, ) - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=1000000, + # The entry contract forwards the transaction's value (the optional + # top-up) and records the call result, shifted so that a failed call + # (1), a successful call (2) and no call at all (0) all differ. + if opcode == Op.CALL: + call_code = Op.CALL(address=creator, value=top_up) + else: + call_code = Op.STATICCALL(address=creator) + entry = pre.deploy_contract( + code=Op.SSTORE(key=CALL_RESULT_SLOT, value=Op.ADD(0x1, call_code)) + + Op.STOP, ) - pre[sender] = Account(balance=0xDE0B6B3A7640000) - # Source: lll - # { (CREATE2 101 0 0 0) } - contract_0 = pre.deploy_contract( # noqa: F841 - code=Op.CREATE2(value=0x65, offset=0x0, size=0x0, salt=0x0) + Op.STOP, - balance=100, - nonce=0, - address=Address(0xE2B35478FDD26477CC576DD906E6277761246A3C), # noqa: E501 + tx = Transaction( + sender=pre.fund_eoa(), + to=entry, + value=top_up, + state_gas_reservoir=0, ) - expect_entries_: list[dict] = [ - { - "indexes": {"data": [0, 2], "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - contract_0: Account(balance=100), - Address( - 0x12AAEFBC0350A026228076E5369E6CE148CE67BE - ): Account.NONEXISTENT, - sender: Account(nonce=1), - }, - }, - { - "indexes": {"data": 1, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - contract_0: Account(balance=0), - Address(0x12AAEFBC0350A026228076E5369E6CE148CE67BE): Account( - balance=101 - ), - sender: Account(nonce=1), - }, - }, - ] - - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) - - tx_data = [ - Op.CALL( - gas=0x249F0, - address=contract_0, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - + Op.STOP, - Op.CALL( - gas=0x249F0, - address=contract_0, - value=0x1, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - + Op.STOP, - Op.STATICCALL( - gas=0x249F0, - address=contract_0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, + created = compute_create2_address(creator, 0, b"") + create_succeeds = opcode == Op.CALL and top_up > 0 + if create_succeeds: + # The whole (topped-up) balance moved into the created account, + # and the creator's nonce was consumed by the creation. + creator_account = Account(nonce=2, balance=0) + created_account: Account | None = Account( + nonce=1, code=b"", balance=CREATE2_ENDOWMENT ) - + Op.STOP, - ] - tx_gas = [400000] - tx_value = [1] + else: + # The balance preflight (or the static fault) aborts before any + # account is touched: no creation and no nonce bump. + creator_account = Account(nonce=1, balance=CREATE2_ENDOWMENT - 1) + created_account = Account.NONEXISTENT - tx = Transaction( - sender=sender, - to=None, - data=tx_data[d], - gas_limit=tx_gas[g], - value=tx_value[v], - error=_exc, - ) + # A static frame faults on CREATE2, so only that arm's call fails. + call_result = 0 if opcode == Op.STATICCALL else 1 + + post = { + entry: Account( + storage={CALL_RESULT_SLOT: 0x1 + call_result}, balance=0 + ), + creator: creator_account, + 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/stCreate2/test_create_message_reverted_oog_in_init2.py b/tests/ported_static/stCreate2/test_create_message_reverted_oog_in_init2.py index 4e4ef237ca4..b72962c1b80 100644 --- a/tests/ported_static/stCreate2/test_create_message_reverted_oog_in_init2.py +++ b/tests/ported_static/stCreate2/test_create_message_reverted_oog_in_init2.py @@ -1,127 +1,151 @@ """ -Create2 oog during the init code, + when create2 is from transaction... +Verify a CREATE2 issued from a contract-creation transaction's init +code: the transaction budget decides whether the CREATE2's child +completes its storage-writing init code or dies out of gas, while the +outer creation completes either way. Ported from: state_tests/stCreate2/CreateMessageRevertedOOGInInit2Filler.json + +@manually-enhanced: Do not overwrite. Both budgets are derived from fork +composites (intrinsic + top-frame state gas + the composed init code). +The outer created account is asserted on both arms with a pre-CREATE2 +canary, the child account's storage on the success arm, and the starved +child lands its first store before dying so its rollback is observable. """ import pytest from execution_testing import ( - EOA, Account, - Address, Alloc, - Environment, + Fork, + Op, 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, + compute_create2_address, + compute_create_address, ) REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +CANARY_SLOT = 0x2 +CANARY = 0xFF +# What the starved child has left after its first store: under the +# EIP-2200 stipend gate and far below a second fresh store. +STORE_SPARE = 1_000 + @pytest.mark.ported_from( ["state_tests/stCreate2/CreateMessageRevertedOOGInInit2Filler.json"], ) -@pytest.mark.valid_from("Cancun") +@pytest.mark.valid_from("Constantinople") @pytest.mark.parametrize( - "d, g, v", + "child_covered", [ - pytest.param( - 0, - 0, - 0, - id="-g0", - ), - pytest.param( - 0, - 1, - 0, - id="-g1", - ), + pytest.param(False, id="child_oog"), + pytest.param(True, id="child_succeeds"), ], ) -@pytest.mark.pre_alloc_mutable def test_create_message_reverted_oog_in_init2( state_test: StateTestFiller, pre: Alloc, fork: Fork, - d: int, - g: int, - v: int, + child_covered: bool, ) -> None: - """Create2 oog during the init code, + when create2 is from...""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B) - sender = EOA( - key=0x45A915E4D060149EB4365960E6A7A45F334393093061116B197E3240065FF2D8 + """The budget decides how far an init-code CREATE2's child gets.""" + # The child's init code writes two fresh slots and deposits nothing. + storage = Storage() + first_store = Op.SSTORE( + key=storage.store_next(0xC), + value=0xC, + key_warm=False, + original_value=0, + new_value=0xC, ) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=1000000000000, + second_store = Op.SSTORE( + key=storage.store_next(0xD), + value=0xD, + key_warm=False, + original_value=0, + new_value=0xD, ) + inner_initcode = first_store + second_store + assert len(inner_initcode) <= 0x20, "inner init code must fit one word" - pre[sender] = Account(balance=0x2DC6C0) - # Source: hex - # 0x - contract_0 = pre.deploy_contract( # noqa: F841 - code="", - balance=10, - nonce=0, - address=Address(0xB94F5374FCE5EDBC8E2A8697C15331677E6EBF0B), # noqa: E501 + # The outer init code writes a completion canary before the CREATE2 + # (only STOP runs after it: the retention after a failed child is + # not budgeted for a store), stages the child's init code in memory + # and runs the CREATE2. It deposits no code. + canary_store = Op.SSTORE( + key=CANARY_SLOT, + value=CANARY, + key_warm=False, + original_value=0, + new_value=CANARY, ) + setup = Op.MSTORE( + offset=0x0, + value=int.from_bytes(inner_initcode, "big"), + new_memory_size=0x20, + ) + create2_code = Op.CREATE2( + value=0x0, + offset=0x20 - len(inner_initcode), + size=len(inner_initcode), + salt=0x0, + new_memory_size=0x20, + old_memory_size=0x20, + init_code_size=len(inner_initcode), + ) + outer_initcode = canary_store + setup + create2_code + Op.STOP - expect_entries_: list[dict] = [ - { - "indexes": {"data": -1, "gas": 0, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - Address( - 0xF3059E18A327C662766F6BA11808C400635847EF - ): Account.NONEXISTENT, - }, - }, - { - "indexes": {"data": -1, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - sender: Account(nonce=1), - Address(0xF3059E18A327C662766F6BA11808C400635847EF): Account( - storage={0: 12, 1: 13}, balance=0, nonce=1 - ), - }, - }, - ] - - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) - - tx_data = [ - Op.MSTORE(offset=0x0, value=0x600C600055600D600155) - + Op.CREATE2(value=0x0, offset=0x16, size=0xA, salt=0x0) - + Op.STOP, - ] - tx_gas = [110000, 150000] - tx_value = [100] + # Both budgets derive from the same overhead: everything the outer + # frame pays before (and including) the CREATE2's own charges. The + # child's grant is what remains after the 1/64 withhold. + overhead = ( + fork.transaction_intrinsic_cost_calculator()( + calldata=outer_initcode, + contract_creation=True, + return_cost_deducted_prior_execution=True, + ) + + fork.transaction_top_frame_state_gas(contract_creation=True) + + outer_initcode.gas_cost(fork) + ) + child_needed = inner_initcode.gas_cost(fork) + if child_covered: + gas_limit = overhead + -(-child_needed * 64 // 63) + 3_000 + else: + # The child lands its first store and dies on the second, so a + # child that leaked instead of rolling back would show a slot. + child_grant = first_store.gas_cost(fork) + STORE_SPARE + assert STORE_SPARE < second_store.gas_cost(fork), "child must die" + assert STORE_SPARE <= fork.gas_costs().CALL_STIPEND, "child must die" + gas_limit = overhead + -(-child_grant * 64 // 63) + sender = pre.fund_eoa() tx = Transaction( sender=sender, to=None, - data=tx_data[d], - gas_limit=tx_gas[g], - value=tx_value[v], - error=_exc, + data=outer_initcode, + gas_limit=gas_limit, ) - state_test(env=env, pre=pre, post=post, tx=tx) + outer_created = compute_create_address(address=sender, nonce=0) + child = compute_create2_address(outer_created, 0, inner_initcode) + post = { + sender: Account(nonce=1), + # The outer creation completes on both arms: the CREATE2 always + # bumps its nonce, and a failed child costs it only the grant. + outer_created: Account( + nonce=2, + code=b"", + storage={CANARY_SLOT: CANARY}, + ), + child: Account(nonce=1, code=b"", balance=0, storage=storage) + if child_covered + else Account.NONEXISTENT, + } + + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stCreate2/test_revert_depth_create2_oog.py b/tests/ported_static/stCreate2/test_revert_depth_create2_oog.py index 00ddb3595a4..2c7d8e86731 100644 --- a/tests/ported_static/stCreate2/test_revert_depth_create2_oog.py +++ b/tests/ported_static/stCreate2/test_revert_depth_create2_oog.py @@ -1,200 +1,240 @@ """ -Test_revert_depth_create2_oog. +Verify a CREATE2 two frames deep under out-of-gas pressure: the calldata +sets the grant a caller forwards to a creating contract, and the two +budgets decide whether the creation stands, the creator dies after it, +or the whole outer frame runs dry, each with a distinct post-state. Ported from: state_tests/stCreate2/RevertDepthCreate2OOGFiller.json +state_tests/stCreate2/RevertDepthCreate2OOGBerlinFiller.json + +@manually-enhanced: Do not overwrite. The byte-identical Berlin twin is +folded in and every budget derives from fork composites. The creator +stores the CREATE2 result so a wrongly failed (or wrongly succeeding) +creation is visible beyond the created account, and every starved arm +completes the CREATE2 before running dry, so the rollback of a finished +creation is what each of them pins. """ import pytest from execution_testing import ( - EOA, Account, - Address, Alloc, - Environment, + Fork, Hash, StateTestFiller, Transaction, + compute_create2_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" +# Caller slots (as in the ported filler). +CALLER_START_SLOT = 0x0 +CALL_RESULT_SLOT = 0x1 +CALLER_DONE_SLOT = 0x4 +# Creator slots: 0x2/0x3 as ported, plus the CREATE2 result. +CREATOR_START_SLOT = 0x2 +CREATOR_DONE_SLOT = 0x3 +CREATE2_RESULT_SLOT = 0x5 + +# Gas the covered creator has left over after its completion marker. +CREATOR_SPARE = 1_000 + @pytest.mark.ported_from( - ["state_tests/stCreate2/RevertDepthCreate2OOGFiller.json"], + [ + "state_tests/stCreate2/RevertDepthCreate2OOGFiller.json", + "state_tests/stCreate2/RevertDepthCreate2OOGBerlinFiller.json", + ], ) -@pytest.mark.valid_from("Cancun") +@pytest.mark.valid_from("Constantinople") @pytest.mark.parametrize( - "d, g, v", + "creator_covered", [ - pytest.param( - 0, - 0, - 0, - id="d0-g0-v0", - ), - pytest.param( - 0, - 0, - 1, - id="d0-g0-v1", - ), - pytest.param( - 0, - 1, - 0, - id="d0-g1-v0", - ), - pytest.param( - 0, - 1, - 1, - id="d0-g1-v1", - ), - pytest.param( - 1, - 0, - 0, - id="d1-g0-v0", - ), - pytest.param( - 1, - 0, - 1, - id="d1-g0-v1", - ), - pytest.param( - 1, - 1, - 0, - id="d1-g1-v0", - ), - pytest.param( - 1, - 1, - 1, - id="d1-g1-v1", - ), + pytest.param(False, id="creator_oog"), + pytest.param(True, id="creator_ok"), ], ) -@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize( + "outer_covered", + [ + pytest.param(False, id="outer_oog"), + pytest.param(True, id="outer_ok"), + ], +) +@pytest.mark.parametrize("tx_value", [1, 0]) def test_revert_depth_create2_oog( state_test: StateTestFiller, pre: Alloc, fork: Fork, - d: int, - g: int, - v: int, + creator_covered: bool, + outer_covered: bool, + tx_value: int, ) -> None: - """Test_revert_depth_create2_oog.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0xA000000000000000000000000000000000000000) - contract_1 = Address(0xB000000000000000000000000000000000000000) - sender = EOA( - key=0x45A915E4D060149EB4365960E6A7A45F334393093061116B197E3240065FF2D8 + """Two stacked budgets decide how deep a nested CREATE2 gets.""" + # The creator: entry marker, an empty-init-code CREATE2 whose result + # is stored (success leaves the created address plus one, a failure + # leaves exactly one), and a completion marker. + sstore_2 = Op.SSTORE( + key=CREATOR_START_SLOT, + value=0x8, + key_warm=False, + original_value=0, + new_value=0x8, + ) + result_store = Op.SSTORE( + key=CREATE2_RESULT_SLOT, + value=Op.ADD( + 0x1, Op.CREATE2(value=0x0, offset=0x0, size=0x0, salt=0x0) + ), + key_warm=False, + original_value=0, + new_value=0x1, + ) + sstore_3 = Op.SSTORE( + key=CREATOR_DONE_SLOT, + value=0xC, + key_warm=False, + original_value=0, + new_value=0xC, + ) + creator = pre.deploy_contract( + code=sstore_2 + result_store + sstore_3 + Op.STOP + ) + + # The empty-init-code child consumes nothing and returns its whole + # grant, so the creator's needs are just its composite costs. A + # starved creator gets through the CREATE2 and the result store and + # dies on its completion marker, which it can only half afford. + creator_needed = ( + sstore_2.gas_cost(fork) + + result_store.gas_cost(fork) + + sstore_3.gas_cost(fork) ) + if creator_covered: + forwarded = creator_needed + CREATOR_SPARE + else: + forwarded = creator_needed - sstore_3.gas_cost(fork) // 2 - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, + # The caller: entry marker, the CALL with its grant taken from + # calldata (as in the ported filler), result store, completion + # marker. + sstore_0 = Op.SSTORE( + key=CALLER_START_SLOT, + value=0x1, + key_warm=False, + original_value=0, + new_value=0x1, ) + call_code = Op.CALL( + gas=Op.CALLDATALOAD(offset=0x0), + address=creator, + address_warm=False, + value_transfer=False, + account_new=False, + ) + sstore_1 = Op.SSTORE( + key=CALL_RESULT_SLOT, + value=call_code, + key_warm=False, + original_value=0, + new_value=0x1, + ) + sstore_4 = Op.SSTORE( + key=CALLER_DONE_SLOT, + value=0xC, + key_warm=False, + original_value=0, + new_value=0xC, + ) + caller_code = sstore_0 + sstore_1 + sstore_4 + Op.STOP + caller = pre.deploy_contract(code=caller_code) - pre[sender] = Account(balance=0xE8D4A51000) - # Source: lll - # { [[2]] 8 (CREATE2 0 0 0 0) [[3]] 12} - contract_1 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x2, value=0x8) - + Op.POP(Op.CREATE2(value=0x0, offset=0x0, size=0x0, salt=0x0)) - + Op.SSTORE(key=0x3, value=0xC) - + Op.STOP, - nonce=0, - address=Address(0xB000000000000000000000000000000000000000), # noqa: E501 + tx_data = Hash(forwarded) + overhead = fork.transaction_intrinsic_cost_calculator()( + calldata=tx_data, + sends_value=tx_value > 0, + return_cost_deducted_prior_execution=True, + ) + sstore_0.gas_cost(fork) + # Enough at the CALL that the EIP-150 clamp still grants the full + # ask. + available = -(-forwarded * 64 // 63) + 64 + assert available - available // 64 >= forwarded, ( + "the full ask must be granted" ) - # Source: lll - # { [[0]] 1 [[1]] (CALL (CALLDATALOAD 0) 0xb000000000000000000000000000000000000000 0 0 0 0 0) [[4]] 12 } # noqa: E501 - contract_0 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE( - key=0x1, - value=Op.CALL( - gas=Op.CALLDATALOAD(offset=0x0), - address=contract_1, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), + if outer_covered: + gas_limit = ( + overhead + + sstore_1.gas_cost(fork) + + sstore_4.gas_cost(fork) + + available ) - + Op.SSTORE(key=0x4, value=0xC) - + Op.STOP, - balance=5, - nonce=54, - address=Address(0xA000000000000000000000000000000000000000), # noqa: E501 + else: + # Nothing is budgeted for the caller's post-call stores: what the + # 1/64 retention and the creator's spare add up to cannot pay the + # result store, so the caller dies after the creator returns. + store_cost = sstore_1.gas_cost(fork) - call_code.gas_cost(fork) + assert available // 64 + CREATOR_SPARE < store_cost, ( + "the retention must not afford the post-call store" + ) + gas_limit = overhead + available + + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=caller, + data=tx_data, + gas_limit=gas_limit, + value=tx_value, ) - expect_entries_: list[dict] = [ - { - "indexes": {"data": 1, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - Address(0x05A28FC366483258507BCF739658573CB47E4FAD): Account( - nonce=1 - ), - contract_0: Account(storage={0: 1, 1: 1, 4: 12}), - contract_1: Account(storage={2: 8, 3: 12}), + created = compute_create2_address(creator, 0, b"") + if not outer_covered: + # The whole transaction ran dry after the CREATE2: only the code + # survives. + caller_account = Account(storage={}, code=caller_code, balance=0) + creator_account = Account(storage={}, nonce=1) + created_account: Account | None = Account.NONEXISTENT + elif not creator_covered: + # The creator died after its CREATE2 and was rolled back with + # the creation and its nonce bump. + caller_account = Account( + storage={ + CALLER_START_SLOT: 0x1, + CALL_RESULT_SLOT: 0x0, + CALLER_DONE_SLOT: 0xC, }, - }, - { - "indexes": {"data": 0, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - Address( - 0x05A28FC366483258507BCF739658573CB47E4FAD - ): Account.NONEXISTENT, - contract_0: Account(storage={0: 1, 4: 12}), - contract_1: Account(storage={}), + balance=tx_value, + ) + creator_account = Account(storage={}, nonce=1) + created_account = Account.NONEXISTENT + else: + caller_account = Account( + storage={ + CALLER_START_SLOT: 0x1, + CALL_RESULT_SLOT: 0x1, + CALLER_DONE_SLOT: 0xC, }, - }, - { - "indexes": {"data": [0, 1], "gas": 0, "value": -1}, - "network": [">=Cancun"], - "result": { - Address( - 0x05A28FC366483258507BCF739658573CB47E4FAD - ): Account.NONEXISTENT, - contract_0: Account(storage={}), - contract_1: Account(storage={}), + balance=tx_value, + ) + creator_account = Account( + storage={ + CREATOR_START_SLOT: 0x8, + CREATE2_RESULT_SLOT: int.from_bytes(bytes(created), "big") + 1, + CREATOR_DONE_SLOT: 0xC, }, - }, - ] - - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) - - tx_data = [ - Hash(0xEA60), - Hash(0x1EA60), - ] - tx_gas = [110000, 170000] - tx_value = [1, 0] + nonce=2, + ) + created_account = Account(nonce=1, code=b"", balance=0) - tx = Transaction( - sender=sender, - to=contract_0, - data=tx_data[d], - gas_limit=tx_gas[g], - value=tx_value[v], - error=_exc, - ) + post = { + sender: Account(nonce=1), + caller: caller_account, + creator: creator_account, + 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/stCreate2/test_revert_depth_create2_oog_berlin.py b/tests/ported_static/stCreate2/test_revert_depth_create2_oog_berlin.py deleted file mode 100644 index c15b8760d49..00000000000 --- a/tests/ported_static/stCreate2/test_revert_depth_create2_oog_berlin.py +++ /dev/null @@ -1,200 +0,0 @@ -""" -Test_revert_depth_create2_oog_berlin. - -Ported from: -state_tests/stCreate2/RevertDepthCreate2OOGBerlinFiller.json -""" - -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" - - -@pytest.mark.ported_from( - ["state_tests/stCreate2/RevertDepthCreate2OOGBerlinFiller.json"], -) -@pytest.mark.valid_from("Cancun") -@pytest.mark.parametrize( - "d, g, v", - [ - pytest.param( - 0, - 0, - 0, - id="d0-g0-v0", - ), - pytest.param( - 0, - 0, - 1, - id="d0-g0-v1", - ), - pytest.param( - 0, - 1, - 0, - id="d0-g1-v0", - ), - pytest.param( - 0, - 1, - 1, - id="d0-g1-v1", - ), - pytest.param( - 1, - 0, - 0, - id="d1-g0-v0", - ), - pytest.param( - 1, - 0, - 1, - id="d1-g0-v1", - ), - pytest.param( - 1, - 1, - 0, - id="d1-g1-v0", - ), - pytest.param( - 1, - 1, - 1, - id="d1-g1-v1", - ), - ], -) -@pytest.mark.pre_alloc_mutable -def test_revert_depth_create2_oog_berlin( - state_test: StateTestFiller, - pre: Alloc, - fork: Fork, - d: int, - g: int, - v: int, -) -> None: - """Test_revert_depth_create2_oog_berlin.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0xA000000000000000000000000000000000000000) - contract_1 = Address(0xB000000000000000000000000000000000000000) - 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=0xE8D4A51000) - # Source: lll - # { [[2]] 8 (CREATE2 0 0 0 0) [[3]] 12} - contract_1 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x2, value=0x8) - + Op.POP(Op.CREATE2(value=0x0, offset=0x0, size=0x0, salt=0x0)) - + Op.SSTORE(key=0x3, value=0xC) - + Op.STOP, - nonce=0, - address=Address(0xB000000000000000000000000000000000000000), # noqa: E501 - ) - # Source: lll - # { [[0]] 1 [[1]] (CALL (CALLDATALOAD 0) 0xb000000000000000000000000000000000000000 0 0 0 0 0) [[4]] 12 } # noqa: E501 - contract_0 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE( - key=0x1, - value=Op.CALL( - gas=Op.CALLDATALOAD(offset=0x0), - address=contract_1, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.SSTORE(key=0x4, value=0xC) - + Op.STOP, - balance=5, - nonce=54, - address=Address(0xA000000000000000000000000000000000000000), # noqa: E501 - ) - - expect_entries_: list[dict] = [ - { - "indexes": {"data": 1, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - Address(0x05A28FC366483258507BCF739658573CB47E4FAD): Account( - nonce=1 - ), - contract_0: Account(storage={0: 1, 1: 1, 4: 12}), - contract_1: Account(storage={2: 8, 3: 12}), - }, - }, - { - "indexes": {"data": 0, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - Address( - 0x05A28FC366483258507BCF739658573CB47E4FAD - ): Account.NONEXISTENT, - contract_0: Account(storage={0: 1, 4: 12}), - contract_1: Account(storage={}), - }, - }, - { - "indexes": {"data": [0, 1], "gas": 0, "value": -1}, - "network": [">=Cancun"], - "result": { - Address( - 0x05A28FC366483258507BCF739658573CB47E4FAD - ): Account.NONEXISTENT, - contract_0: Account(storage={}), - contract_1: Account(storage={}), - }, - }, - ] - - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) - - tx_data = [ - Hash(0xEA60), - Hash(0x1EA60), - ] - tx_gas = [110000, 170000] - tx_value = [1, 0] - - tx = Transaction( - sender=sender, - to=contract_0, - 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/stCreate2/test_revert_depth_create_address_collision.py b/tests/ported_static/stCreate2/test_revert_depth_create_address_collision.py index 38873c202b1..7400835ad66 100644 --- a/tests/ported_static/stCreate2/test_revert_depth_create_address_collision.py +++ b/tests/ported_static/stCreate2/test_revert_depth_create_address_collision.py @@ -1,222 +1,265 @@ """ -Copy of this test for CREATE2. +Verify a CREATE2 that collides with a live account, its own caller: the +collision burns the child's grant and bumps the creator's nonce without +creating anything, and the two stacked budgets decide whether the +creator survives its aftermath, dies on it, or the whole outer frame +runs dry after it. Ported from: state_tests/stCreate2/RevertDepthCreateAddressCollisionFiller.json +state_tests/stCreate2/RevertDepthCreateAddressCollisionBerlinFiller.json -@manually-enhanced: Do not overwrite. `tx_gas` raised on Amsterdam to -cover EIP-8037 NEW_ACCOUNT state-gas spill on the CREATE2-via-revert -path. Pre-EIP-8037 keeps the original [110_000, 170_000] tuned budgets; -post-state expectations unchanged on all forks. - +@manually-enhanced: Do not overwrite. The byte-identical Berlin twin is +folded in, and the legacy fillers' vacancy is repaired: they kept the +collider at contract_1's CREATE address while the code runs CREATE2, so +nothing ever collided. The caller now occupies the CREATE2 target, the +creator pre-writes its result slot so the post-collision store is a +dirty-warm write its 1/64 retention can afford, every arm reaches the +collision, and all budgets derive from fork composites. """ import pytest from execution_testing import ( - EOA, Account, - Address, Alloc, - Environment, + Fork, Hash, StateTestFiller, Transaction, + compute_create2_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" +# Caller slots (as in the ported filler). +CALLER_START_SLOT = 0x0 +CALL_RESULT_SLOT = 0x1 +CALLER_DONE_SLOT = 0x4 +# Creator slots: 0x2 as ported, plus the pre-written CREATE2 result. +CREATOR_START_SLOT = 0x2 +CREATE2_RESULT_SLOT = 0x5 +# Pre-written sentinel, overwritten by the CREATE2 result plus one: a +# surviving creator must show 1 (collision), never 0xFF or an address. +RESULT_PREWRITE = 0xFF + +# Post-collision slack for the starved-creator arm: retains under 1/64th +# of the EIP-2200 stipend, so the result store cannot run. +STARVED_SLACK = 10_000 +# What the covered creator keeps after its result store, on top of the +# EIP-2200 stipend gate that store must clear. +RETENTION_MARGIN = 100 + @pytest.mark.ported_from( - ["state_tests/stCreate2/RevertDepthCreateAddressCollisionFiller.json"], + [ + "state_tests/stCreate2/RevertDepthCreateAddressCollisionFiller.json", + "state_tests/stCreate2/RevertDepthCreateAddressCollisionBerlinFiller.json", # noqa: E501 + ], +) +@pytest.mark.valid_from("Constantinople") +@pytest.mark.parametrize( + "creator_covered", + [ + pytest.param(False, id="creator_oog"), + pytest.param(True, id="creator_ok"), + ], ) -@pytest.mark.valid_from("Cancun") @pytest.mark.parametrize( - "d, g, v", + "outer_covered", [ - pytest.param( - 0, - 0, - 0, - id="d0-g0-v0", - ), - pytest.param( - 0, - 0, - 1, - id="d0-g0-v1", - ), - pytest.param( - 0, - 1, - 0, - id="d0-g1-v0", - ), - pytest.param( - 0, - 1, - 1, - id="d0-g1-v1", - ), - pytest.param( - 1, - 0, - 0, - id="d1-g0-v0", - ), - pytest.param( - 1, - 0, - 1, - id="d1-g0-v1", - ), - pytest.param( - 1, - 1, - 0, - id="d1-g1-v0", - ), - pytest.param( - 1, - 1, - 1, - id="d1-g1-v1", - ), + pytest.param(False, id="outer_oog"), + pytest.param(True, id="outer_ok"), ], ) +@pytest.mark.parametrize("tx_value", [1, 0]) @pytest.mark.pre_alloc_mutable def test_revert_depth_create_address_collision( state_test: StateTestFiller, pre: Alloc, fork: Fork, - d: int, - g: int, - v: int, + creator_covered: bool, + outer_covered: bool, + tx_value: int, ) -> None: - """Copy of this test for CREATE2.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0x3E180B1862F9D158ABB5E519A6D8605540C23682) - contract_1 = Address(0xB000000000000000000000000000000000000000) - sender = EOA( - key=0x45A915E4D060149EB4365960E6A7A45F334393093061116B197E3240065FF2D8 + """A CREATE2 collision burns the grant and budgets decide the rest.""" + # The creator: entry marker, result-slot pre-write, then the CREATE2 + # aimed at the caller's (occupied) address, whose result overwrites + # the sentinel as a dirty-warm store the 1/64 retention can pay. + sstore_2 = Op.SSTORE( + key=CREATOR_START_SLOT, + value=0x8, + key_warm=False, + original_value=0, + new_value=0x8, + ) + sentinel_store = Op.SSTORE( + key=CREATE2_RESULT_SLOT, + value=RESULT_PREWRITE, + key_warm=False, + original_value=0, + new_value=RESULT_PREWRITE, + ) + create2_code = Op.CREATE2( + value=0x0, + offset=0x0, + size=0x0, + salt=0x0, + account_new=False, + ) + result_store = Op.SSTORE( + key=CREATE2_RESULT_SLOT, + value=Op.ADD(0x1, create2_code), + key_warm=True, + original_value=0, + current_value=RESULT_PREWRITE, + new_value=0x1, + ) + creator = pre.deploy_contract( + code=sstore_2 + sentinel_store + result_store + Op.STOP ) - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, + # The caller occupies the creator's CREATE2 target. This is the + # repaired collision, and why the pre allocation must stay mutable. + sstore_0 = Op.SSTORE( + key=CALLER_START_SLOT, + value=0x1, + key_warm=False, + original_value=0, + new_value=0x1, + ) + call_code = Op.CALL( + gas=Op.CALLDATALOAD(offset=0x0), + address=creator, + address_warm=False, + value_transfer=False, + account_new=False, + ) + sstore_1 = Op.SSTORE( + key=CALL_RESULT_SLOT, + value=call_code, + key_warm=False, + original_value=0, + new_value=0x1, + ) + sstore_4 = Op.SSTORE( + key=CALLER_DONE_SLOT, + value=0xC, + key_warm=False, + original_value=0, + new_value=0xC, ) + caller_code = sstore_0 + sstore_1 + sstore_4 + Op.STOP + caller = compute_create2_address(creator, 0, b"") + pre.deploy_contract(code=caller_code, address=caller) - pre[sender] = Account(balance=0xE8D4A51000) - # Source: lll - # { [[2]] 8 (CREATE2 0 0 0 0) [[3]] 12} - contract_1 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x2, value=0x8) - + Op.POP(Op.CREATE2(value=0x0, offset=0x0, size=0x0, salt=0x0)) - + Op.SSTORE(key=0x3, value=0xC) - + Op.STOP, - nonce=0, - address=Address(0xB000000000000000000000000000000000000000), # noqa: E501 + # The collision consumes everything left after the CREATE2's charges + # but one 64th (the live target means no new-account state gas moves + # in either direction), so the creator's fate is set by the slack + # riding on top of its pre-collision costs. + create2_charge = create2_code.gas_cost(fork) + result_tail = result_store.gas_cost(fork) - create2_charge + stipend = fork.gas_costs().CALL_STIPEND + if creator_covered: + slack = 64 * (stipend + result_tail + RETENTION_MARGIN) + else: + slack = STARVED_SLACK + assert slack // 64 <= stipend, ( + "the retention must not afford the result store" + ) + forwarded = ( + sstore_2.gas_cost(fork) + + sentinel_store.gas_cost(fork) + + create2_charge + + slack + ) + + tx_data = Hash(forwarded) + overhead = fork.transaction_intrinsic_cost_calculator()( + calldata=tx_data, + sends_value=tx_value > 0, + return_cost_deducted_prior_execution=True, + ) + sstore_0.gas_cost(fork) + # Enough at the CALL that the EIP-150 clamp still grants the full + # ask. + available = -(-forwarded * 64 // 63) + 64 + assert available - available // 64 >= forwarded, ( + "the full ask must be granted" ) - # Source: lll - # { [[0]] 1 [[1]] (CALL (CALLDATALOAD 0) 0xb000000000000000000000000000000000000000 0 0 0 0 0) [[4]] 12 } # noqa: E501 - contract_0 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE( - key=0x1, - value=Op.CALL( - gas=Op.CALLDATALOAD(offset=0x0), - address=0xB000000000000000000000000000000000000000, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), + if outer_covered: + gas_limit = ( + overhead + + sstore_1.gas_cost(fork) + + sstore_4.gas_cost(fork) + + available ) - + Op.SSTORE(key=0x4, value=0xC) - + Op.STOP, - balance=5, - nonce=54, - address=Address(0x3E180B1862F9D158ABB5E519A6D8605540C23682), # noqa: E501 + else: + # Nothing is budgeted for the caller's post-call stores: what the + # 1/64 retention and a surviving creator's leftovers add up to + # cannot pay the completion marker, so the caller dies after the + # collision. + creator_spare = stipend + RETENTION_MARGIN if creator_covered else 0 + assert available // 64 + creator_spare < sstore_4.gas_cost(fork), ( + "the retention must not afford the post-call stores" + ) + gas_limit = overhead + available + + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=caller, + data=tx_data, + gas_limit=gas_limit, + value=tx_value, ) - expect_entries_: list[dict] = [ - { - "indexes": {"data": 1, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - contract_0: Account(storage={0: 1, 1: 1, 4: 12}, nonce=54), - contract_1: Account(storage={2: 8, 3: 12}), - }, - }, - { - "indexes": {"data": 0, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - contract_0: Account(storage={0: 1, 4: 12}, nonce=54), - contract_1: Account(storage={}), + if not outer_covered: + # The whole transaction ran dry after the collision: only the + # code survives. + caller_account = Account( + storage={}, code=caller_code, balance=0, nonce=1 + ) + creator_account = Account(storage={}, nonce=1) + elif not creator_covered: + # The creator reached the collision but died on its aftermath + # and was rolled back, including the collision's nonce bump. + caller_account = Account( + storage={ + CALLER_START_SLOT: 0x1, + CALL_RESULT_SLOT: 0x0, + CALLER_DONE_SLOT: 0xC, }, - }, - { - "indexes": {"data": 1, "gas": 0, "value": -1}, - "network": [">=Cancun"], - "result": { - contract_0: Account( - storage={}, - code=bytes.fromhex( - "60016000556000600060006000600073b000000000000000000000000000000000000000600035f1600155600c60045500" # noqa: E501 - ), - balance=5, - nonce=54, - ), - contract_1: Account(storage={}), + code=caller_code, + balance=tx_value, + nonce=1, + ) + creator_account = Account(storage={}, nonce=1) + else: + # The collision's signature: a nonce bump with nothing created, + # a zero CREATE2 result, and the caller's account untouched. + caller_account = Account( + storage={ + CALLER_START_SLOT: 0x1, + CALL_RESULT_SLOT: 0x1, + CALLER_DONE_SLOT: 0xC, }, - }, - { - "indexes": {"data": 0, "gas": 0, "value": -1}, - "network": [">=Cancun"], - "result": { - contract_0: Account( - storage={}, - code=bytes.fromhex( - "60016000556000600060006000600073b000000000000000000000000000000000000000600035f1600155600c60045500" # noqa: E501 - ), - nonce=54, - ), - contract_1: Account(storage={}), + code=caller_code, + balance=tx_value, + nonce=1, + ) + creator_account = Account( + storage={ + CREATOR_START_SLOT: 0x8, + CREATE2_RESULT_SLOT: 0x1, }, - }, - ] - - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) - - tx_data = [ - Hash(0xEA60), - Hash(0x1EA60), - ] - # EIP-8037 NEW_ACCOUNT state-gas spill on Amsterdam exceeds the - # original tuned tx_gas budgets; pre-EIP-8037 keeps the originals. - tx_gas = [110000, 170000] - if fork.is_eip_enabled(8037): - tx_gas = [500_000, 700_000] - tx_value = [1, 0] + nonce=2, + ) - tx = Transaction( - sender=sender, - to=contract_0, - data=tx_data[d], - gas_limit=tx_gas[g], - value=tx_value[v], - error=_exc, - ) + post = { + sender: Account(nonce=1), + caller: caller_account, + creator: creator_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/stCreate2/test_revert_depth_create_address_collision_berlin.py b/tests/ported_static/stCreate2/test_revert_depth_create_address_collision_berlin.py deleted file mode 100644 index 907358ab624..00000000000 --- a/tests/ported_static/stCreate2/test_revert_depth_create_address_collision_berlin.py +++ /dev/null @@ -1,224 +0,0 @@ -""" -Copy of this test for CREATE2. - -Ported from: -state_tests/stCreate2/RevertDepthCreateAddressCollisionBerlinFiller.json - -@manually-enhanced: Do not overwrite. `tx_gas` raised on Amsterdam to -cover EIP-8037 NEW_ACCOUNT state-gas spill on the CREATE2-via-revert -path. Pre-EIP-8037 keeps the original [110_000, 170_000] tuned budgets; -post-state expectations unchanged on all forks. - -""" - -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" - - -@pytest.mark.ported_from( - [ - "state_tests/stCreate2/RevertDepthCreateAddressCollisionBerlinFiller.json" # noqa: E501 - ], -) -@pytest.mark.valid_from("Cancun") -@pytest.mark.parametrize( - "d, g, v", - [ - pytest.param( - 0, - 0, - 0, - id="d0-g0-v0", - ), - pytest.param( - 0, - 0, - 1, - id="d0-g0-v1", - ), - pytest.param( - 0, - 1, - 0, - id="d0-g1-v0", - ), - pytest.param( - 0, - 1, - 1, - id="d0-g1-v1", - ), - pytest.param( - 1, - 0, - 0, - id="d1-g0-v0", - ), - pytest.param( - 1, - 0, - 1, - id="d1-g0-v1", - ), - pytest.param( - 1, - 1, - 0, - id="d1-g1-v0", - ), - pytest.param( - 1, - 1, - 1, - id="d1-g1-v1", - ), - ], -) -@pytest.mark.pre_alloc_mutable -def test_revert_depth_create_address_collision_berlin( - state_test: StateTestFiller, - pre: Alloc, - fork: Fork, - d: int, - g: int, - v: int, -) -> None: - """Copy of this test for CREATE2.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0x3E180B1862F9D158ABB5E519A6D8605540C23682) - contract_1 = Address(0xB000000000000000000000000000000000000000) - 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=0xE8D4A51000) - # Source: lll - # { [[2]] 8 (CREATE2 0 0 0 0) [[3]] 12} - contract_1 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x2, value=0x8) - + Op.POP(Op.CREATE2(value=0x0, offset=0x0, size=0x0, salt=0x0)) - + Op.SSTORE(key=0x3, value=0xC) - + Op.STOP, - nonce=0, - address=Address(0xB000000000000000000000000000000000000000), # noqa: E501 - ) - # Source: lll - # { [[0]] 1 [[1]] (CALL (CALLDATALOAD 0) 0xb000000000000000000000000000000000000000 0 0 0 0 0) [[4]] 12 } # noqa: E501 - contract_0 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE( - key=0x1, - value=Op.CALL( - gas=Op.CALLDATALOAD(offset=0x0), - address=0xB000000000000000000000000000000000000000, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.SSTORE(key=0x4, value=0xC) - + Op.STOP, - balance=5, - nonce=54, - address=Address(0x3E180B1862F9D158ABB5E519A6D8605540C23682), # noqa: E501 - ) - - expect_entries_: list[dict] = [ - { - "indexes": {"data": 1, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - contract_0: Account(storage={0: 1, 1: 1, 4: 12}, nonce=54), - contract_1: Account(storage={2: 8, 3: 12}), - }, - }, - { - "indexes": {"data": 0, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - contract_0: Account(storage={0: 1, 4: 12}, nonce=54), - contract_1: Account(storage={}), - }, - }, - { - "indexes": {"data": 1, "gas": 0, "value": -1}, - "network": [">=Cancun"], - "result": { - contract_0: Account( - storage={}, - code=bytes.fromhex( - "60016000556000600060006000600073b000000000000000000000000000000000000000600035f1600155600c60045500" # noqa: E501 - ), - balance=5, - nonce=54, - ), - contract_1: Account(storage={}), - }, - }, - { - "indexes": {"data": 0, "gas": 0, "value": -1}, - "network": [">=Cancun"], - "result": { - contract_0: Account( - storage={}, - code=bytes.fromhex( - "60016000556000600060006000600073b000000000000000000000000000000000000000600035f1600155600c60045500" # noqa: E501 - ), - nonce=54, - ), - contract_1: Account(storage={}), - }, - }, - ] - - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) - - tx_data = [ - Hash(0xEA60), - Hash(0x1EA60), - ] - # EIP-8037 NEW_ACCOUNT state-gas spill on Amsterdam exceeds the - # original tuned tx_gas budgets; pre-EIP-8037 keeps the originals. - tx_gas = [110000, 170000] - if fork.is_eip_enabled(8037): - tx_gas = [500_000, 700_000] - tx_value = [1, 0] - - tx = Transaction( - sender=sender, - to=contract_0, - 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/stRefundTest/test_refund50_2.py b/tests/ported_static/stRefundTest/test_refund50_2.py index 4d471f24074..c0c04b6c6ba 100644 --- a/tests/ported_static/stRefundTest/test_refund50_2.py +++ b/tests/ported_static/stRefundTest/test_refund50_2.py @@ -1,17 +1,21 @@ """ -Test_refund50_2. +Verify the refund cap over five storage clears: the sender's final +balance reflects the executed gas minus the capped refund. Ported from: state_tests/stRefundTest/refund50_2Filler.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 ( Account, - Address, Alloc, - Bytes, - Environment, + Fork, StateTestFiller, Transaction, ) @@ -20,57 +24,69 @@ REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +INITIAL_BALANCE = 10**18 +GAS_PRICE = 10 + @pytest.mark.ported_from( ["state_tests/stRefundTest/refund50_2Filler.json"], ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable +@pytest.mark.valid_from("Berlin") def test_refund50_2( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: - """Test_refund50_2.""" - coinbase = Address(0xEB201D2887816E041F6E807E804F64F3A7A226FE) - sender = pre.fund_eoa(amount=0x989680) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=1000000, + """Five storage clears refund gas up to the fork's cap.""" + code = ( + Op.SSTORE( + key=0xA, value=0x1, key_warm=False, original_value=0, new_value=1 + ) + + Op.SSTORE( + key=0xB, value=0x1, key_warm=False, original_value=0, new_value=1 + ) + + Op.SSTORE( + key=0x1, value=0x0, key_warm=False, original_value=1, new_value=0 + ) + + Op.SSTORE( + key=0x2, value=0x0, key_warm=False, 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 + ) ) - - pre[coinbase] = Account(balance=0, nonce=1) - # Source: lll - # { [[ 10 ]] 1 [[ 11 ]] 1 [[ 1 ]] 0 [[ 2 ]] 0 [[ 3 ]] 0 [[ 4 ]] 0 [[ 5 ]] 0 } # noqa: E501 - target = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0xA, value=0x1) - + Op.SSTORE(key=0xB, value=0x1) - + 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.STOP, + target = pre.deploy_contract( + code=code + Op.STOP, storage={1: 1, 2: 1, 3: 1, 4: 1, 5: 1}, - balance=0xDE0B6B3A7640000, - nonce=0, ) + intrinsic = fork.transaction_intrinsic_cost_calculator()() + executed = intrinsic + code.gas_cost(fork) + gas_limit = executed + 5_000 + + sender = pre.fund_eoa(amount=INITIAL_BALANCE) tx = Transaction( sender=sender, to=target, - data=Bytes(""), - gas_limit=100000, + gas_limit=gas_limit, + gas_price=GAS_PRICE, ) + # The refund is capped at a fork-defined fraction of the executed + # gas. On EIP-8037 forks the repriced fresh sets lift that cap above + # the five clears' refund, which then binds instead. + refund = min(code.refund(fork), executed // fork.max_refund_quotient()) + gas_used = executed - refund + post = { - target: Account(storage={10: 1, 11: 1}), - coinbase: Account(balance=0), - sender: Account(balance=0x8D926C, nonce=1), + target: Account(storage={0xA: 1, 0xB: 1}), + sender: Account(balance=INITIAL_BALANCE - gas_used * GAS_PRICE), } - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRefundTest/test_refund50percent_cap.py b/tests/ported_static/stRefundTest/test_refund50percent_cap.py index d92a991123c..4b9f0e266a2 100644 --- a/tests/ported_static/stRefundTest/test_refund50percent_cap.py +++ b/tests/ported_static/stRefundTest/test_refund50percent_cap.py @@ -1,18 +1,21 @@ """ -Test_refund50percent_cap. +Verify the refund cap over six storage clears: the sender's final +balance reflects the executed gas minus the capped refund. Ported from: state_tests/stRefundTest/refund50percentCapFiller.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, StateTestFiller, Transaction, ) @@ -21,69 +24,85 @@ REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +CONTRACT_BALANCE = 0xDE0B6B3A7640000 +INITIAL_BALANCE = 10**18 +GAS_PRICE = 10 + @pytest.mark.ported_from( ["state_tests/stRefundTest/refund50percentCapFiller.json"], ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable +@pytest.mark.valid_from("Berlin") def test_refund50percent_cap( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: - """Test_refund50percent_cap.""" - 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 fork's cap.""" + code = ( + Op.POP(Op.SLOAD(key=0x1, key_warm=False)) + + Op.POP(Op.SLOAD(key=0x2, key_warm=False)) + + Op.SSTORE( + key=0xA, + value=Op.EXP(0x2, 0xFF, exponent=0xFF), + key_warm=False, + original_value=0, + new_value=2**255, + ) + + 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 0xff) [[ 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, 0xFF)) - + 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(0xEF67F354C8505E1056889970C3D9B5E0FE65D1E2), # noqa: E501 + balance=CONTRACT_BALANCE, ) + intrinsic = fork.transaction_intrinsic_cost_calculator()() + executed = intrinsic + code.gas_cost(fork) + gas_limit = executed + 5_000 + + sender = pre.fund_eoa(amount=INITIAL_BALANCE) tx = Transaction( sender=sender, to=target, - data=Bytes(""), - gas_limit=100000, + gas_limit=gas_limit, + gas_price=GAS_PRICE, ) + # The refund is capped at a fork-defined fraction of the executed + # gas. + refund = min(code.refund(fork), executed // fork.max_refund_quotient()) + gas_used = executed - refund + post = { target: Account( - storage={ - 10: 0x8000000000000000000000000000000000000000000000000000000000000000, # noqa: E501 - 11: 0xDE0B6B3A7640000, - }, + storage={0xA: 2**255, 0xB: CONTRACT_BALANCE}, ), - coinbase: Account(balance=0), - sender: Account(balance=0x8CF0A0), + sender: Account(balance=INITIAL_BALANCE - gas_used * GAS_PRICE), } - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRefundTest/test_refund_call_a.py b/tests/ported_static/stRefundTest/test_refund_call_a.py index bc745f449be..6f99f7393b0 100644 --- a/tests/ported_static/stRefundTest/test_refund_call_a.py +++ b/tests/ported_static/stRefundTest/test_refund_call_a.py @@ -1,17 +1,24 @@ """ -Test_refund_call_a. +Verify a storage-clear refund earned inside a sub-call is credited to +the transaction: the sender's final balance reflects the executed gas +minus the refund. Ported from: state_tests/stRefundTest/refund_CallAFiller.json + +@manually-enhanced: Do not overwrite. The sub-call forwards all gas +instead of a schedule-sized constant, and the sender's balance, refund cap +and budget derive from the fork (`code.gas_cost` / `code.refund` +composites), so EIP-8037's repriced stores are tracked instead of pinned. +The legacy transaction value and the caller balance it pinned are dropped +as incidental to the refund. """ import pytest from execution_testing import ( Account, - Address, Alloc, - Bytes, - Environment, + Fork, StateTestFiller, Transaction, ) @@ -20,72 +27,68 @@ REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +INITIAL_BALANCE = 10**18 +GAS_PRICE = 10 + @pytest.mark.ported_from( ["state_tests/stRefundTest/refund_CallAFiller.json"], ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable +@pytest.mark.valid_from("Berlin") def test_refund_call_a( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: - """Test_refund_call_a.""" - coinbase = Address(0xEB201D2887816E041F6E807E804F64F3A7A226FE) - sender = pre.fund_eoa(amount=0x1312D00) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=1000000, + """A callee's storage clear is credited to the transaction refund.""" + callee_code = Op.SSTORE( + key=0x1, value=0x0, key_warm=False, original_value=1, new_value=0 ) - - pre[coinbase] = Account(balance=0, nonce=1) - # Source: lll - # { [[ 1 ]] 0 } - addr = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x1, value=0x0) + Op.STOP, + callee = pre.deploy_contract( + code=callee_code + Op.STOP, storage={1: 1}, - balance=0xDE0B6B3A7640000, - nonce=0, ) - # Source: lll - # { [[ 0 ]] (CALL 5500 0 0 0 0 0 )} # noqa: E501 - target = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE( - key=0x0, - value=Op.CALL( - gas=0x157C, - address=addr, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.STOP, + + # The caller's own slot 1 stays set: the callee clears its own storage. + caller_code = Op.SSTORE( + key=0x0, + value=Op.CALL(address=callee, address_warm=False), + key_warm=False, + original_value=0, + new_value=1, + ) + caller = pre.deploy_contract( + code=caller_code + Op.STOP, storage={1: 1}, - balance=0xDE0B6B3A7640000, - nonce=0, ) + intrinsic = fork.transaction_intrinsic_cost_calculator()() + executed = ( + intrinsic + caller_code.gas_cost(fork) + callee_code.gas_cost(fork) + ) + gas_limit = executed + 5_000 + + sender = pre.fund_eoa(amount=INITIAL_BALANCE) tx = Transaction( sender=sender, - to=target, - data=Bytes(""), - gas_limit=200000, - value=10, + to=caller, + gas_limit=gas_limit, + gas_price=GAS_PRICE, + ) + + # The refund is capped at a fork-defined fraction of the executed + # gas. A single clear stays well under it, so the earned refund is + # what the balance pins. + refund = min( + caller_code.refund(fork) + callee_code.refund(fork), + executed // fork.max_refund_quotient(), ) + gas_used = executed - refund post = { - target: Account(storage={0: 1, 1: 1}, balance=0xDE0B6B3A764000A), - coinbase: Account(balance=0), - sender: Account(balance=0x12A2AD2, nonce=1), - addr: Account(storage={}), + caller: Account(storage={0: 1, 1: 1}), + callee: Account(storage={}), + sender: Account(balance=INITIAL_BALANCE - gas_used * GAS_PRICE), } - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRefundTest/test_refund_suicide50procent_cap.py b/tests/ported_static/stRefundTest/test_refund_suicide50procent_cap.py index 4083b826958..bd78c892b56 100644 --- a/tests/ported_static/stRefundTest/test_refund_suicide50procent_cap.py +++ b/tests/ported_static/stRefundTest/test_refund_suicide50procent_cap.py @@ -1,158 +1,186 @@ """ -Test_refund_suicide50procent_cap. +Verify the refund cap when eight storage clears surround a gas-limited +call to a self-destructing contract: the stored gas delta and the +sender's final balance track the executed gas minus the capped refund, +for both a starved and a fully funded sub-call. Ported from: state_tests/stRefundTest/refundSuicide50procentCapFiller.json + +@manually-enhanced: Do not overwrite. The sub-call grant, the stored gas +delta, the refund cap and the budget all derive from fork composites. The +destructor self-destructs to CALLER so every address is dynamic, and the +post branches on EIP-6780 for the destructor's survival. """ import pytest from execution_testing import ( Account, - Address, Alloc, - Environment, + Fork, 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" +TARGET_BALANCE = 0xDE0B6B3A7640000 +DESTRUCTOR_BALANCE = 0xDE0B6B3A7640000 +INITIAL_BALANCE = 10**18 +GAS_PRICE = 10 +FLAG_SLOT = 0xA +RESULT_SLOT = 0xB +GAS_SLOT = 0x17 +SNAPSHOT_OFFSET = 0x16 +MEMORY_SIZE = SNAPSHOT_OFFSET + 32 +GRANT_MARGIN = 1_000 + @pytest.mark.ported_from( ["state_tests/stRefundTest/refundSuicide50procentCapFiller.json"], ) -@pytest.mark.valid_from("Cancun") +@pytest.mark.valid_from("Berlin") @pytest.mark.parametrize( - "d, g, v", - [ - pytest.param( - 0, - 0, - 0, - id="d0", - ), - pytest.param( - 1, - 0, - 0, - id="d1", - ), - ], + "call_succeeds", + [False, True], + ids=["starved_grant", "full_grant"], ) -@pytest.mark.pre_alloc_mutable def test_refund_suicide50procent_cap( state_test: StateTestFiller, pre: Alloc, fork: Fork, - d: int, - g: int, - v: int, + call_succeeds: bool, ) -> None: - """Test_refund_suicide50procent_cap.""" - coinbase = Address(0xEB201D2887816E041F6E807E804F64F3A7A226FE) - sender = pre.fund_eoa(amount=0x3B9ACA00) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=100000000, + """Storage clears around a self-destruct call refund up to the cap.""" + # From EIP-6780 on, a pre-existing contract only moves its balance. + destructor_code = Op.SELFDESTRUCT( + address=Op.CALLER, + address_warm=True, + account_new=False, + self_destructed_account=not fork.is_eip_enabled(6780), + ) + destructor = pre.deploy_contract( + code=destructor_code, + balance=DESTRUCTOR_BALANCE, ) - pre[coinbase] = Account(balance=0, nonce=1) - # Source: lll - # { [22] (GAS) [[ 10 ]] 1 [[ 11 ]] (CALL (CALLDATALOAD 0) 0 0 0 0 0 ) [[ 1 ]] 0 [[ 2 ]] 0 [[ 3 ]] 0 [[ 4 ]] 0 [[ 5 ]] 0 [[ 6 ]] 0 [[ 7 ]] 0 [[ 8 ]] 0 [[ 23 ]] (SUB @22 (GAS)) } # noqa: E501 - target = pre.deploy_contract( # noqa: F841 - code=Op.MSTORE(offset=0x16, value=Op.GAS) - + Op.SSTORE(key=0xA, value=0x1) - + Op.SSTORE( - key=0xB, - value=Op.CALL( - gas=Op.CALLDATALOAD(offset=0x0), - address=0x4FF65047CE9C85F968689E4369C10003026A41A9, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + 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.SSTORE(key=0x7, value=0x0) - + Op.SSTORE(key=0x8, value=0x0) - + Op.SSTORE(key=0x17, value=Op.SUB(Op.MLOAD(offset=0x16), Op.GAS)) - + Op.STOP, - storage={1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1}, - balance=0xDE0B6B3A7640000, - nonce=0, - address=Address(0xA6CC2CA5611255D50118601AA8ECE6F124FC4C45), # noqa: E501 + # The grant either covers the destructor completely or falls one gas + # short, so the sub-call forfeits its whole grant. + destructor_cost = destructor_code.gas_cost(fork) + if call_succeeds: + grant = destructor_cost + GRANT_MARGIN + inner_consumed = destructor_cost + else: + grant = destructor_cost - 1 + inner_consumed = grant + call_result = 1 if call_succeeds else 0 + + # First GAS read: the delta window opens after the GAS opcode itself. + head = Op.MSTORE( + offset=SNAPSHOT_OFFSET, value=Op.GAS, new_memory_size=MEMORY_SIZE + ) + body = Op.SSTORE( + key=FLAG_SLOT, + value=0x1, + key_warm=False, + original_value=0, + new_value=1, + ) + Op.SSTORE( + key=RESULT_SLOT, + value=Op.CALL( + gas=Op.CALLDATALOAD(offset=0x0), + address=destructor, + address_warm=False, + ), + key_warm=False, + original_value=0, + new_value=call_result, ) - # Source: lll - # { (SELFDESTRUCT ) } # noqa: E501 - addr = pre.deploy_contract( # noqa: F841 - code=Op.SELFDESTRUCT( - address=0xA6CC2CA5611255D50118601AA8ECE6F124FC4C45 + for slot in range(1, 9): + body += Op.SSTORE( + key=slot, + value=0x0, + key_warm=False, + original_value=1, + new_value=0, ) - + Op.STOP, - balance=0xDE0B6B3A7640000, - nonce=0, - address=Address(0x4FF65047CE9C85F968689E4369C10003026A41A9), # noqa: E501 + # Second GAS read closes the window. The head's own GAS cost stands + # in for it in the derived delta (both GAS reads cost the same). + # new_value is a placeholder: an SSTORE's cost depends only on the + # zero/non-zero transition, not the stored magnitude. + tail = Op.SSTORE( + key=GAS_SLOT, + value=Op.SUB( + Op.MLOAD( + offset=SNAPSHOT_OFFSET, + new_memory_size=MEMORY_SIZE, + old_memory_size=MEMORY_SIZE, + ), + Op.GAS, + ), + key_warm=False, + original_value=0, + new_value=1, + ) + target = pre.deploy_contract( + code=head + body + tail + Op.STOP, + storage=dict.fromkeys(range(1, 9), 1), + balance=TARGET_BALANCE, ) - expect_entries_: list[dict] = [ - { - "indexes": {"data": 0, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - target: Account( - storage={10: 1, 11: 0, 23: 0x107A7}, - balance=0xDE0B6B3A7640000, - ), - sender: Account(nonce=1), - }, - }, - { - "indexes": {"data": 1, "gas": -1, "value": -1}, - "network": [">=Cancun"], - "result": { - target: Account( - storage={10: 1, 11: 1, 23: 0x166FA}, - balance=0x1BC16D674EC80000, - ), - sender: Account(nonce=1), - }, - }, - ] - - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) + gas_delta = head.gas_cost(fork) + body.gas_cost(fork) + inner_consumed - tx_data = [ - Hash(0x1F4), - Hash(0x10000), - ] - tx_gas = [10000000] + data = Hash(grant) + # The refund cap is a fraction of the gas actually deducted before + # execution, which excludes the EIP-7623 calldata floor. + intrinsic = fork.transaction_intrinsic_cost_calculator()( + calldata=data, return_cost_deducted_prior_execution=True + ) + executed = intrinsic + gas_delta + tail.gas_cost(fork) + gas_limit = executed + 5_000 + sender = pre.fund_eoa(amount=INITIAL_BALANCE) tx = Transaction( sender=sender, to=target, - data=tx_data[d], - gas_limit=tx_gas[g], - error=_exc, + data=data, + gas_limit=gas_limit, + gas_price=GAS_PRICE, ) - state_test(env=env, pre=pre, post=post, tx=tx) + # The refund is capped at a fork-defined fraction of the executed + # gas. + total_refund = body.refund(fork) + ( + destructor_code.refund(fork) if call_succeeds else 0 + ) + refund = min(total_refund, executed // fork.max_refund_quotient()) + gas_used = executed - refund + + post = { + target: Account( + storage={ + FLAG_SLOT: 1, + RESULT_SLOT: call_result, + GAS_SLOT: gas_delta, + }, + balance=TARGET_BALANCE + + (DESTRUCTOR_BALANCE if call_succeeds else 0), + ), + # EIP-6780: a pre-existing contract is no longer deleted, only + # its balance is transferred. + destructor: ( + ( + Account(balance=0) + if fork.is_eip_enabled(6780) + else Account.NONEXISTENT + ) + if call_succeeds + else Account(balance=DESTRUCTOR_BALANCE, storage={}) + ), + sender: Account(balance=INITIAL_BALANCE - gas_used * GAS_PRICE), + } + + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRefundTest/test_refund_tx_to_suicide.py b/tests/ported_static/stRefundTest/test_refund_tx_to_suicide.py index ab754657c75..9821c9cf087 100644 --- a/tests/ported_static/stRefundTest/test_refund_tx_to_suicide.py +++ b/tests/ported_static/stRefundTest/test_refund_tx_to_suicide.py @@ -1,18 +1,21 @@ """ -Test_refund_tx_to_suicide. +Verify a transaction into a self-destructing contract: the balance +(including the transaction value) moves to the beneficiary and the +self-destruct refund the fork grants (none from EIP-3529 on) is applied. Ported from: state_tests/stRefundTest/refund_TxToSuicideFiller.json + +@manually-enhanced: Do not overwrite. Beneficiary and budget are derived +(nonexistent account, `code.gas_cost` composite) and the post branches on +EIP-6780 (pre-Cancun the contract is deleted, after it persists). """ import pytest from execution_testing import ( - EOA, Account, - Address, Alloc, - Bytes, - Environment, + Fork, StateTestFiller, Transaction, ) @@ -21,59 +24,66 @@ REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +CONTRACT_BALANCE = 0xDE0B6B3A7640000 +INITIAL_BALANCE = 10**18 +GAS_PRICE = 10 +TX_VALUE = 10 + @pytest.mark.ported_from( ["state_tests/stRefundTest/refund_TxToSuicideFiller.json"], ) -@pytest.mark.valid_from("Cancun") -@pytest.mark.pre_alloc_mutable +@pytest.mark.valid_from("Berlin") def test_refund_tx_to_suicide( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: - """Test_refund_tx_to_suicide.""" - coinbase = Address(0xEB201D2887816E041F6E807E804F64F3A7A226FE) - sender = EOA( - key=0xA2333EEF5630066B928DEA5FD85A239F511B5B067D1441EE7AC290D0122B917B - ) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=10000000, + """Self-destruct moves the balance and refunds what the fork says.""" + beneficiary = pre.nonexistent_account() + # From EIP-6780 on, a pre-existing contract only moves its balance. + code = Op.SELFDESTRUCT( + address=beneficiary, + address_warm=False, + account_new=True, + self_destructed_account=not fork.is_eip_enabled(6780), ) - - pre[coinbase] = Account(balance=0, nonce=1) - pre[sender] = Account(balance=0x5F5E100) - # Source: lll - # { (SELFDESTRUCT 0x095e7baea6a6c7c4c2dfeb977efac326af552d87) } - target = pre.deploy_contract( # noqa: F841 - code=Op.SELFDESTRUCT(address=0x95E7BAEA6A6C7C4C2DFEB977EFAC326AF552D87) - + Op.STOP, + target = pre.deploy_contract( + code=code, storage={1: 1}, - balance=0xDE0B6B3A7640000, - nonce=0, - address=Address(0x2BC33A472F0FBA1E30BF2317D07910367908C7F6), # noqa: E501 + balance=CONTRACT_BALANCE, ) + intrinsic = fork.transaction_intrinsic_cost_calculator()(sends_value=True) + executed = intrinsic + code.gas_cost(fork) + gas_limit = executed + 5_000 + + sender = pre.fund_eoa(amount=INITIAL_BALANCE) tx = Transaction( sender=sender, to=target, - data=Bytes(""), - gas_limit=61003, - value=10, + gas_limit=gas_limit, + gas_price=GAS_PRICE, + value=TX_VALUE, ) + # The self-destruct refund (zero from EIP-3529 on) and the cap both + # come from the fork. + refund = min(code.refund(fork), executed // fork.max_refund_quotient()) + gas_used = executed - refund + post = { - Address(0x095E7BAEA6A6C7C4C2DFEB977EFAC326AF552D87): Account( - storage={}, balance=0xDE0B6B3A764000A + beneficiary: Account(balance=CONTRACT_BALANCE + TX_VALUE), + # EIP-6780: a pre-existing contract is no longer deleted, only + # its balance is transferred. + target: ( + Account(storage={1: 1}, balance=0) + if fork.is_eip_enabled(6780) + else Account.NONEXISTENT + ), + sender: Account( + balance=INITIAL_BALANCE - TX_VALUE - gas_used * GAS_PRICE ), - coinbase: Account(balance=0), - sender: Account(balance=0x5EDB318, nonce=1), - target: Account(storage={1: 1}, balance=0, nonce=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/stRevertTest/test_loop_calls_depth_then_revert.py b/tests/ported_static/stRevertTest/test_loop_calls_depth_then_revert.py index fc4b0089a75..edddfa8502e 100644 --- a/tests/ported_static/stRevertTest/test_loop_calls_depth_then_revert.py +++ b/tests/ported_static/stRevertTest/test_loop_calls_depth_then_revert.py @@ -1,8 +1,16 @@ """ -Test_loop_calls_depth_then_revert. +Verify a mutual CALL recursion that terminates by gas exhaustion: two +contracts increment their own counters and call each other until the +EIP-150 63/64 attenuation starves the deepest frame, which halts before +its own store while every ancestor's increment persists. Ported from: state_tests/stRevertTest/LoopCallsDepthThenRevertFiller.json + +@manually-enhanced: Do not overwrite. The reached depth is bounded by the +fixed gas budget (not the 1024 depth limit), so the frame counts are +pinned per gas-schedule era: EIP-8037/EIP-2780 shift the attenuation on +Amsterdam. One address literal remains to break the reference cycle. """ import pytest @@ -10,16 +18,31 @@ Account, Address, Alloc, - Bytes, - Environment, + 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" +# The recursion depth is a function of this budget via EIP-150's 63/64 +# forwarding rule. Changing it changes the pinned frame counts. +GAS_BUDGET = 10_000_000 +# Fixed address for the second contract: it must be known before the +# first contract's code (which calls it) can be built. +PONG_ADDRESS = Address(0x80D46FA47B41AB46A227915AE4F63559C0D4DFE2) + + +def loop_code(partner: Address) -> Bytecode: + """Increment the own counter, then recurse into the partner.""" + return ( + Op.SSTORE(key=0x0, value=Op.ADD(Op.SLOAD(key=0x0), 0x1)) + + Op.CALL(address=partner) + + Op.STOP + ) + @pytest.mark.ported_from( ["state_tests/stRevertTest/LoopCallsDepthThenRevertFiller.json"], @@ -29,65 +52,30 @@ def test_loop_calls_depth_then_revert( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: - """Test_loop_calls_depth_then_revert.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = pre.fund_eoa(amount=0xE8D4A51000) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=100000000, - ) - - # Source: lll - # { [[0]] (+ (SLOAD 0) 1) (CALL (GAS) 0 0 0 0 0) } # noqa: E501 - target = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=Op.ADD(Op.SLOAD(key=0x0), 0x1)) - + Op.CALL( - gas=Op.GAS, - address=0x80D46FA47B41AB46A227915AE4F63559C0D4DFE2, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - + Op.STOP, - nonce=0, - address=Address(0xF59FD1C021541704A4A52C067454304566717666), # noqa: E501 - ) - # Source: lll - # { [[0]] (+ (SLOAD 0) 1) (CALL (GAS) 0 0 0 0 0) } # noqa: E501 - addr = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=Op.ADD(Op.SLOAD(key=0x0), 0x1)) - + Op.CALL( - gas=Op.GAS, - address=0xF59FD1C021541704A4A52C067454304566717666, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - + Op.STOP, - nonce=0, - address=Address(0x80D46FA47B41AB46A227915AE4F63559C0D4DFE2), # noqa: E501 - ) + """Only the gas-starved deepest frame of a call loop fails.""" + ping = pre.deploy_contract(code=loop_code(PONG_ADDRESS)) + pong = pre.deploy_contract(code=loop_code(ping), address=PONG_ADDRESS) + sender = pre.fund_eoa() tx = Transaction( sender=sender, - to=target, - data=Bytes(""), - gas_limit=10000000, + to=ping, + gas_limit=GAS_BUDGET, ) + # Completed frames under GAS_BUDGET, pinned per gas-schedule era: + # EIP-8037's state gas for the two first stores trims one frame off + # the depth the 63/64 attenuation allows. + if fork.is_eip_enabled(8037): + ping_frames, pong_frames = 192, 192 + else: + ping_frames, pong_frames = 193, 192 + post = { - target: Account(storage={0: 193}), - addr: Account(storage={0: 192}), + ping: Account(storage={0: ping_frames}), + pong: Account(storage={0: pong_frames}), } - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRevertTest/test_loop_delegate_calls_depth_then_revert.py b/tests/ported_static/stRevertTest/test_loop_delegate_calls_depth_then_revert.py index 1be3fb70d90..cb654ec9788 100644 --- a/tests/ported_static/stRevertTest/test_loop_delegate_calls_depth_then_revert.py +++ b/tests/ported_static/stRevertTest/test_loop_delegate_calls_depth_then_revert.py @@ -1,8 +1,17 @@ """ -Test_loop_delegate_calls_depth_then_revert. +Verify a mutual DELEGATECALL recursion that terminates by gas +exhaustion: both contracts' code increments the entry contract's counter +(the storage context never changes) until the EIP-150 63/64 attenuation +starves the deepest frame, which halts before its own store while every +ancestor's increment persists. Ported from: state_tests/stRevertTest/LoopDelegateCallsDepthThenRevertFiller.json + +@manually-enhanced: Do not overwrite. The reached depth is bounded by the +fixed gas budget (not the 1024 depth limit), so the frame count is +pinned per gas-schedule era: EIP-8037/EIP-2780 shift the attenuation on +Amsterdam. One address literal remains to break the reference cycle. """ import pytest @@ -10,16 +19,31 @@ Account, Address, Alloc, - Bytes, - Environment, + 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" +# The recursion depth is a function of this budget via EIP-150's 63/64 +# forwarding rule. Changing it changes the pinned frame count. +GAS_BUDGET = 10_000_000 +# Fixed address for the second contract: it must be known before the +# first contract's code (which delegate-calls it) can be built. +PONG_ADDRESS = Address(0xF798CB78490DA31DFACDCD1F2B3FB1948BB2B228) + + +def loop_code(partner: Address) -> Bytecode: + """Increment the context counter, then recurse into the partner.""" + return ( + Op.SSTORE(key=0x0, value=Op.ADD(Op.SLOAD(key=0x0), 0x1)) + + Op.DELEGATECALL(address=partner) + + Op.STOP + ) + @pytest.mark.ported_from( ["state_tests/stRevertTest/LoopDelegateCallsDepthThenRevertFiller.json"], @@ -29,63 +53,29 @@ def test_loop_delegate_calls_depth_then_revert( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: - """Test_loop_delegate_calls_depth_then_revert.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = pre.fund_eoa(amount=0xE8D4A51000) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - gas_limit=100000000, - ) - - # Source: lll - # { [[0]] (+ (SLOAD 0) 1) (DELEGATECALL (GAS) 0 0 0 0) } # noqa: E501 - target = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=Op.ADD(Op.SLOAD(key=0x0), 0x1)) - + Op.DELEGATECALL( - gas=Op.GAS, - address=0xF798CB78490DA31DFACDCD1F2B3FB1948BB2B228, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - + Op.STOP, - nonce=0, - address=Address(0xB0923C4A632DE291FCDAC653E6C6CC2B4E4CDFA8), # noqa: E501 - ) - # Source: lll - # { [[0]] (+ (SLOAD 0) 1) (DELEGATECALL (GAS) 0 0 0 0) } # noqa: E501 - addr = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=Op.ADD(Op.SLOAD(key=0x0), 0x1)) - + Op.DELEGATECALL( - gas=Op.GAS, - address=0xB0923C4A632DE291FCDAC653E6C6CC2B4E4CDFA8, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - + Op.STOP, - nonce=0, - address=Address(0xF798CB78490DA31DFACDCD1F2B3FB1948BB2B228), # noqa: E501 - ) + """Only the gas-starved deepest frame of a delegate loop fails.""" + ping = pre.deploy_contract(code=loop_code(PONG_ADDRESS)) + pong = pre.deploy_contract(code=loop_code(ping), address=PONG_ADDRESS) + sender = pre.fund_eoa() tx = Transaction( sender=sender, - to=target, - data=Bytes(""), - gas_limit=10000000, + to=ping, + gas_limit=GAS_BUDGET, ) + # Completed frames under GAS_BUDGET, pinned per gas-schedule era: + # every frame increments the entry contract's counter because + # DELEGATECALL keeps the storage context. The partner's own storage + # is never touched. EIP-8037's state gas for the first store shifts + # the depth the 63/64 attenuation allows. + frames = 385 if fork.is_eip_enabled(8037) else 386 + post = { - target: Account(storage={0: 386}), - addr: Account(storage={}), + ping: Account(storage={0: frames}), + pong: Account(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/stRevertTest/test_revert_depth_create_address_collision.py b/tests/ported_static/stRevertTest/test_revert_depth_create_address_collision.py index 11c3aaa568c..5d135774d4e 100644 --- a/tests/ported_static/stRevertTest/test_revert_depth_create_address_collision.py +++ b/tests/ported_static/stRevertTest/test_revert_depth_create_address_collision.py @@ -1,222 +1,199 @@ """ -Test_revert_depth_create_address_collision. +Verify revert propagation around a nested CREATE whose target address +collides with an existing contract, its own caller: the collision burns +the child's grant and bumps the creator's nonce without creating +anything, and the stacked budgets decide whether the creator survives +its aftermath, dies on it, or the caller or the whole transaction runs +dry after it. Ported from: state_tests/stRevertTest/RevertDepthCreateAddressCollisionFiller.json + +@manually-enhanced: Do not overwrite. Restores the collision the machine +port lost: the caller is deployed at the creator's CREATE address (as in +the original filler). Grants and budgets derive from fork composites, +the collided account's code, nonce and storage are pinned in every arm, +and the creator's post-collision slack is sized so the burned grant is +what decides its fate. """ import pytest from execution_testing import ( - EOA, Account, - Address, Alloc, - Environment, + Fork, 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, -) - REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +# Head room on top of the completion marker's cost in the starved +# creator's slack: had the collision returned the grant, the marker +# would be paid, but one 64th of the slack never covers it. +SLACK_MARGIN = 5_000 +# What the covered creator keeps after its completion marker, on top of +# the EIP-2200 stipend gate that store must clear. +RETENTION_MARGIN = 100 + @pytest.mark.ported_from( ["state_tests/stRevertTest/RevertDepthCreateAddressCollisionFiller.json"], ) @pytest.mark.valid_from("Cancun") @pytest.mark.parametrize( - "d, g, v", - [ - pytest.param( - 0, - 0, - 0, - id="d0-g0-v0", - ), - pytest.param( - 0, - 0, - 1, - id="d0-g0-v1", - ), - pytest.param( - 0, - 1, - 0, - id="d0-g1-v0", - ), - pytest.param( - 0, - 1, - 1, - id="d0-g1-v1", - ), - pytest.param( - 1, - 0, - 0, - id="d1-g0-v0", - ), - pytest.param( - 1, - 0, - 1, - id="d1-g0-v1", - ), - pytest.param( - 1, - 1, - 0, - id="d1-g1-v0", - ), - pytest.param( - 1, - 1, - 1, - id="d1-g1-v1", - ), - ], + "scenario", + ["creator_oog", "creator_ok", "caller_oog", "tx_oog"], ) +@pytest.mark.parametrize("tx_value", [1, 0], ids=["v1", "v0"]) +# Keep the intentional collider out of other tests with the same creator. +@pytest.mark.pre_alloc_group("create_address_collision") @pytest.mark.pre_alloc_mutable def test_revert_depth_create_address_collision( state_test: StateTestFiller, pre: Alloc, fork: Fork, - d: int, - g: int, - v: int, + scenario: str, + tx_value: int, ) -> None: - """Test_revert_depth_create_address_collision.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - sender = EOA( - key=0x4F31B3206FBF0E0E598B9B1A7D8AC86302A0FF1D8930738F1BEBAE9B67173E52 + """A CREATE address collision leaves the collided account intact.""" + creator_store = Op.SSTORE( + key=0x2, value=0x8, key_warm=False, original_value=0, new_value=8 ) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, + # The target is alive, so no new-account state gas is charged. + create_code = Op.POP( + Op.CREATE( + value=0x0, + offset=0x0, + size=0x0, + init_code_size=0, + new_memory_size=0, + account_new=False, + ) + ) + creator_tail = Op.SSTORE( + key=0x3, value=0xC, key_warm=False, original_value=0, new_value=0xC + ) + creator = pre.deploy_contract( + code=creator_store + create_code + creator_tail + Op.STOP ) - pre[sender] = Account(balance=0xE8D4A51000) - # Source: lll - # { [[2]] 8 (CREATE 0 0 0) [[3]] 12} - addr = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x2, value=0x8) - + Op.POP(Op.CREATE(value=0x0, offset=0x0, size=0x0)) - + Op.SSTORE(key=0x3, value=0xC) - + Op.STOP, - nonce=0, - address=Address(0xB1B49241A4ECF7860872E686090781C906B1B437), # noqa: E501 + head_store = Op.SSTORE( + key=0x0, value=0x1, key_warm=False, original_value=0, new_value=1 ) - # Source: lll - # { [[0]] 1 [[1]] (CALL (CALLDATALOAD 0) 0 0 0 0 0) [[4]] 12 } # noqa: E501 - target = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE( - key=0x1, - value=Op.CALL( - gas=Op.CALLDATALOAD(offset=0x0), - address=0xB1B49241A4ECF7860872E686090781C906B1B437, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.SSTORE(key=0x4, value=0xC) - + Op.STOP, - balance=5, - nonce=54, - address=Address(0x97E33A176B7C8D61B356D1C170AC2119D28867DF), # noqa: E501 + call_code = Op.CALL( + gas=Op.CALLDATALOAD(offset=0x0), address=creator, address_warm=False + ) + call_store = Op.SSTORE( + key=0x1, + value=call_code, + key_warm=False, + original_value=0, + new_value=1, ) + tail_store = Op.SSTORE( + key=0x4, value=0xC, key_warm=False, original_value=0, new_value=0xC + ) + caller_code = head_store + call_store + tail_store + Op.STOP + # The caller sits exactly at the creator's CREATE address: the + # nested creation collides with the very contract that called it. + caller = pre.deploy_contract( + code=caller_code, + address=compute_create_address(address=creator, nonce=1), + ) + + # The collision consumes everything left after the CREATE's own + # charges but one 64th, so the creator's fate is set by the slack + # riding on top of its pre-collision costs. + stipend = fork.gas_costs().CALL_STIPEND + tail = creator_tail.gas_cost(fork) + if scenario == "creator_ok": + slack = 64 * (stipend + tail + RETENTION_MARGIN) + else: + slack = tail + SLACK_MARGIN + assert slack // 64 < tail, "the retention must not afford the marker" + ask = (creator_store + create_code).gas_cost(fork) + slack + + intrinsic_calculator = fork.transaction_intrinsic_cost_calculator() + + def overhead(data: Hash) -> int: + """Return what the caller pays before its CALL.""" + return intrinsic_calculator( + calldata=data, + sends_value=tx_value > 0, + return_cost_deducted_prior_execution=True, + ) + head_store.gas_cost(fork) - expect_entries_: list[dict] = [ - { - "indexes": {"data": 1, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - target: Account( - storage={}, - code=bytes.fromhex( - "60016000556000600060006000600073b1b49241a4ecf7860872e686090781c906b1b437600035f1600155600c60045500" # noqa: E501 - ), - nonce=54, - ), - addr: Account(storage={}), - }, - }, - { - "indexes": {"data": 0, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - target: Account( - storage={0: 1, 4: 12}, - code=bytes.fromhex( - "60016000556000600060006000600073b1b49241a4ecf7860872e686090781c906b1b437600035f1600155600c60045500" # noqa: E501 - ), - nonce=54, - ), - addr: Account(storage={}), - }, - }, - { - "indexes": {"data": 1, "gas": 0, "value": -1}, - "network": [">=Cancun"], - "result": { - target: Account( - storage={}, - code=bytes.fromhex( - "60016000556000600060006000600073b1b49241a4ecf7860872e686090781c906b1b437600035f1600155600c60045500" # noqa: E501 - ), - balance=5, - nonce=54, - ), - addr: Account(storage={}), - }, - }, - { - "indexes": {"data": 0, "gas": 0, "value": -1}, - "network": [">=Cancun"], - "result": { - target: Account( - storage={}, - code=bytes.fromhex( - "60016000556000600060006000600073b1b49241a4ecf7860872e686090781c906b1b437600035f1600155600c60045500" # noqa: E501 - ), - nonce=54, - ), - addr: Account(storage={}), - }, - }, - ] - - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) - - tx_data = [ - Hash(0xEA60), - Hash(0x1EA60), - ] - tx_gas = [110000, 160000] - tx_value = [1, 0] + # Enough at the CALL that the EIP-150 clamp still grants the full + # ask. + available = -(-ask * 64 // 63) + 64 + assert available - available // 64 >= ask, "the full ask must be granted" + data = Hash(ask) + post_call = call_store.gas_cost(fork) + tail_store.gas_cost(fork) + gas_limit = overhead(data) + post_call + available + if scenario == "caller_oog": + # An oversized ask is clamped to the EIP-150 cap: the creator + # gets everything the caller has and still dies on the collision, + # while the caller keeps one 64th, never enough for its stores. + data = Hash(gas_limit) + gas_limit = overhead(data) + post_call + available + remaining = post_call + available + granted = remaining - remaining // 64 + creator_left = granted - (creator_store + create_code).gas_cost(fork) + assert creator_left // 64 < tail, "the creator must die" + assert remaining // 64 < tail_store.gas_cost(fork), "caller must die" + elif scenario == "tx_oog": + # Nothing is budgeted for the caller's post-call stores: the + # 1/64 retention cannot pay them, so the whole transaction runs + # dry after the collision. + gas_limit = overhead(data) + available + assert available // 64 < tail_store.gas_cost(fork), "caller must die" + sender = pre.fund_eoa() tx = Transaction( sender=sender, - to=target, - data=tx_data[d], - gas_limit=tx_gas[g], - value=tx_value[v], - error=_exc, + to=caller, + data=data, + gas_limit=gas_limit, + value=tx_value, ) - state_test(env=env, pre=pre, post=post, tx=tx) + if scenario == "creator_ok": + # The collision's signature: a nonce bump with nothing created, + # a zero CREATE result, and the caller's account untouched. + caller_account = Account( + storage={0: 1, 1: 1, 4: 0xC}, + code=caller_code, + balance=tx_value, + nonce=1, + ) + creator_account = Account(storage={2: 8, 3: 0xC}, nonce=2) + elif scenario == "creator_oog": + # The creator died on its completion marker and was rolled back, + # including the collision's nonce bump. + caller_account = Account( + storage={0: 1, 1: 0, 4: 0xC}, + code=caller_code, + balance=tx_value, + nonce=1, + ) + creator_account = Account(storage={}, nonce=1) + else: + # The transaction ran dry after the collision: only the code + # survives. + caller_account = Account( + storage={}, code=caller_code, balance=0, nonce=1 + ) + creator_account = Account(storage={}, nonce=1) + + post = { + sender: Account(nonce=1), + caller: caller_account, + creator: creator_account, + } + + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRevertTest/test_revert_depth_create_oog.py b/tests/ported_static/stRevertTest/test_revert_depth_create_oog.py index 3cd7ee838c3..07312e458e3 100644 --- a/tests/ported_static/stRevertTest/test_revert_depth_create_oog.py +++ b/tests/ported_static/stRevertTest/test_revert_depth_create_oog.py @@ -1,195 +1,184 @@ """ -Test_revert_depth_create_oog. +Verify revert propagation around a nested CREATE that runs out of gas: +a sub-call either funds its CREATE-then-store sequence completely, or +runs out of gas after the CREATE, reverting the created account but not +the caller. A starved outer budget reverts everything after the +sub-call ran, a completed creation included. Ported from: state_tests/stRevertTest/RevertDepthCreateOOGFiller.json + +@manually-enhanced: Do not overwrite. The sub-call grants and both +transaction budgets derive from fork composites (EIP-8037 state gas is +tracked instead of pinned), all addresses are dynamic, every account +including the created one is pinned in each arm, and the starved arms +run the CREATE before the transaction dies. """ import pytest from execution_testing import ( Account, - Address, Alloc, - Environment, + Fork, 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, -) - REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +# Gas left in the creator frame after its CREATE: enough to reach the +# following store, never enough to pay for it. +PARTIAL_MARGIN = 5_000 +# Head room on top of a derived budget. +BUDGET_MARGIN = 5_000 + @pytest.mark.ported_from( ["state_tests/stRevertTest/RevertDepthCreateOOGFiller.json"], ) @pytest.mark.valid_from("Cancun") @pytest.mark.parametrize( - "d, g, v", - [ - pytest.param( - 0, - 0, - 0, - id="d0-g0-v0", - ), - pytest.param( - 0, - 0, - 1, - id="d0-g0-v1", - ), - pytest.param( - 0, - 1, - 0, - id="d0-g1-v0", - ), - pytest.param( - 0, - 1, - 1, - id="d0-g1-v1", - ), - pytest.param( - 1, - 0, - 0, - id="d1-g0-v0", - ), - pytest.param( - 1, - 0, - 1, - id="d1-g0-v1", - ), - pytest.param( - 1, - 1, - 0, - id="d1-g1-v0", - ), - pytest.param( - 1, - 1, - 1, - id="d1-g1-v1", - ), - ], + "full_grant", + [False, True], + ids=["partial_grant", "full_grant"], +) +@pytest.mark.parametrize( + "ample_budget", + [False, True], + ids=["starved", "ample"], ) -@pytest.mark.pre_alloc_mutable +@pytest.mark.parametrize("tx_value", [1, 0], ids=["v1", "v0"]) def test_revert_depth_create_oog( state_test: StateTestFiller, pre: Alloc, fork: Fork, - d: int, - g: int, - v: int, + full_grant: bool, + ample_budget: bool, + tx_value: int, ) -> None: - """Test_revert_depth_create_oog.""" - coinbase = Address(0x2ADC25665018AA1FE0E6BC666DAC8FC2697FF9BA) - contract_0 = Address(0xA000000000000000000000000000000000000000) - contract_1 = Address(0xB000000000000000000000000000000000000000) - sender = pre.fund_eoa(amount=0xE8D4A51000) - - env = Environment( - fee_recipient=coinbase, - number=1, - timestamp=1000, - prev_randao=0x20000, - base_fee_per_gas=10, - ) - - # Source: lll - # { [[2]] 8 (CREATE 0 0 0) [[3]] 12} - contract_1 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x2, value=0x8) - + Op.POP(Op.CREATE(value=0x0, offset=0x0, size=0x0)) - + Op.SSTORE(key=0x3, value=0xC) - + Op.STOP, - nonce=0, + """An out-of-gas CREATE frame reverts alone, a starved caller in full.""" + creator_store = Op.SSTORE( + key=0x2, value=0x8, key_warm=False, original_value=0, new_value=8 ) - # Source: lll - # { [[0]] 1 [[1]] (CALL (CALLDATALOAD 0) 0xb000000000000000000000000000000000000000 0 0 0 0 0) [[4]] 12 } # noqa: E501 - contract_0 = pre.deploy_contract( # noqa: F841 - code=Op.SSTORE(key=0x0, value=0x1) - + Op.SSTORE( - key=0x1, - value=Op.CALL( - gas=Op.CALLDATALOAD(offset=0x0), - address=contract_1, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), + create_code = Op.POP( + Op.CREATE( + value=0x0, + offset=0x0, + size=0x0, + init_code_size=0, + new_memory_size=0, ) - + Op.SSTORE(key=0x4, value=0xC) - + Op.STOP, - balance=5, - nonce=54, ) + creator_tail = Op.SSTORE( + key=0x3, value=0xC, key_warm=False, original_value=0, new_value=0xC + ) + creator_code = creator_store + create_code + creator_tail + Op.STOP + creator = pre.deploy_contract(code=creator_code) + created = compute_create_address(address=creator, nonce=1) - expect_entries_: list[dict] = [ - { - "indexes": {"data": 1, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - compute_create_address(address=contract_1, nonce=0): Account( - nonce=1 - ), - contract_0: Account(storage={0: 1, 1: 1, 4: 12}), - contract_1: Account(storage={2: 8, 3: 12}), - }, - }, - { - "indexes": {"data": 0, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - compute_create_address( - address=contract_1, nonce=0 - ): Account.NONEXISTENT, - contract_0: Account(storage={0: 1, 4: 12}), - contract_1: Account(storage={}), - }, - }, - { - "indexes": {"data": [0, 1], "gas": 0, "value": -1}, - "network": [">=Cancun"], - "result": { - compute_create_address( - address=contract_1, nonce=0 - ): Account.NONEXISTENT, - contract_0: Account(storage={}), - contract_1: Account(storage={}), - }, - }, - ] + # The grant covers the creator completely, or only up to and + # including its CREATE, leaving too little for the following store. + if full_grant: + grant = creator_code.gas_cost(fork) + BUDGET_MARGIN + inner_consumed = creator_code.gas_cost(fork) + else: + grant = (creator_store + create_code).gas_cost(fork) + PARTIAL_MARGIN + inner_consumed = grant + inner_succeeds = ample_budget and full_grant + inner_fails_reaching_create = ample_budget and not full_grant - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) + head_store = Op.SSTORE( + key=0x0, value=0x1, key_warm=False, original_value=0, new_value=1 + ) + call_code = Op.CALL( + gas=Op.CALLDATALOAD(offset=0x0), address=creator, address_warm=False + ) + call_store = Op.SSTORE( + key=0x1, + value=call_code, + key_warm=False, + original_value=0, + new_value=1 if inner_succeeds else 0, + ) + tail_store = Op.SSTORE( + key=0x4, value=0xC, key_warm=False, original_value=0, new_value=0xC + ) + caller = pre.deploy_contract( + code=head_store + call_store + tail_store + Op.STOP + ) - tx_data = [ - Hash(0xEA60), - Hash(0x1EA60), - ] - tx_gas = [110000, 180000] - tx_value = [1, 0] + data = Hash(grant) + intrinsic = fork.transaction_intrinsic_cost_calculator()( + calldata=data, + sends_value=tx_value > 0, + return_cost_deducted_prior_execution=True, + ) + if ample_budget: + gas_limit = ( + intrinsic + + head_store.gas_cost(fork) + + call_store.gas_cost(fork) + + inner_consumed + + tail_store.gas_cost(fork) + + BUDGET_MARGIN + ) + # The requested grant must fit under the EIP-150 63/64 cap. + available = ( + gas_limit + - intrinsic + - head_store.gas_cost(fork) + - call_code.gas_cost(fork) + ) + assert grant <= available - available // 64, "grant must be granted" + else: + # The grant is granted in full but nothing is budgeted for the + # caller's post-call stores: the 1/64 retention plus whatever the + # creator hands back cannot pay them, so the whole transaction + # runs dry after the creator ran. + available = -(-grant * 64 // 63) + 64 + assert grant <= available - available // 64, "grant must be granted" + creator_spare = grant - inner_consumed + assert available // 64 + creator_spare < tail_store.gas_cost(fork), ( + "caller must die" + ) + gas_limit = ( + intrinsic + + head_store.gas_cost(fork) + + call_code.gas_cost(fork) + + available + ) + sender = pre.fund_eoa() tx = Transaction( sender=sender, - to=contract_0, - data=tx_data[d], - gas_limit=tx_gas[g], - value=tx_value[v], - error=_exc, + to=caller, + data=data, + gas_limit=gas_limit, + value=tx_value, ) - state_test(env=env, pre=pre, post=post, tx=tx) + post: dict + if inner_succeeds: + post = { + created: Account(nonce=1), + caller: Account(storage={0: 1, 1: 1, 4: 0xC}, balance=tx_value), + creator: Account(storage={2: 8, 3: 0xC}), + } + elif inner_fails_reaching_create: + post = { + created: Account.NONEXISTENT, + caller: Account(storage={0: 1, 4: 0xC}, balance=tx_value), + creator: Account(storage={}), + } + else: + post = { + created: Account.NONEXISTENT, + caller: Account(storage={}, balance=0), + creator: Account(storage={}), + } + + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/ported_static/stRevertTest/test_revert_opcode_in_calls_on_non_empty_return_data.py b/tests/ported_static/stRevertTest/test_revert_opcode_in_calls_on_non_empty_return_data.py index a49cc7b38bc..0525de05aaf 100644 --- a/tests/ported_static/stRevertTest/test_revert_opcode_in_calls_on_non_empty_return_data.py +++ b/tests/ported_static/stRevertTest/test_revert_opcode_in_calls_on_non_empty_return_data.py @@ -1,35 +1,48 @@ """ -Test: this test checks that the returndata buffer is changed when a... +Verify that a reverting callee's return data replaces the non-empty +return data buffer left by an earlier call: each prober first fills the +buffer from a returning callee, then records the failed call's result +and RETURNDATASIZE, for CALL, CALLCODE, DELEGATECALL and a nested CALL +chain, with an ample and a starved transaction budget. Ported from: state_tests/stRevertTest/RevertOpcodeInCallsOnNonEmptyReturnDataFiller.json -@manually-enhanced: Do not overwrite. Inner-CALL/DELEGATECALL gas -bumped on Amsterdam to cover EIP-8037 state-gas spill into regular gas; -pre-EIP-8037 unchanged. +@manually-enhanced: Do not overwrite. The buffer-filling call forwards +gas so the buffer really is non-empty before the probe (the legacy +filler gave it a zero grant, so nothing was ever replaced). Sub-calls +forward all gas and the ample arm omits the gas limit (maxing the +EIP-8037 reservoir), replacing per-fork gas bumps. The starved budget +derives from fork composites, all addresses are dynamic and every +contract is pinned in the post. """ import pytest from execution_testing import ( - EOA, Account, Address, Alloc, - Environment, + Fork, 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, -) +from execution_testing.vm import Bytecode, Op REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" +RESULT_SLOT = 0x0 +RETURN_DATA_SIZE_SLOT = 0x2 +NESTED_RESULT_SLOT = 0x4 +NESTED_RETURN_DATA_SIZE_SLOT = 0x5 +ENTRY_SLOT = 0xA +ENTRY_SLOT_INITIAL = 255 +RETURNED_SIZE = 0x40 +# Gas left at the entry frame's call site in the starved arm: enough to +# start the call, too little for any frame to complete. +STARVE_MARGIN = 1_000 + @pytest.mark.ported_from( [ @@ -38,396 +51,146 @@ ) @pytest.mark.valid_from("Cancun") @pytest.mark.parametrize( - "d, g, v", - [ - pytest.param( - 0, - 0, - 0, - id="d0-g0", - ), - pytest.param( - 0, - 1, - 0, - id="d0-g1", - ), - pytest.param( - 1, - 0, - 0, - id="d1-g0", - ), - pytest.param( - 1, - 1, - 0, - id="d1-g1", - ), - pytest.param( - 2, - 0, - 0, - id="d2-g0", - ), - pytest.param( - 2, - 1, - 0, - id="d2-g1", - ), - pytest.param( - 3, - 0, - 0, - id="d3-g0", - ), - pytest.param( - 3, - 1, - 0, - id="d3-g1", - ), - ], + "call_op", + [Op.CALL, Op.CALLCODE, Op.DELEGATECALL, None], + ids=["call", "callcode", "delegatecall", "nested_call"], +) +@pytest.mark.parametrize( + "ample_gas", + [True, False], + ids=["ample", "starved"], ) -@pytest.mark.pre_alloc_mutable def test_revert_opcode_in_calls_on_non_empty_return_data( state_test: StateTestFiller, pre: Alloc, fork: Fork, - d: int, - g: int, - v: int, + call_op: Op | None, + ample_gas: bool, ) -> None: - """Test: tis test checks that the returndata buffer is changed when a...""" - 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, - ) - - pre[sender] = Account(balance=0xE8D4A51000) - # EIP-8037 inner-CALL/DELEGATECALL gas bumps: original values - # restored for pre-EIP-8037 forks; bumped for state-gas spill on - # Amsterdam. - inner_call_gas = 50000 - deeper_call_gas = 100000 - deepest_call_gas = 260000 - if fork.is_eip_enabled(8037): - inner_call_gas = 100000 - deeper_call_gas = 1000000 - deepest_call_gas = 1000000 - # Source: lll - # { [[1]] 12 (REVERT 0 1) [[3]] 13 } - addr_6 = pre.deploy_contract( # noqa: F841 + """A revert's return data replaces a non-empty buffer.""" + # Reverts one byte of return data. The store before the REVERT is + # undone and the code after it must never run. + reverter = pre.deploy_contract( code=Op.SSTORE(key=0x1, value=0xC) + Op.REVERT(offset=0x0, size=0x1) + Op.SSTORE(key=0x3, value=0xD) + Op.STOP, - balance=1, - nonce=0, - address=Address(0x93A599BDE9A3B6390AFDB06952AA5EC0B8C44F3B), # noqa: E501 - ) - # Source: lll - # { [1] 12 (RETURN 0 64) } - addr_7 = pre.deploy_contract( # noqa: F841 - code=Op.MSTORE(offset=0x1, value=0xC) - + Op.RETURN(offset=0x0, size=0x40) - + Op.STOP, - balance=1, - nonce=0, - address=Address(0x127EAF7E31D691A8393B7A2F84A6E94372190C01), # noqa: E501 - ) - # Source: lll - # { (CALL 0 0 0 0 0 0) [[0]] (DELEGATECALL 50000 0 0 0 0) [[2]] (RETURNDATASIZE) } # noqa: E501 - addr_3 = pre.deploy_contract( # noqa: F841 - code=Op.POP( - Op.CALL( - gas=0x0, - address=0x127EAF7E31D691A8393B7A2F84A6E94372190C01, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - ) - + Op.SSTORE( - key=0x0, - value=Op.DELEGATECALL( - gas=inner_call_gas, - address=0x93A599BDE9A3B6390AFDB06952AA5EC0B8C44F3B, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.SSTORE(key=0x2, value=Op.RETURNDATASIZE) - + Op.STOP, - balance=1, - nonce=0, - address=Address(0xF20CCAF271BEAA36E7CF4C9CED2867FAC9558F14), # noqa: E501 ) - # Source: lll - # { (CALL 0 0 0 0 0 0) [[0]] (CALLCODE 50000 0 0 0 0 0) [[2]] (RETURNDATASIZE) } # noqa: E501 - addr_2 = pre.deploy_contract( # noqa: F841 - code=Op.POP( - Op.CALL( - gas=0x0, - address=0x127EAF7E31D691A8393B7A2F84A6E94372190C01, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) - ) - + Op.SSTORE( - key=0x0, - value=Op.CALLCODE( - gas=inner_call_gas, - address=0x93A599BDE9A3B6390AFDB06952AA5EC0B8C44F3B, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), + # Fills the return data buffer with RETURNED_SIZE bytes. + returner_code = Op.MSTORE( + offset=0x1, value=0xC, new_memory_size=RETURNED_SIZE + ) + Op.RETURN(offset=0x0, size=RETURNED_SIZE) + returner = pre.deploy_contract(code=returner_code) + + prelude = Op.POP(Op.CALL(address=returner, address_warm=False)) + + def prober_code( + op: Op, callee: Address, result_slot: int, rds_slot: int + ) -> Bytecode: + """Call the callee and record its result and RETURNDATASIZE.""" + return ( + prelude + + Op.SSTORE(key=result_slot, value=op(address=callee)) + + Op.SSTORE(key=rds_slot, value=Op.RETURNDATASIZE) + + Op.STOP ) - + Op.SSTORE(key=0x2, value=Op.RETURNDATASIZE) - + Op.STOP, - balance=1, - nonce=0, - address=Address(0xC9DA6CD8413F64323F12CD44C99671F280F15E1C), # noqa: E501 + + prober_call = pre.deploy_contract( + code=prober_code(Op.CALL, reverter, RESULT_SLOT, RETURN_DATA_SIZE_SLOT) ) - # Source: lll - # { (CALL 0 0 0 0 0 0) [[4]] (CALL 50000 0 0 0 0 0) [[5]] (RETURNDATASIZE) } # noqa: E501 - addr_5 = pre.deploy_contract( # noqa: F841 - code=Op.POP( - Op.CALL( - gas=0x0, - address=0x127EAF7E31D691A8393B7A2F84A6E94372190C01, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) + prober_callcode = pre.deploy_contract( + code=prober_code( + Op.CALLCODE, reverter, RESULT_SLOT, RETURN_DATA_SIZE_SLOT ) - + Op.SSTORE( - key=0x4, - value=Op.CALL( - gas=inner_call_gas, - address=0x93A599BDE9A3B6390AFDB06952AA5EC0B8C44F3B, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.SSTORE(key=0x5, value=Op.RETURNDATASIZE) - + Op.STOP, - balance=1, - nonce=0, - address=Address(0xEA519C47889074E6378B0D83747F2C3EA0B9CBC9), # noqa: E501 ) - # Source: lll - # { (CALL 0 0 0 0 0 0) [[0]] (CALL 50000 0 0 0 0 0) [[2]] (RETURNDATASIZE) } # noqa: E501 - addr = pre.deploy_contract( # noqa: F841 - code=Op.POP( - Op.CALL( - gas=0x0, - address=0x127EAF7E31D691A8393B7A2F84A6E94372190C01, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) + prober_delegatecall = pre.deploy_contract( + code=prober_code( + Op.DELEGATECALL, reverter, RESULT_SLOT, RETURN_DATA_SIZE_SLOT ) - + Op.SSTORE( - key=0x0, - value=Op.CALL( - gas=inner_call_gas, - address=0x93A599BDE9A3B6390AFDB06952AA5EC0B8C44F3B, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.SSTORE(key=0x2, value=Op.RETURNDATASIZE) - + Op.STOP, - balance=1, - nonce=0, - address=Address(0xE73611B5B479B30C93AC377AEB3BFB199764F3C3), # noqa: E501 ) - # Source: lll - # { (CALL 0 0 0 0 0 0) [[10]] (CALL 260000 (CALLDATALOAD 0) 0 0 0 0 0)} # noqa: E501 - target = pre.deploy_contract( # noqa: F841 - code=Op.POP( - Op.CALL( - gas=0x0, - address=0x127EAF7E31D691A8393B7A2F84A6E94372190C01, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) + inner_prober = pre.deploy_contract( + code=prober_code( + Op.CALL, + reverter, + NESTED_RESULT_SLOT, + NESTED_RETURN_DATA_SIZE_SLOT, ) - + Op.SSTORE( - key=0xA, - value=Op.CALL( - gas=deepest_call_gas, - address=Op.CALLDATALOAD(offset=0x0), - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.STOP, - storage={10: 255}, - balance=1, - nonce=0, - address=Address(0x172A8F572404293AA810685DFDC6F740C300CC4B), # noqa: E501 ) - # Source: lll - # { (CALL 0 0 0 0 0 0) [[0]] (CALL 100000 0 0 0 0 0) [[2]] (RETURNDATASIZE) } # noqa: E501 - addr_4 = pre.deploy_contract( # noqa: F841 - code=Op.POP( - Op.CALL( - gas=0x0, - address=0x127EAF7E31D691A8393B7A2F84A6E94372190C01, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ) + prober_nested = pre.deploy_contract( + code=prober_code( + Op.CALL, inner_prober, RESULT_SLOT, RETURN_DATA_SIZE_SLOT ) - + Op.SSTORE( - key=0x0, - value=Op.CALL( - gas=deeper_call_gas, - address=0xEA519C47889074E6378B0D83747F2C3EA0B9CBC9, - value=0x0, - args_offset=0x0, - args_size=0x0, - ret_offset=0x0, - ret_size=0x0, - ), - ) - + Op.SSTORE(key=0x2, value=Op.RETURNDATASIZE) - + Op.STOP, - balance=1, - nonce=0, - address=Address(0x6BACDFA8216DBB2A09819F8739E57AE3574C9FFF), # noqa: E501 ) - expect_entries_: list[dict] = [ - { - "indexes": {"data": 0, "gas": 0, "value": -1}, - "network": [">=Cancun"], - "result": { - addr_6: Account(storage={}), - target: Account(storage={10: 1}), - addr: Account(storage={2: 1}, nonce=0), - }, - }, - { - "indexes": {"data": 0, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - addr_6: Account(storage={}), - addr: Account(storage={}), - }, - }, - { - "indexes": {"data": 1, "gas": 0, "value": -1}, - "network": [">=Cancun"], - "result": { - addr_6: Account(storage={}), - target: Account(storage={10: 1}), - addr_2: Account(storage={2: 1}, nonce=0), - }, - }, - { - "indexes": {"data": 1, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - addr_6: Account(storage={}), - addr_2: Account(storage={}), - }, - }, - { - "indexes": {"data": 2, "gas": 0, "value": -1}, - "network": [">=Cancun"], - "result": { - addr_6: Account(storage={}), - target: Account(storage={10: 1}), - addr_3: Account(storage={2: 1}, nonce=0), - }, - }, - { - "indexes": {"data": 2, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - addr_6: Account(storage={}), - addr_3: Account(storage={}), - }, - }, - { - "indexes": {"data": 3, "gas": 0, "value": -1}, - "network": [">=Cancun"], - "result": { - addr_6: Account(storage={}), - target: Account(storage={10: 1}), - addr_4: Account(storage={0: 1}, nonce=0), - addr_5: Account(storage={5: 1}, nonce=0), - }, - }, - { - "indexes": {"data": 3, "gas": 1, "value": -1}, - "network": [">=Cancun"], - "result": { - addr_6: Account(storage={}), - target: Account(storage={10: 255}), - addr_4: Account(storage={0: 0}, nonce=0), - addr_5: Account(storage={5: 0}, nonce=0), - }, - }, - ] + big_call = Op.CALL(address=Op.CALLDATALOAD(offset=0x0), address_warm=False) + entry = pre.deploy_contract( + code=prelude + Op.SSTORE(key=ENTRY_SLOT, value=big_call) + Op.STOP, + storage={ENTRY_SLOT: ENTRY_SLOT_INITIAL}, + ) - post, _exc = resolve_expect_post(expect_entries_, d, g, v, fork) + probers = { + Op.CALL: prober_call, + Op.CALLCODE: prober_callcode, + Op.DELEGATECALL: prober_delegatecall, + None: prober_nested, + } + prober = probers[call_op] + data = Hash(prober, left_padding=True) - tx_data = [ - Hash(addr, left_padding=True), - Hash(addr_2, left_padding=True), - Hash(addr_3, left_padding=True), - Hash(addr_4, left_padding=True), - ] - tx_gas = [860000, 28000] + sender = pre.fund_eoa() + if ample_gas: + tx = Transaction(sender=sender, to=entry, data=data) + else: + # The entry frame reaches its call with only STARVE_MARGIN left: + # the prober cannot even pay its prelude, and the retained 1/64 + # cannot pass the EIP-2200 stipend check, so everything reverts. + intrinsic = fork.transaction_intrinsic_cost_calculator()( + calldata=data, return_cost_deducted_prior_execution=True + ) + starved = ( + intrinsic + + prelude.gas_cost(fork) + + returner_code.gas_cost(fork) + + big_call.gas_cost(fork) + + STARVE_MARGIN + ) + forwarded = STARVE_MARGIN - STARVE_MARGIN // 64 + assert forwarded < prelude.gas_cost(fork), "prober must starve" + assert STARVE_MARGIN // 64 <= 2300, "entry store must halt" + tx = Transaction(sender=sender, to=entry, data=data, gas_limit=starved) - tx = Transaction( - sender=sender, - to=target, - data=tx_data[d], - gas_limit=tx_gas[g], - error=_exc, - ) + untouched = { + reverter: Account(storage={}), + prober_call: Account(storage={}), + prober_callcode: Account(storage={}), + prober_delegatecall: Account(storage={}), + inner_prober: Account(storage={}), + prober_nested: Account(storage={}), + } + if not ample_gas: + # The transaction runs out of gas at the entry frame: everything + # reverts. + post = { + **untouched, + entry: Account(storage={ENTRY_SLOT: ENTRY_SLOT_INITIAL}), + } + elif call_op is None: + # The nested prober's callee completes (its own probe fails), so + # the outer call succeeds and its empty return data replaces the + # buffer the prelude filled. + post = { + **untouched, + entry: Account(storage={ENTRY_SLOT: 1}), + prober_nested: Account(storage={RESULT_SLOT: 1}), + inner_prober: Account(storage={NESTED_RETURN_DATA_SIZE_SLOT: 1}), + } + else: + # The probed call reverts with one byte of return data: result 0, + # RETURNDATASIZE 1. + post = { + **untouched, + entry: Account(storage={ENTRY_SLOT: 1}), + prober: Account(storage={RETURN_DATA_SIZE_SLOT: 1}), + } - state_test(env=env, pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) From faf66377663259411f7c150f5490cbac683d3cc9 Mon Sep 17 00:00:00 2001 From: spencer Date: Wed, 23 Sep 2026 12:01:04 +0200 Subject: [PATCH 10/28] fix(spec-specs): wipe pre-existing storage on contract creation from Cancun onward (#3508) Co-authored-by: fselmo --- .../forks/amsterdam/block_access_lists.py | 17 +- src/ethereum/forks/amsterdam/state_tracker.py | 26 +- 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/cancun/state_tracker.py | 27 +- src/ethereum/forks/osaka/state_tracker.py | 27 +- src/ethereum/forks/prague/state_tracker.py | 27 +- src/ethereum/state.py | 8 +- .../frontier/create/test_create_collision.py | 337 +++++++++++++++++- .../test_deleted_account_storage.py | 101 ++++++ 13 files changed, 676 insertions(+), 29 deletions(-) create mode 100644 tests/spurious_dragon/eip161_state_trie_clearing/test_deleted_account_storage.py diff --git a/src/ethereum/forks/amsterdam/block_access_lists.py b/src/ethereum/forks/amsterdam/block_access_lists.py index 6af1063c0f5..b29fe120049 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists.py +++ b/src/ethereum/forks/amsterdam/block_access_lists.py @@ -706,19 +706,22 @@ def _get_pre_tx_account( def _get_pre_tx_storage( - pre_tx_storage: Dict[Address, Dict[Bytes32, U256]], - pre_state: PreState, + block_state: BlockState, address: Address, key: Bytes32, ) -> U256: """ Look up a storage value in cumulative state, falling back to `pre_state`. - Returns `0` if not set. + Returns `0` if not set, or if the storage at `address` was wiped + earlier in the block. """ - if address in pre_tx_storage and key in pre_tx_storage[address]: - return pre_tx_storage[address][key] - return pre_state.get_storage(address, key) + if address in block_state.storage_writes: + if key in block_state.storage_writes[address]: + return block_state.storage_writes[address][key] + if address in block_state.storage_clears: + return U256(0) + return block_state.pre_state.get_storage(address, key) def _index_start_account( @@ -754,7 +757,7 @@ def _index_start_storage( index_key = (builder.block_access_index, address, key) if index_key not in builder.index_start_storage: builder.index_start_storage[index_key] = _get_pre_tx_storage( - block_state.storage_writes, block_state.pre_state, address, key + block_state, address, key ) return builder.index_start_storage[index_key] diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 130b85aaf18..e5a8dda284f 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -49,6 +49,12 @@ class BlockState: ``account_reads`` and ``storage_reads`` accumulate across all transactions for BAL generation. + + ``storage_clears`` records addresses whose pre-existing storage + was wiped earlier in the block, so later reads must not fall back + to ``pre_state``. Contract creation over a storage-only account + and deletion of an empty account that still holds storage both + wipe storage. """ pre_state: PreState @@ -61,6 +67,7 @@ class BlockState: default_factory=dict ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) + storage_clears: Set[Address] = field(default_factory=set) @final @@ -87,6 +94,7 @@ class TransactionState: ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) created_accounts: Set[Address] = field(default_factory=set) + storage_clears: Set[Address] = field(default_factory=set) transient_storage: Dict[Tuple[Address, Bytes32], U256] = field( default_factory=dict ) @@ -267,9 +275,13 @@ def get_storage( if address in tx_state.storage_writes: if key in tx_state.storage_writes[address]: return tx_state.storage_writes[address][key] + if address in tx_state.storage_clears: + return U256(0) if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -297,6 +309,8 @@ def get_storage_original( if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -507,7 +521,8 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: Convert storage writes to reads before deleting so that accesses from created-then-destroyed accounts appear in the Block Access - List. Only supports same transaction destruction. + List. Record the address in ``storage_clears`` so that reads no + longer fall back to earlier block writes or ``pre_state``. Parameters ---------- @@ -521,6 +536,7 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: for key in tx_state.storage_writes[address]: tx_state.storage_reads.add((address, key)) del tx_state.storage_writes[address] + tx_state.storage_clears.add(address) def mark_account_created(tx_state: TransactionState, address: Address) -> None: @@ -743,6 +759,7 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: }, code_writes=dict(tx_state.code_writes), created_accounts=tx_state.created_accounts, + storage_clears=set(tx_state.storage_clears), transient_storage=dict(tx_state.transient_storage), storage_reads=tx_state.storage_reads, account_reads=tx_state.account_reads, @@ -766,6 +783,7 @@ def restore_tx_state( tx_state.account_writes = snapshot.account_writes tx_state.storage_writes = snapshot.storage_writes tx_state.code_writes = snapshot.code_writes + tx_state.storage_clears = snapshot.storage_clears tx_state.transient_storage = snapshot.transient_storage @@ -806,6 +824,10 @@ def incorporate_tx_into_block( for address, account in tx_state.account_writes.items(): block.account_writes[address] = account + for address in tx_state.storage_clears: + block.storage_clears.add(address) + block.storage_writes.pop(address, None) + for address, slots in tx_state.storage_writes.items(): if address not in block.storage_writes: block.storage_writes[address] = {} @@ -817,6 +839,7 @@ def incorporate_tx_into_block( tx_state.storage_writes.clear() tx_state.code_writes.clear() tx_state.created_accounts.clear() + tx_state.storage_clears.clear() tx_state.transient_storage.clear() tx_state.storage_reads = set() tx_state.account_reads = set() @@ -841,4 +864,5 @@ def extract_block_diff(block_state: BlockState) -> BlockDiff: account_changes=block_state.account_writes, storage_changes=block_state.storage_writes, code_changes=block_state.code_writes, + storage_clears=block_state.storage_clears, ) diff --git a/src/ethereum/forks/bpo1/state_tracker.py b/src/ethereum/forks/bpo1/state_tracker.py index 6435de13297..7ffb85ca27b 100644 --- a/src/ethereum/forks/bpo1/state_tracker.py +++ b/src/ethereum/forks/bpo1/state_tracker.py @@ -43,6 +43,12 @@ class BlockState: Accumulate committed transaction-level changes across a block. Read chain: block writes -> pre_state. + + ``storage_clears`` records addresses whose pre-existing storage + was wiped earlier in the block, so later reads must not fall back + to ``pre_state``. Contract creation over a storage-only account + and deletion of an empty account that still holds storage both + wipe storage. """ pre_state: PreState @@ -53,6 +59,7 @@ class BlockState: default_factory=dict ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) + storage_clears: Set[Address] = field(default_factory=set) @final @@ -73,6 +80,7 @@ class TransactionState: ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) created_accounts: Set[Address] = field(default_factory=set) + storage_clears: Set[Address] = field(default_factory=set) transient_storage: Dict[Tuple[Address, Bytes32], U256] = field( default_factory=dict ) @@ -186,9 +194,13 @@ def get_storage( if address in tx_state.storage_writes: if key in tx_state.storage_writes[address]: return tx_state.storage_writes[address][key] + if address in tx_state.storage_clears: + return U256(0) if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -216,6 +228,8 @@ def get_storage_original( if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -401,7 +415,9 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ Completely remove the storage at ``address``. - Only supports same transaction destruction. + Drop this transaction's writes and record the address in + ``storage_clears`` so that reads no longer fall back to earlier + block writes or ``pre_state``. Parameters ---------- @@ -413,6 +429,7 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ if address in tx_state.storage_writes: del tx_state.storage_writes[address] + tx_state.storage_clears.add(address) def mark_account_created(tx_state: TransactionState, address: Address) -> None: @@ -634,6 +651,7 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: }, code_writes=dict(tx_state.code_writes), created_accounts=tx_state.created_accounts, + storage_clears=set(tx_state.storage_clears), transient_storage=dict(tx_state.transient_storage), ) @@ -655,6 +673,7 @@ def restore_tx_state( tx_state.account_writes = snapshot.account_writes tx_state.storage_writes = snapshot.storage_writes tx_state.code_writes = snapshot.code_writes + tx_state.storage_clears = snapshot.storage_clears tx_state.transient_storage = snapshot.transient_storage @@ -676,6 +695,10 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: for address, account in tx_state.account_writes.items(): block.account_writes[address] = account + for address in tx_state.storage_clears: + block.storage_clears.add(address) + block.storage_writes.pop(address, None) + for address, slots in tx_state.storage_writes.items(): if address not in block.storage_writes: block.storage_writes[address] = {} @@ -687,6 +710,7 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: tx_state.storage_writes.clear() tx_state.code_writes.clear() tx_state.created_accounts.clear() + tx_state.storage_clears.clear() tx_state.transient_storage.clear() @@ -709,4 +733,5 @@ def extract_block_diff(block_state: BlockState) -> BlockDiff: account_changes=block_state.account_writes, storage_changes=block_state.storage_writes, code_changes=block_state.code_writes, + storage_clears=block_state.storage_clears, ) diff --git a/src/ethereum/forks/bpo2/state_tracker.py b/src/ethereum/forks/bpo2/state_tracker.py index 6435de13297..7ffb85ca27b 100644 --- a/src/ethereum/forks/bpo2/state_tracker.py +++ b/src/ethereum/forks/bpo2/state_tracker.py @@ -43,6 +43,12 @@ class BlockState: Accumulate committed transaction-level changes across a block. Read chain: block writes -> pre_state. + + ``storage_clears`` records addresses whose pre-existing storage + was wiped earlier in the block, so later reads must not fall back + to ``pre_state``. Contract creation over a storage-only account + and deletion of an empty account that still holds storage both + wipe storage. """ pre_state: PreState @@ -53,6 +59,7 @@ class BlockState: default_factory=dict ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) + storage_clears: Set[Address] = field(default_factory=set) @final @@ -73,6 +80,7 @@ class TransactionState: ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) created_accounts: Set[Address] = field(default_factory=set) + storage_clears: Set[Address] = field(default_factory=set) transient_storage: Dict[Tuple[Address, Bytes32], U256] = field( default_factory=dict ) @@ -186,9 +194,13 @@ def get_storage( if address in tx_state.storage_writes: if key in tx_state.storage_writes[address]: return tx_state.storage_writes[address][key] + if address in tx_state.storage_clears: + return U256(0) if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -216,6 +228,8 @@ def get_storage_original( if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -401,7 +415,9 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ Completely remove the storage at ``address``. - Only supports same transaction destruction. + Drop this transaction's writes and record the address in + ``storage_clears`` so that reads no longer fall back to earlier + block writes or ``pre_state``. Parameters ---------- @@ -413,6 +429,7 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ if address in tx_state.storage_writes: del tx_state.storage_writes[address] + tx_state.storage_clears.add(address) def mark_account_created(tx_state: TransactionState, address: Address) -> None: @@ -634,6 +651,7 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: }, code_writes=dict(tx_state.code_writes), created_accounts=tx_state.created_accounts, + storage_clears=set(tx_state.storage_clears), transient_storage=dict(tx_state.transient_storage), ) @@ -655,6 +673,7 @@ def restore_tx_state( tx_state.account_writes = snapshot.account_writes tx_state.storage_writes = snapshot.storage_writes tx_state.code_writes = snapshot.code_writes + tx_state.storage_clears = snapshot.storage_clears tx_state.transient_storage = snapshot.transient_storage @@ -676,6 +695,10 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: for address, account in tx_state.account_writes.items(): block.account_writes[address] = account + for address in tx_state.storage_clears: + block.storage_clears.add(address) + block.storage_writes.pop(address, None) + for address, slots in tx_state.storage_writes.items(): if address not in block.storage_writes: block.storage_writes[address] = {} @@ -687,6 +710,7 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: tx_state.storage_writes.clear() tx_state.code_writes.clear() tx_state.created_accounts.clear() + tx_state.storage_clears.clear() tx_state.transient_storage.clear() @@ -709,4 +733,5 @@ def extract_block_diff(block_state: BlockState) -> BlockDiff: account_changes=block_state.account_writes, storage_changes=block_state.storage_writes, code_changes=block_state.code_writes, + storage_clears=block_state.storage_clears, ) diff --git a/src/ethereum/forks/bpo3/state_tracker.py b/src/ethereum/forks/bpo3/state_tracker.py index 6435de13297..7ffb85ca27b 100644 --- a/src/ethereum/forks/bpo3/state_tracker.py +++ b/src/ethereum/forks/bpo3/state_tracker.py @@ -43,6 +43,12 @@ class BlockState: Accumulate committed transaction-level changes across a block. Read chain: block writes -> pre_state. + + ``storage_clears`` records addresses whose pre-existing storage + was wiped earlier in the block, so later reads must not fall back + to ``pre_state``. Contract creation over a storage-only account + and deletion of an empty account that still holds storage both + wipe storage. """ pre_state: PreState @@ -53,6 +59,7 @@ class BlockState: default_factory=dict ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) + storage_clears: Set[Address] = field(default_factory=set) @final @@ -73,6 +80,7 @@ class TransactionState: ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) created_accounts: Set[Address] = field(default_factory=set) + storage_clears: Set[Address] = field(default_factory=set) transient_storage: Dict[Tuple[Address, Bytes32], U256] = field( default_factory=dict ) @@ -186,9 +194,13 @@ def get_storage( if address in tx_state.storage_writes: if key in tx_state.storage_writes[address]: return tx_state.storage_writes[address][key] + if address in tx_state.storage_clears: + return U256(0) if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -216,6 +228,8 @@ def get_storage_original( if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -401,7 +415,9 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ Completely remove the storage at ``address``. - Only supports same transaction destruction. + Drop this transaction's writes and record the address in + ``storage_clears`` so that reads no longer fall back to earlier + block writes or ``pre_state``. Parameters ---------- @@ -413,6 +429,7 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ if address in tx_state.storage_writes: del tx_state.storage_writes[address] + tx_state.storage_clears.add(address) def mark_account_created(tx_state: TransactionState, address: Address) -> None: @@ -634,6 +651,7 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: }, code_writes=dict(tx_state.code_writes), created_accounts=tx_state.created_accounts, + storage_clears=set(tx_state.storage_clears), transient_storage=dict(tx_state.transient_storage), ) @@ -655,6 +673,7 @@ def restore_tx_state( tx_state.account_writes = snapshot.account_writes tx_state.storage_writes = snapshot.storage_writes tx_state.code_writes = snapshot.code_writes + tx_state.storage_clears = snapshot.storage_clears tx_state.transient_storage = snapshot.transient_storage @@ -676,6 +695,10 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: for address, account in tx_state.account_writes.items(): block.account_writes[address] = account + for address in tx_state.storage_clears: + block.storage_clears.add(address) + block.storage_writes.pop(address, None) + for address, slots in tx_state.storage_writes.items(): if address not in block.storage_writes: block.storage_writes[address] = {} @@ -687,6 +710,7 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: tx_state.storage_writes.clear() tx_state.code_writes.clear() tx_state.created_accounts.clear() + tx_state.storage_clears.clear() tx_state.transient_storage.clear() @@ -709,4 +733,5 @@ def extract_block_diff(block_state: BlockState) -> BlockDiff: account_changes=block_state.account_writes, storage_changes=block_state.storage_writes, code_changes=block_state.code_writes, + storage_clears=block_state.storage_clears, ) diff --git a/src/ethereum/forks/bpo4/state_tracker.py b/src/ethereum/forks/bpo4/state_tracker.py index 6435de13297..7ffb85ca27b 100644 --- a/src/ethereum/forks/bpo4/state_tracker.py +++ b/src/ethereum/forks/bpo4/state_tracker.py @@ -43,6 +43,12 @@ class BlockState: Accumulate committed transaction-level changes across a block. Read chain: block writes -> pre_state. + + ``storage_clears`` records addresses whose pre-existing storage + was wiped earlier in the block, so later reads must not fall back + to ``pre_state``. Contract creation over a storage-only account + and deletion of an empty account that still holds storage both + wipe storage. """ pre_state: PreState @@ -53,6 +59,7 @@ class BlockState: default_factory=dict ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) + storage_clears: Set[Address] = field(default_factory=set) @final @@ -73,6 +80,7 @@ class TransactionState: ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) created_accounts: Set[Address] = field(default_factory=set) + storage_clears: Set[Address] = field(default_factory=set) transient_storage: Dict[Tuple[Address, Bytes32], U256] = field( default_factory=dict ) @@ -186,9 +194,13 @@ def get_storage( if address in tx_state.storage_writes: if key in tx_state.storage_writes[address]: return tx_state.storage_writes[address][key] + if address in tx_state.storage_clears: + return U256(0) if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -216,6 +228,8 @@ def get_storage_original( if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -401,7 +415,9 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ Completely remove the storage at ``address``. - Only supports same transaction destruction. + Drop this transaction's writes and record the address in + ``storage_clears`` so that reads no longer fall back to earlier + block writes or ``pre_state``. Parameters ---------- @@ -413,6 +429,7 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ if address in tx_state.storage_writes: del tx_state.storage_writes[address] + tx_state.storage_clears.add(address) def mark_account_created(tx_state: TransactionState, address: Address) -> None: @@ -634,6 +651,7 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: }, code_writes=dict(tx_state.code_writes), created_accounts=tx_state.created_accounts, + storage_clears=set(tx_state.storage_clears), transient_storage=dict(tx_state.transient_storage), ) @@ -655,6 +673,7 @@ def restore_tx_state( tx_state.account_writes = snapshot.account_writes tx_state.storage_writes = snapshot.storage_writes tx_state.code_writes = snapshot.code_writes + tx_state.storage_clears = snapshot.storage_clears tx_state.transient_storage = snapshot.transient_storage @@ -676,6 +695,10 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: for address, account in tx_state.account_writes.items(): block.account_writes[address] = account + for address in tx_state.storage_clears: + block.storage_clears.add(address) + block.storage_writes.pop(address, None) + for address, slots in tx_state.storage_writes.items(): if address not in block.storage_writes: block.storage_writes[address] = {} @@ -687,6 +710,7 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: tx_state.storage_writes.clear() tx_state.code_writes.clear() tx_state.created_accounts.clear() + tx_state.storage_clears.clear() tx_state.transient_storage.clear() @@ -709,4 +733,5 @@ def extract_block_diff(block_state: BlockState) -> BlockDiff: account_changes=block_state.account_writes, storage_changes=block_state.storage_writes, code_changes=block_state.code_writes, + storage_clears=block_state.storage_clears, ) diff --git a/src/ethereum/forks/bpo5/state_tracker.py b/src/ethereum/forks/bpo5/state_tracker.py index 6435de13297..7ffb85ca27b 100644 --- a/src/ethereum/forks/bpo5/state_tracker.py +++ b/src/ethereum/forks/bpo5/state_tracker.py @@ -43,6 +43,12 @@ class BlockState: Accumulate committed transaction-level changes across a block. Read chain: block writes -> pre_state. + + ``storage_clears`` records addresses whose pre-existing storage + was wiped earlier in the block, so later reads must not fall back + to ``pre_state``. Contract creation over a storage-only account + and deletion of an empty account that still holds storage both + wipe storage. """ pre_state: PreState @@ -53,6 +59,7 @@ class BlockState: default_factory=dict ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) + storage_clears: Set[Address] = field(default_factory=set) @final @@ -73,6 +80,7 @@ class TransactionState: ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) created_accounts: Set[Address] = field(default_factory=set) + storage_clears: Set[Address] = field(default_factory=set) transient_storage: Dict[Tuple[Address, Bytes32], U256] = field( default_factory=dict ) @@ -186,9 +194,13 @@ def get_storage( if address in tx_state.storage_writes: if key in tx_state.storage_writes[address]: return tx_state.storage_writes[address][key] + if address in tx_state.storage_clears: + return U256(0) if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -216,6 +228,8 @@ def get_storage_original( if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -401,7 +415,9 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ Completely remove the storage at ``address``. - Only supports same transaction destruction. + Drop this transaction's writes and record the address in + ``storage_clears`` so that reads no longer fall back to earlier + block writes or ``pre_state``. Parameters ---------- @@ -413,6 +429,7 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ if address in tx_state.storage_writes: del tx_state.storage_writes[address] + tx_state.storage_clears.add(address) def mark_account_created(tx_state: TransactionState, address: Address) -> None: @@ -634,6 +651,7 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: }, code_writes=dict(tx_state.code_writes), created_accounts=tx_state.created_accounts, + storage_clears=set(tx_state.storage_clears), transient_storage=dict(tx_state.transient_storage), ) @@ -655,6 +673,7 @@ def restore_tx_state( tx_state.account_writes = snapshot.account_writes tx_state.storage_writes = snapshot.storage_writes tx_state.code_writes = snapshot.code_writes + tx_state.storage_clears = snapshot.storage_clears tx_state.transient_storage = snapshot.transient_storage @@ -676,6 +695,10 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: for address, account in tx_state.account_writes.items(): block.account_writes[address] = account + for address in tx_state.storage_clears: + block.storage_clears.add(address) + block.storage_writes.pop(address, None) + for address, slots in tx_state.storage_writes.items(): if address not in block.storage_writes: block.storage_writes[address] = {} @@ -687,6 +710,7 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: tx_state.storage_writes.clear() tx_state.code_writes.clear() tx_state.created_accounts.clear() + tx_state.storage_clears.clear() tx_state.transient_storage.clear() @@ -709,4 +733,5 @@ def extract_block_diff(block_state: BlockState) -> BlockDiff: account_changes=block_state.account_writes, storage_changes=block_state.storage_writes, code_changes=block_state.code_writes, + storage_clears=block_state.storage_clears, ) diff --git a/src/ethereum/forks/cancun/state_tracker.py b/src/ethereum/forks/cancun/state_tracker.py index 6435de13297..7ffb85ca27b 100644 --- a/src/ethereum/forks/cancun/state_tracker.py +++ b/src/ethereum/forks/cancun/state_tracker.py @@ -43,6 +43,12 @@ class BlockState: Accumulate committed transaction-level changes across a block. Read chain: block writes -> pre_state. + + ``storage_clears`` records addresses whose pre-existing storage + was wiped earlier in the block, so later reads must not fall back + to ``pre_state``. Contract creation over a storage-only account + and deletion of an empty account that still holds storage both + wipe storage. """ pre_state: PreState @@ -53,6 +59,7 @@ class BlockState: default_factory=dict ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) + storage_clears: Set[Address] = field(default_factory=set) @final @@ -73,6 +80,7 @@ class TransactionState: ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) created_accounts: Set[Address] = field(default_factory=set) + storage_clears: Set[Address] = field(default_factory=set) transient_storage: Dict[Tuple[Address, Bytes32], U256] = field( default_factory=dict ) @@ -186,9 +194,13 @@ def get_storage( if address in tx_state.storage_writes: if key in tx_state.storage_writes[address]: return tx_state.storage_writes[address][key] + if address in tx_state.storage_clears: + return U256(0) if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -216,6 +228,8 @@ def get_storage_original( if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -401,7 +415,9 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ Completely remove the storage at ``address``. - Only supports same transaction destruction. + Drop this transaction's writes and record the address in + ``storage_clears`` so that reads no longer fall back to earlier + block writes or ``pre_state``. Parameters ---------- @@ -413,6 +429,7 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ if address in tx_state.storage_writes: del tx_state.storage_writes[address] + tx_state.storage_clears.add(address) def mark_account_created(tx_state: TransactionState, address: Address) -> None: @@ -634,6 +651,7 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: }, code_writes=dict(tx_state.code_writes), created_accounts=tx_state.created_accounts, + storage_clears=set(tx_state.storage_clears), transient_storage=dict(tx_state.transient_storage), ) @@ -655,6 +673,7 @@ def restore_tx_state( tx_state.account_writes = snapshot.account_writes tx_state.storage_writes = snapshot.storage_writes tx_state.code_writes = snapshot.code_writes + tx_state.storage_clears = snapshot.storage_clears tx_state.transient_storage = snapshot.transient_storage @@ -676,6 +695,10 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: for address, account in tx_state.account_writes.items(): block.account_writes[address] = account + for address in tx_state.storage_clears: + block.storage_clears.add(address) + block.storage_writes.pop(address, None) + for address, slots in tx_state.storage_writes.items(): if address not in block.storage_writes: block.storage_writes[address] = {} @@ -687,6 +710,7 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: tx_state.storage_writes.clear() tx_state.code_writes.clear() tx_state.created_accounts.clear() + tx_state.storage_clears.clear() tx_state.transient_storage.clear() @@ -709,4 +733,5 @@ def extract_block_diff(block_state: BlockState) -> BlockDiff: account_changes=block_state.account_writes, storage_changes=block_state.storage_writes, code_changes=block_state.code_writes, + storage_clears=block_state.storage_clears, ) diff --git a/src/ethereum/forks/osaka/state_tracker.py b/src/ethereum/forks/osaka/state_tracker.py index 6435de13297..7ffb85ca27b 100644 --- a/src/ethereum/forks/osaka/state_tracker.py +++ b/src/ethereum/forks/osaka/state_tracker.py @@ -43,6 +43,12 @@ class BlockState: Accumulate committed transaction-level changes across a block. Read chain: block writes -> pre_state. + + ``storage_clears`` records addresses whose pre-existing storage + was wiped earlier in the block, so later reads must not fall back + to ``pre_state``. Contract creation over a storage-only account + and deletion of an empty account that still holds storage both + wipe storage. """ pre_state: PreState @@ -53,6 +59,7 @@ class BlockState: default_factory=dict ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) + storage_clears: Set[Address] = field(default_factory=set) @final @@ -73,6 +80,7 @@ class TransactionState: ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) created_accounts: Set[Address] = field(default_factory=set) + storage_clears: Set[Address] = field(default_factory=set) transient_storage: Dict[Tuple[Address, Bytes32], U256] = field( default_factory=dict ) @@ -186,9 +194,13 @@ def get_storage( if address in tx_state.storage_writes: if key in tx_state.storage_writes[address]: return tx_state.storage_writes[address][key] + if address in tx_state.storage_clears: + return U256(0) if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -216,6 +228,8 @@ def get_storage_original( if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -401,7 +415,9 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ Completely remove the storage at ``address``. - Only supports same transaction destruction. + Drop this transaction's writes and record the address in + ``storage_clears`` so that reads no longer fall back to earlier + block writes or ``pre_state``. Parameters ---------- @@ -413,6 +429,7 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ if address in tx_state.storage_writes: del tx_state.storage_writes[address] + tx_state.storage_clears.add(address) def mark_account_created(tx_state: TransactionState, address: Address) -> None: @@ -634,6 +651,7 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: }, code_writes=dict(tx_state.code_writes), created_accounts=tx_state.created_accounts, + storage_clears=set(tx_state.storage_clears), transient_storage=dict(tx_state.transient_storage), ) @@ -655,6 +673,7 @@ def restore_tx_state( tx_state.account_writes = snapshot.account_writes tx_state.storage_writes = snapshot.storage_writes tx_state.code_writes = snapshot.code_writes + tx_state.storage_clears = snapshot.storage_clears tx_state.transient_storage = snapshot.transient_storage @@ -676,6 +695,10 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: for address, account in tx_state.account_writes.items(): block.account_writes[address] = account + for address in tx_state.storage_clears: + block.storage_clears.add(address) + block.storage_writes.pop(address, None) + for address, slots in tx_state.storage_writes.items(): if address not in block.storage_writes: block.storage_writes[address] = {} @@ -687,6 +710,7 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: tx_state.storage_writes.clear() tx_state.code_writes.clear() tx_state.created_accounts.clear() + tx_state.storage_clears.clear() tx_state.transient_storage.clear() @@ -709,4 +733,5 @@ def extract_block_diff(block_state: BlockState) -> BlockDiff: account_changes=block_state.account_writes, storage_changes=block_state.storage_writes, code_changes=block_state.code_writes, + storage_clears=block_state.storage_clears, ) diff --git a/src/ethereum/forks/prague/state_tracker.py b/src/ethereum/forks/prague/state_tracker.py index 6435de13297..7ffb85ca27b 100644 --- a/src/ethereum/forks/prague/state_tracker.py +++ b/src/ethereum/forks/prague/state_tracker.py @@ -43,6 +43,12 @@ class BlockState: Accumulate committed transaction-level changes across a block. Read chain: block writes -> pre_state. + + ``storage_clears`` records addresses whose pre-existing storage + was wiped earlier in the block, so later reads must not fall back + to ``pre_state``. Contract creation over a storage-only account + and deletion of an empty account that still holds storage both + wipe storage. """ pre_state: PreState @@ -53,6 +59,7 @@ class BlockState: default_factory=dict ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) + storage_clears: Set[Address] = field(default_factory=set) @final @@ -73,6 +80,7 @@ class TransactionState: ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) created_accounts: Set[Address] = field(default_factory=set) + storage_clears: Set[Address] = field(default_factory=set) transient_storage: Dict[Tuple[Address, Bytes32], U256] = field( default_factory=dict ) @@ -186,9 +194,13 @@ def get_storage( if address in tx_state.storage_writes: if key in tx_state.storage_writes[address]: return tx_state.storage_writes[address][key] + if address in tx_state.storage_clears: + return U256(0) if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -216,6 +228,8 @@ def get_storage_original( if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -401,7 +415,9 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ Completely remove the storage at ``address``. - Only supports same transaction destruction. + Drop this transaction's writes and record the address in + ``storage_clears`` so that reads no longer fall back to earlier + block writes or ``pre_state``. Parameters ---------- @@ -413,6 +429,7 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ if address in tx_state.storage_writes: del tx_state.storage_writes[address] + tx_state.storage_clears.add(address) def mark_account_created(tx_state: TransactionState, address: Address) -> None: @@ -634,6 +651,7 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: }, code_writes=dict(tx_state.code_writes), created_accounts=tx_state.created_accounts, + storage_clears=set(tx_state.storage_clears), transient_storage=dict(tx_state.transient_storage), ) @@ -655,6 +673,7 @@ def restore_tx_state( tx_state.account_writes = snapshot.account_writes tx_state.storage_writes = snapshot.storage_writes tx_state.code_writes = snapshot.code_writes + tx_state.storage_clears = snapshot.storage_clears tx_state.transient_storage = snapshot.transient_storage @@ -676,6 +695,10 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: for address, account in tx_state.account_writes.items(): block.account_writes[address] = account + for address in tx_state.storage_clears: + block.storage_clears.add(address) + block.storage_writes.pop(address, None) + for address, slots in tx_state.storage_writes.items(): if address not in block.storage_writes: block.storage_writes[address] = {} @@ -687,6 +710,7 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: tx_state.storage_writes.clear() tx_state.code_writes.clear() tx_state.created_accounts.clear() + tx_state.storage_clears.clear() tx_state.transient_storage.clear() @@ -709,4 +733,5 @@ def extract_block_diff(block_state: BlockState) -> BlockDiff: account_changes=block_state.account_writes, storage_changes=block_state.storage_writes, code_changes=block_state.code_writes, + storage_clears=block_state.storage_clears, ) diff --git a/src/ethereum/state.py b/src/ethereum/state.py index 5134e02e672..b8d3dda6e4c 100644 --- a/src/ethereum/state.py +++ b/src/ethereum/state.py @@ -79,9 +79,11 @@ class BlockDiff: storage_clears: Set[Address] = field(default_factory=set) """ Addresses whose pre-existing storage was wiped during block - execution (via a pre-EIP-6780 `SELFDESTRUCT`). Their storage - tries are dropped before [`storage_changes`][sc] is applied, so any - post-wipe writes begin from empty storage. + execution: a pre-EIP-6780 `SELFDESTRUCT`, a contract creation + over a storage-only account, or the deletion of an empty account + that still held storage. Their storage tries are dropped before + [`storage_changes`][sc] is applied, so any post-wipe writes begin + from empty storage. [sc]: ref:ethereum.state.BlockDiff.storage_changes """ diff --git a/tests/frontier/create/test_create_collision.py b/tests/frontier/create/test_create_collision.py index f25438039e6..be6551e6cde 100644 --- a/tests/frontier/create/test_create_collision.py +++ b/tests/frontier/create/test_create_collision.py @@ -1,17 +1,21 @@ """ Test collision in CREATE/CREATE2 account creation, where the existing account has non-empty code or nonce (EIP-684), and that an account -with only a balance is deployable. +with only a balance or only storage is deployable. """ -from typing import Dict, List +from typing import Dict, List, Tuple import pytest from execution_testing import ( Account, Alloc, BalAccountExpectation, + BalStorageChange, + BalStorageSlot, + Block, BlockAccessListExpectation, + BlockchainTestFiller, Bytecode, Fork, GasConsumer, @@ -20,6 +24,7 @@ ParameterSet, StateTestFiller, Transaction, + TransactionReceipt, compute_create_address, ) @@ -40,14 +45,13 @@ # 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. +# nonce. Cells with zero nonce and empty code are excluded because +# the account is deployable (see the balance-only and storage-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, @@ -359,3 +363,316 @@ def test_create_opcode_balance_only_target( }, tx=tx, ) + + +# Initcode for the storage-only tests. Slot 0x00 records one more than +# the value the initcode reads from slot 0x01, which the target account +# pre-seeds: a wiped target stores 1, a retained one stores 2, and an +# aborted creation stores nothing. Slot 0x02 is never read, so only a +# wipe that covers slots the initcode does not touch can zero it. +STORAGE_PROBE_PREFIX = Op.SSTORE( + 0, + Op.ADD(Op.SLOAD(1, key_warm=False), 1), + key_warm=False, + original_value=0, + new_value=1, +) +STORAGE_PROBE_INITCODE = Initcode( + deploy_code=Op.STOP, + initcode_prefix=STORAGE_PROBE_PREFIX, +) +STORAGE_ONLY_PRE_STORAGE = {0x01: 0x01, 0x02: 0x02} +STORAGE_ONLY_POST_STORAGE = {0x00: 0x01, 0x01: 0x00, 0x02: 0x00} + +# TODO: Contract creation over a zero-nonce account that holds storage +# stays undefined for clients until EIP-8253 (Hegota) bumps the nonce of +# the mainnet accounts of that shape. Revisit the storage-only tests once +# EIP-8253 ships: unskip them or drop them. See PR #3508. +STORAGE_ONLY_ACCOUNT_SKIP = pytest.mark.skip( + reason="Undefined until EIP-8253 (Hegota), see PR #3508" +) + + +STORAGE_ONLY_INITCODE_OUTCOMES = [ + pytest.param("correct", id="correct-initcode"), + pytest.param("revert", id="revert-initcode"), + pytest.param("oog", id="oog-initcode"), +] + + +def created_contract_nonce(fork: Fork) -> int: + """ + Return the nonce of a freshly created contract. EIP-161 set it to + one; before that it stayed at zero, which is how the storage-only + account shape came to exist. + """ + return 1 if fork.is_eip_enabled(161) else 0 + + +def storage_only_case( + outcome: str, fork: Fork, balance: int +) -> Tuple[Bytecode, Account]: + """ + Return the initcode for a storage-only creation outcome and the + target account it leaves behind: the probe deploys over wiped + storage, and a reverting or out-of-gas initcode leaves the storage + in place. + """ + retained = Account( + nonce=0, balance=balance, code=b"", storage=STORAGE_ONLY_PRE_STORAGE + ) + if outcome == "correct": + deployed = Account( + nonce=created_contract_nonce(fork), + balance=balance, + code=STORAGE_PROBE_INITCODE.deploy_code, + storage=STORAGE_ONLY_POST_STORAGE, + ) + return STORAGE_PROBE_INITCODE, deployed + elif outcome == "revert": + return Op.REVERT(0, 0), retained + elif outcome == "oog": + return GasConsumer.out_of_gas(fork), retained + else: + raise ValueError(f"unknown initcode outcome: {outcome}") + + +def creation_tx_gas(fork: Fork, initcode: Bytecode) -> int | None: + """ + Return the gas a creation transaction running ``initcode`` uses, or + ``None`` when it is the gas limit and pins nothing: an initcode that + runs out of gas, or ``REVERT`` before EIP-140 made it an opcode. + """ + if initcode == GasConsumer.out_of_gas(fork): + return None + if initcode == Op.REVERT(0, 0) and not fork.is_eip_enabled(140): + return None + if isinstance(initcode, Initcode) and fork.is_eip_enabled(8037): + # TODO: Initcode.gas_cost prices the code deposit's state gas + # 512 short of the spec, so the deploying case stays unpinned. + return None + intrinsic_cost = fork.transaction_intrinsic_cost_calculator() + # EIP-7623: the calldata floor binds when execution is cheap. + execution_gas = intrinsic_cost( + calldata=initcode, + contract_creation=True, + return_cost_deducted_prior_execution=True, + ) + initcode.gas_cost(fork) + floor_gas = intrinsic_cost(calldata=initcode, contract_creation=True) + return max(execution_gas, floor_gas) + + +@STORAGE_ONLY_ACCOUNT_SKIP +@pytest.mark.parametrize("initcode_outcome", STORAGE_ONLY_INITCODE_OUTCOMES) +@pytest.mark.parametrize("balance", [0, 1]) +@pytest.mark.with_all_contract_creating_tx_types +def test_create_tx_storage_only_target( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + tx_type: int, + initcode_outcome: str, + balance: int, +) -> None: + """ + Test that a contract creation transaction succeeds when the target + address has zero nonce and no code but non-empty storage: EIP-684 + does not consider storage, and the pre-existing storage is wiped + before the initcode runs. A failing initcode leaves it in place. + """ + initcode, target_post = storage_only_case(initcode_outcome, fork, balance) + gas_used = creation_tx_gas(fork, initcode) + tx = Transaction( + sender=pre.fund_eoa(), + ty=tx_type, + to=None, + data=initcode, + protected=False, + expected_receipt=( + TransactionReceipt(cumulative_gas_used=gas_used) + if gas_used is not None + else None + ), + ) + + created_contract_address = tx.created_contract + + pre[created_contract_address] = Account( + balance=balance, storage=STORAGE_ONLY_PRE_STORAGE + ) + + state_test( + pre=pre, + post={created_contract_address: target_post}, + tx=tx, + ) + + +@STORAGE_ONLY_ACCOUNT_SKIP +@pytest.mark.parametrize("initcode_outcome", STORAGE_ONLY_INITCODE_OUTCOMES) +@pytest.mark.parametrize("balance", [0, 1]) +@pytest.mark.with_all_create_opcodes +def test_create_opcode_storage_only_target( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + create_opcode: Op, + initcode_outcome: str, + balance: int, +) -> None: + """ + Test that a contract creation opcode succeeds when the target + address has zero nonce and no code but non-empty storage: EIP-684 + does not consider storage, and the pre-existing storage is wiped + before the initcode runs. A failing initcode leaves it in place. + """ + initcode, target_post = storage_only_case(initcode_outcome, fork, balance) + assert len(initcode) <= 32 + contract_creator_code = ( + # Stores the created address, which is zero on failure. + Op.MSTORE(0, Op.PUSH32(bytes(initcode).ljust(32, b"\0"))) + + Op.SSTORE(0x01, create_opcode(value=0, offset=0, size=len(initcode))) + ) + 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=balance, storage=STORAGE_ONLY_PRE_STORAGE + ) + + if initcode_outcome == "correct": + creator_post = Account( + nonce=2, storage={0x01: created_contract_address} + ) + else: + # Whether the creator's nonce bump survives the failed child + # depends on EIP-150, which is not the subject here. + creator_post = Account(storage={0x01: 0}) + state_test( + pre=pre, + post={ + created_contract_address: target_post, + contract_creator_address: creator_post, + }, + tx=tx, + ) + + +@STORAGE_ONLY_ACCOUNT_SKIP +def test_create_tx_storage_only_target_later_tx( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test that storage wiped by a contract creation stays wiped for the + rest of the block: a later transaction reads the old slots as zero + and a write of zero to one of them is a no-op. + """ + # The deployed code repeats the initcode probe on slot 0x02, which + # only the wipe zeroes, and writes the zero back so the block access + # list has to net that slot against the wiped value, not the + # pre-block one. + runtime_probe = Op.SSTORE( + 3, + Op.ADD(Op.SLOAD(2, key_warm=False), 1), + key_warm=False, + original_value=0, + new_value=1, + ) + Op.SSTORE(2, 0, key_warm=True, original_value=0, new_value=0) + initcode = Initcode( + deploy_code=runtime_probe, + initcode_prefix=STORAGE_PROBE_PREFIX, + ) + + sender = pre.fund_eoa() + create_gas = creation_tx_gas(fork, initcode) + create_tx = Transaction( + sender=sender, + to=None, + data=initcode, + protected=False, + expected_receipt=( + TransactionReceipt(cumulative_gas_used=create_gas) + if create_gas is not None + else None + ), + ) + created_contract_address = create_tx.created_contract + pre[created_contract_address] = Account(storage=STORAGE_ONLY_PRE_STORAGE) + + # The wiped slot prices as an untouched one: a cold read, a fresh + # write to slot 3 and a warm no-op write back to slot 2. + probe_gas = ( + fork.transaction_intrinsic_cost_calculator()() + + runtime_probe.gas_cost(fork) + ) + probe_tx = Transaction( + sender=sender, + to=created_contract_address, + protected=False, + expected_receipt=( + TransactionReceipt(cumulative_gas_used=create_gas + probe_gas) + if create_gas is not None + else None + ), + ) + + expected_block_access_list = None + if fork.is_eip_enabled(7928): + expected_block_access_list = BlockAccessListExpectation( + account_expectations={ + created_contract_address: BalAccountExpectation( + storage_reads=[0x01, 0x02], + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=1 + ) + ], + ), + BalStorageSlot( + slot=0x03, + slot_changes=[ + BalStorageChange( + block_access_index=2, post_value=1 + ) + ], + ), + ], + ), + } + ) + + blockchain_test( + pre=pre, + post={ + created_contract_address: Account( + nonce=created_contract_nonce(fork), + balance=0, + code=runtime_probe, + storage={**STORAGE_ONLY_POST_STORAGE, 0x03: 0x01}, + ), + }, + blocks=[ + Block( + txs=[create_tx, probe_tx], + expected_block_access_list=expected_block_access_list, + ) + ], + ) diff --git a/tests/spurious_dragon/eip161_state_trie_clearing/test_deleted_account_storage.py b/tests/spurious_dragon/eip161_state_trie_clearing/test_deleted_account_storage.py new file mode 100644 index 00000000000..3beb534d0c3 --- /dev/null +++ b/tests/spurious_dragon/eip161_state_trie_clearing/test_deleted_account_storage.py @@ -0,0 +1,101 @@ +""" +Deleting an empty account must drop its storage as well, so an account +re-created later in the block starts from empty storage. +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Environment, + Fork, + Header, + RecipientType, + Transaction, + TransactionReceipt, +) + +from .spec import ref_spec_161 + +REFERENCE_SPEC_GIT_PATH = ref_spec_161.git_path +REFERENCE_SPEC_VERSION = ref_spec_161.version + +# TODO: Deletion of an empty account that holds storage stays undefined +# for clients until EIP-8253 (Hegota) bumps the nonce of the mainnet +# accounts of that shape. Revisit this test once EIP-8253 ships: unskip +# it or drop it. See PR #3508. +STORAGE_ONLY_ACCOUNT_SKIP = pytest.mark.skip( + reason="Undefined until EIP-8253 (Hegota), see PR #3508" +) + + +@STORAGE_ONLY_ACCOUNT_SKIP +@pytest.mark.valid_from("London") +@pytest.mark.pre_alloc_mutable +def test_zero_tip_deletes_coinbase_storage( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test that a zero-tip fee credit deletes an empty coinbase together + with its pre-existing storage, so a later transaction in the block + re-creates the account with empty storage. + """ + coinbase = pre.fund_eoa(amount=0) + pre[coinbase] = Account(storage={0x01: 0x01}) + + genesis_environment = Environment(base_fee_per_gas=7) + base_fee_per_gas = fork.base_fee_per_gas_calculator()( + parent_base_fee_per_gas=7, + parent_gas_used=0, + parent_gas_limit=genesis_environment.gas_limit, + ) + + sender = pre.fund_eoa() + intrinsic_cost = fork.transaction_intrinsic_cost_calculator() + touch_gas = intrinsic_cost() + touch_tx = Transaction( + sender=sender, + to=pre.fund_eoa(amount=1), + gas_price=base_fee_per_gas, + expected_receipt=TransactionReceipt(cumulative_gas_used=touch_gas), + ) + # The deleted coinbase is a new account again for the second transfer. + fund_gas = intrinsic_cost( + sends_value=True + ) + fork.transaction_top_frame_gas_calculator()( + sends_value=True, recipient_type=RecipientType.EMPTY_ACCOUNT + ) + fund_tx = Transaction( + sender=sender, + to=coinbase, + value=1, + gas_price=base_fee_per_gas, + expected_receipt=TransactionReceipt( + cumulative_gas_used=touch_gas + fund_gas + ), + ) + + blockchain_test( + pre=pre, + genesis_environment=genesis_environment, + # Before the merge the coinbase also collects the block reward. + post={ + coinbase: Account( + nonce=0, + balance=1 + fork.get_reward(), + code=b"", + storage={}, + ), + }, + blocks=[ + Block( + txs=[touch_tx, fund_tx], + fee_recipient=coinbase, + header_verify=Header(base_fee_per_gas=base_fee_per_gas), + ) + ], + ) From 4a05b82ea94ed5dbc73972ea87cfca19eade0fbd Mon Sep 17 00:00:00 2001 From: felipe Date: Thu, 24 Sep 2026 05:08:47 -0600 Subject: [PATCH 11/28] fix(tests): isolate the BAL factory create-chain collision pre-alloc group (#3640) --- .../test_block_access_lists.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 79697903d1f..ca631c7e1be 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 @@ -3116,7 +3116,16 @@ def test_bal_cross_tx_deploy_then_call( "failure_mode", [ pytest.param("none", id="no_failure"), - pytest.param("collision", id="mid_chain_collision"), + pytest.param( + "collision", + id="mid_chain_collision", + marks=pytest.mark.pre_alloc_group( + "separate", + reason="Seeds code at a factory CREATE address that the " + "other failure modes deploy to, so sharing their genesis " + "turns their deployment into a collision.", + ), + ), pytest.param("oog", id="mid_chain_oog"), ], ) From e64b25703eb7172eca7cc0ebe12f9837ef748114 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, 25 Sep 2026 00:07:33 +0800 Subject: [PATCH 12/28] refactor(test-fixtures): stream fixture writes and merges through part files (#3628) * refactor: stream fixture writes and merges through part files * refactor(test-fixtures): simplify .part merge + make safe to interrupt - Drop the existing-target seeding: the output directory must be empty when a session starts, so a target at merge time can only be a leftover from an interrupted merge, which is now overwritten. Unlink the partial index files before their parts, so an interrupted cleanup leaves orphan parts rather than an index pointing at deleted files. Create part files exclusively, so a name clash with a part left over from an earlier session fails instead of silently merging the wrong fixture. - Tests: fold the big-fixture helper into `_make_fixture`, move the streaming tests into `TestPartialFixtureFiles`, measure merge memory as a delta so the test also passes with tracemalloc already tracing, and replace the seeding test with one that redoes an interrupted merge. * refactor: resolve tracemalloc import issue --------- Co-authored-by: fselmo --- .../execution_testing/fixtures/collector.py | 129 ++++++++++++------ .../fixtures/tests/test_collector.py | 88 +++++++++++- 2 files changed, 172 insertions(+), 45 deletions(-) diff --git a/packages/testing/src/execution_testing/fixtures/collector.py b/packages/testing/src/execution_testing/fixtures/collector.py index 7761180eda8..31bb5b7109b 100644 --- a/packages/testing/src/execution_testing/fixtures/collector.py +++ b/packages/testing/src/execution_testing/fixtures/collector.py @@ -3,6 +3,7 @@ of generated fixtures. """ +import itertools import json import os import re @@ -28,16 +29,45 @@ from .consume import FixtureConsumer, TestCaseIndexFile from .file import Fixtures +COPY_CHUNK_SIZE = 1 << 16 + +# Not ".json": a part file left behind by a killed worker must never be +# picked up as a fixture file by `consume` or `gen_index`. +PART_SUFFIX = ".part" + +# Process-wide, not per collector: a worker builds one collector per test +# module under the same worker id, and two collectors reusing a sequence +# number would name the same part file. +_part_counter = itertools.count() + + +def _copy_indented(src: Path, out: IO[str]) -> None: + """ + Append `src` to `out`, indenting every line after the first by four + spaces. + + Copies fixed-size chunks rather than lines: a single fixture line (a + transaction's calldata, a block access list) can be gigabytes. A newline + is one character, so it can never straddle a chunk boundary. + """ + with open(src) as f: + while chunk := f.read(COPY_CHUNK_SIZE): + out.write(chunk.replace("\n", "\n ")) + def merge_partial_fixture_files(output_dir: Path) -> None: """ - Merge all partial fixture JSONL files into final JSON fixture files. + Merge all partial fixture files into final JSON fixture files. Called at session end after all workers have written their partials. - Each partial file contains JSONL lines: {"k": fixture_id, "v": json_str} - - Processes one target file at a time, reading its partials sequentially - into a dict. Memory = O(entries per target), freed before next target. + Each partial JSONL file contains one line per fixture, + {"k": fixture_id, "p": part_file_name}, pointing at a part file that + holds that fixture's indented JSON. + + Fixture contents are streamed from the part files into the target, so + memory is bounded by the number of fixtures, not by their size. An + existing target is overwritten: the output directory is empty when a + session starts, so one can only be left over from an interrupted merge. """ # Find all partial files partial_files = list(output_dir.rglob("*.partial.*.jsonl")) @@ -62,17 +92,9 @@ def merge_partial_fixture_files(output_dir: Path) -> None: # Merge each group into its target file for target_path, partials in partials_by_target.items(): - # Seed from existing target file (if any) so that - # repeated single-test sessions accumulate into one file. - entries: Dict[str, str] = {} - if target_path.exists(): - with open(target_path) as existing: - try: - existing_data = json.load(existing) - for k, v in existing_data.items(): - entries[k] = json.dumps(v, indent=4) - except json.JSONDecodeError: - pass + part_by_id: Dict[str, Path] = {} + # Every part file, including any superseded by a later entry. + part_files: List[Path] = [] for partial in partials: with open(partial) as f: @@ -80,30 +102,31 @@ def merge_partial_fixture_files(output_dir: Path) -> None: line = line.strip() if line: entry = json.loads(line) - entries[entry["k"]] = entry["v"] + part_path = partial.parent / entry["p"] + part_by_id[entry["k"]] = part_path + part_files.append(part_path) - # Write sorted entries to output file + # Write sorted entries to output file, streaming each part with open(target_path, "w") as out_f: out_f.write("{\n") - sorted_keys = sorted(entries.keys()) + sorted_keys = sorted(part_by_id.keys()) last_idx = len(sorted_keys) - 1 for i, key in enumerate(sorted_keys): - key_json = json.dumps(key) - value_indented = entries[key].replace("\n", "\n ") - out_f.write(f" {key_json}: {value_indented}") + out_f.write(f" {json.dumps(key)}: ") + _copy_indented(part_by_id[key], out_f) out_f.write(",\n" if i < last_idx else "\n") out_f.write("}") - # Free memory before processing next target - entries.clear() - - # Clean up partial files + # Partials go first: an interrupted cleanup must never leave an index + # line pointing at a deleted part, only orphan parts. for partial in partials: partial.unlink() # Also remove lock files lock_file = partial.with_suffix(".lock") if lock_file.exists(): lock_file.unlink() + for part_path in part_files: + part_path.unlink() @dataclass(kw_only=True, slots=True) @@ -257,13 +280,18 @@ def _get_worker_id(self) -> str | None: self._worker_id_cached = True return self.worker_id + def _worker_suffix(self) -> str: + """Return the worker tag used in partial file names, e.g. ".gw0".""" + worker_id = self._get_worker_id() + return f".{worker_id}" if worker_id else ".main" + def add_fixture( self, info: TestInfo, fixture: BaseFixture, output_subdir: Path | None = None, ) -> Path: - """Add fixture and immediately stream to partial JSONL file.""" + """Add fixture and immediately write it to a part file.""" fixture_basename = self.get_fixture_basename(info) if ( output_subdir is not None @@ -283,7 +311,7 @@ def add_fixture( fixture.output_file_extension ) - # Stream fixture directly to partial JSONL (no memory accumulation) + # File mode: write the fixture to disk now. if self.output_dir.name != "stdout": self._stream_fixture_to_partial( fixture_path, info.get_id(), fixture @@ -316,10 +344,10 @@ def add_fixture( return fixture_path def _get_partial_fixture_file(self, fixture_path: Path) -> "IO[str]": - """Get or create a file handle for streaming fixtures.""" - worker_id = self._get_worker_id() - suffix = f".{worker_id}" if worker_id else ".main" - partial_path = fixture_path.with_suffix(f".partial{suffix}.jsonl") + """Get or create this worker's partial JSONL file for a target.""" + partial_path = fixture_path.with_suffix( + f".partial{self._worker_suffix()}.jsonl" + ) if partial_path not in self._partial_fixture_files: partial_path.parent.mkdir(parents=True, exist_ok=True) @@ -333,21 +361,40 @@ def _stream_fixture_to_partial( fixture_id: str, fixture: BaseFixture, ) -> None: - """Stream a single fixture to its partial JSONL file.""" - value = json.dumps(fixture.json_dict_with_info(), indent=4) - line = json.dumps({"k": fixture_id, "v": value}) + "\n" + """ + Write a fixture to its own part file and index it in the partial + JSONL file. + + `json.dump` serialises straight into the file, so the fixture is + never held as a single string. The part is created exclusively: a + name clash with a part left over from an earlier session, whose + index would still point at it, fails here rather than merging the + wrong fixture. + """ + partial_f = self._get_partial_fixture_file(fixture_path) + part_path = self._next_part_path(fixture_path) + with open(part_path, "x") as part_f: + json.dump(fixture.json_dict_with_info(), part_f, indent=4) - f = self._get_partial_fixture_file(fixture_path) - f.write(line) - f.flush() # Ensure data is written immediately + partial_f.write( + json.dumps({"k": fixture_id, "p": part_path.name}) + "\n" + ) + partial_f.flush() # Ensure data is written immediately + + def _next_part_path(self, fixture_path: Path) -> Path: + """Return the next part file path next to the target fixture file.""" + return fixture_path.with_suffix( + f".partial{self._worker_suffix()}.{next(_part_counter)}" + f"{PART_SUFFIX}" + ) def _get_partial_index_file(self) -> "IO[str]": """Get or create the file handle for streaming index entries.""" if self._partial_index_file is None: - worker_id = self._get_worker_id() - suffix = f".{worker_id}" if worker_id else ".main" partial_index_path = ( - self.output_dir / ".meta" / f"partial_index{suffix}.jsonl" + self.output_dir + / ".meta" + / f"partial_index{self._worker_suffix()}.jsonl" ) partial_index_path.parent.mkdir(parents=True, exist_ok=True) self._partial_index_file = open(partial_index_path, "a") diff --git a/packages/testing/src/execution_testing/fixtures/tests/test_collector.py b/packages/testing/src/execution_testing/fixtures/tests/test_collector.py index 11ef7ba1eed..22757d811b1 100644 --- a/packages/testing/src/execution_testing/fixtures/tests/test_collector.py +++ b/packages/testing/src/execution_testing/fixtures/tests/test_collector.py @@ -6,15 +6,20 @@ import pytest from ..base import BaseFixture -from ..collector import FixtureCollector, TestInfo, merge_partial_fixture_files +from ..collector import ( + COPY_CHUNK_SIZE, + FixtureCollector, + TestInfo, + merge_partial_fixture_files, +) from ..file import Fixtures from ..transaction import FixtureResult, TransactionFixture -def _make_fixture(nonce: int = 0) -> TransactionFixture: - """Create a minimal TransactionFixture for testing.""" +def _make_fixture(nonce: int = 0, size: int = 1) -> TransactionFixture: + """Create a minimal fixture whose data repeats the nonce `size` times.""" fixture = TransactionFixture( - transaction=f"0x{nonce:04x}", + transaction="0x" + f"{nonce:04x}" * size, result={"Paris": FixtureResult(intrinsic_gas=nonce)}, ) fixture.fill_info( @@ -253,6 +258,81 @@ def test_partial_files_cleaned_up_after_merge( # Verify partial file is deleted after merge partial_files = list(output_dir.rglob("*.partial.*.jsonl")) assert len(partial_files) == 0 + assert not list(output_dir.rglob("*.part")) + + def test_merge_does_not_hold_fixture_in_memory( + self, output_dir: Path, filler_path: Path, module_path: Path + ) -> None: + """Merging must allocate far less than the fixture being merged.""" + tracemalloc = pytest.importorskip("tracemalloc") # CPython only + collector = FixtureCollector( + output_dir=output_dir, + single_fixture_per_file=False, + filler_path=filler_path, + generate_index=False, + ) + fixture = _make_fixture(0, size=32 * COPY_CHUNK_SIZE) + collector.add_fixture(_make_info("big", module_path), fixture) + collector.close_streaming_files() + document_size = sum( + p.stat().st_size for p in output_dir.rglob("*.part") + ) + assert document_size > 64 * COPY_CHUNK_SIZE + + tracemalloc.start() + tracemalloc.reset_peak() + baseline, _ = tracemalloc.get_traced_memory() + merge_partial_fixture_files(output_dir) + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + + assert peak - baseline < document_size // 2 + + def test_large_fixture_matches_json_dumps( + self, output_dir: Path, filler_path: Path, module_path: Path + ) -> None: + """A fixture larger than one copy chunk round-trips byte for byte.""" + collector = FixtureCollector( + output_dir=output_dir, + single_fixture_per_file=False, + filler_path=filler_path, + generate_index=False, + ) + fixture = _make_fixture(1, size=4 * COPY_CHUNK_SIZE) + info = _make_info("big", module_path) + collector.add_fixture(info, fixture) + collector.close_streaming_files() + merge_partial_fixture_files(output_dir) + + json_files = list(output_dir.rglob("*.json")) + assert len(json_files) == 1 + expected = json.dumps( + {info.get_id(): fixture.json_dict_with_info()}, indent=4 + ) + assert json_files[0].read_text() == expected + + def test_interrupted_merge_is_redone( + self, output_dir: Path, filler_path: Path, module_path: Path + ) -> None: + """A target truncated by an interrupted merge is written again.""" + collector = FixtureCollector( + output_dir=output_dir, + single_fixture_per_file=False, + filler_path=filler_path, + generate_index=False, + ) + fixture = _make_fixture(1) + info = _make_info("tx_test", module_path) + target = collector.add_fixture(info, fixture) + collector.close_streaming_files() + target.write_text('{\n "half": ') + + merge_partial_fixture_files(output_dir) + + expected = json.dumps( + {info.get_id(): fixture.json_dict_with_info()}, indent=4 + ) + assert target.read_text() == expected class TestLegacyCompatibility: From fb7fc09daee342bca6c5347f09463b9fd7241363 Mon Sep 17 00:00:00 2001 From: felipe Date: Thu, 24 Sep 2026 19:09:11 -0600 Subject: [PATCH 13/28] fix(test-client-clis): accept every opcode name geth's t8n reports (#3644) geth's t8n writes its opcode counts into every result, keyed by its own names: an undefined byte as "opcode 0xb8 not defined", and EOF opcodes the fork does not define by their EOF names. `OpcodeCount` rejected those keys, so the whole t8n result failed to parse whenever a block ran an undefined opcode. The count now normalizes names as the RPC path already did (moved here from `client_backend`): undefined bytes by value, unknown names dropped with a warning. --- .../client_clis/cli_types.py | 49 ++++++++++++++++++- .../client_clis/client_backend.py | 27 ++-------- .../client_clis/tests/test_transition_tool.py | 16 ++++++ 3 files changed, 67 insertions(+), 25 deletions(-) diff --git a/packages/testing/src/execution_testing/client_clis/cli_types.py b/packages/testing/src/execution_testing/client_clis/cli_types.py index fbb47bfaf9b..0f510aeced7 100644 --- a/packages/testing/src/execution_testing/client_clis/cli_types.py +++ b/packages/testing/src/execution_testing/client_clis/cli_types.py @@ -1,6 +1,7 @@ """Types used in the transition tool interactions.""" import json +import re import shutil import tempfile from dataclasses import dataclass, field @@ -18,7 +19,7 @@ ) import ijson # type: ignore[import-untyped] -from pydantic import Field, PlainSerializer, PlainValidator +from pydantic import Field, PlainSerializer, PlainValidator, model_validator from execution_testing.base_types import ( Account, @@ -355,9 +356,55 @@ def validate_opcode(obj: Any) -> Opcodes | Opcode | UndefinedOpcode: raise Exception(f"Unable to validate {obj} (type={type(obj)})") +def normalize_opcode_name(name: str) -> str | None: + """ + Map a tool's opcode name to one ``OpcodeCount`` accepts. + + Handles non-canonical casing (nethermind) and geth's + ``"opcode 0xNN not defined"``; names this framework has no opcode for + (geth still names EOF opcodes the fork does not define) are dropped. + """ + for candidate in (name, name.upper()): + try: + validate_opcode(candidate) + return candidate + except Exception: + continue + match = re.search(r"0x[0-9a-fA-F]+", name) + if match is not None: + return match.group(0) + logger.warning(f"opcode count: dropping unrecognized {name!r}") + return None + + class OpcodeCount(EthereumTestRootModel): """Opcode count returned from the evm tool.""" + @model_validator(mode="before") + @classmethod + def _normalize_names(cls, data: Any) -> Any: + """ + Accept every name a tool reports, dropping the ones with no opcode. + + geth's t8n reports its opcode counts in every result, keyed by + its own names. An undefined byte comes back as ``"opcode 0xb8 not + defined"``, and before this the whole result failed to parse: the + fuzzer's diff lane read that as geth refusing the input and lost + one case in five. + """ + if not isinstance(data, dict): + return data + normalized: Dict[str, int] = {} + for name, count in data.items(): + key = ( + name + if not isinstance(name, str) + else (normalize_opcode_name(name)) + ) + if key is not None: + normalized[key] = normalized.get(key, 0) + count + return normalized + root: Dict[ Annotated[ Opcodes | UndefinedOpcode, 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 b4a42c4b3f2..202e83539b5 100644 --- a/packages/testing/src/execution_testing/client_clis/client_backend.py +++ b/packages/testing/src/execution_testing/client_clis/client_backend.py @@ -11,7 +11,6 @@ info, block boundaries, and pre-alloc declarations flow unchanged. """ -import re from pathlib import Path from typing import Any, ClassVar, Dict, List, Optional @@ -49,7 +48,7 @@ Result, Traces, TransitionToolOutput, - validate_opcode, + normalize_opcode_name, ) from .transition_tool import TransitionTool @@ -76,26 +75,6 @@ DEFAULT_OPCODE_COUNT_TRACE_TIMEOUT = "1h" -def _normalize_opcode_name(name: str) -> str | None: - """ - Map a tracer opcode name to one ``OpcodeCount`` accepts. - - Handles non-canonical casing (nethermind) and geth's - ``"opcode 0xNN not defined"``; unrecognized names are dropped. - """ - for candidate in (name, name.upper()): - try: - validate_opcode(candidate) - return candidate - except Exception: - continue - match = re.search(r"0x[0-9a-fA-F]+", name) - if match is not None: - return match.group(0) - logger.warning(f"opcode trace: dropping unrecognized {name!r}") - return None - - def _opcode_count_from_js_tracer(traces: Any) -> OpcodeCount: """Aggregate the per-tx ``{opcode: count}`` maps the JS tracer emits.""" counts: Dict[str, int] = {} @@ -107,7 +86,7 @@ def _opcode_count_from_js_tracer(traces: Any) -> OpcodeCount: if not isinstance(tx_counts, dict) or "structLogs" in tx_counts: continue for opcode, count in tx_counts.items(): - key = _normalize_opcode_name(opcode) + key = normalize_opcode_name(opcode) if key is None or not isinstance(count, int): continue counts[key] = counts.get(key, 0) + count @@ -130,7 +109,7 @@ def _opcode_count_from_struct_logs(traces: Any) -> OpcodeCount: op = step.get("op") if not op: continue - key = _normalize_opcode_name(op) + key = normalize_opcode_name(op) if key is None: continue counts[key] = counts.get(key, 0) + 1 diff --git a/packages/testing/src/execution_testing/client_clis/tests/test_transition_tool.py b/packages/testing/src/execution_testing/client_clis/tests/test_transition_tool.py index c2468da7069..dfe132e8eec 100644 --- a/packages/testing/src/execution_testing/client_clis/tests/test_transition_tool.py +++ b/packages/testing/src/execution_testing/client_clis/tests/test_transition_tool.py @@ -498,3 +498,19 @@ def test_opcode_count_accumulation() -> None: tool.reset_opcode_count() assert tool.opcode_count == OpcodeCount({}) assert tool.opcode_count_per_block == [] + + +def test_an_opcode_count_accepts_every_name_geth_reports() -> None: + """ + Geth's t8n reports an undefined byte as ``"opcode 0xb8 not defined"`` + and still names EOF opcodes the fork does not define. The count keeps + the byte and drops the names with no opcode here, rather than failing + the whole result. + """ + count = OpcodeCount.model_validate( + {"ADD": 3, "opcode 0xb8 not defined": 2, "EOFCREATE": 1} + ) + assert {str(k): v for k, v in count.root.items()} == { + "ADD": 3, + "0xb8": 2, + } From 57d414402bad4d6f9c57e748f57f90eba7ae8111 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, 25 Sep 2026 11:50:45 +0800 Subject: [PATCH 14/28] refactor(test-vm, tests): add `create_op` helper and drop `CREATE`/`CREATE2` branches (#3625) * feat: add create_op wrapper * refactor: eip8037 suite to use create_op wrapper * refactor: all amsterdam suite to use wrapper * fix: linting issue * refactor: apply suggestion --- .../testing/src/execution_testing/__init__.py | 2 + .../src/execution_testing/vm/__init__.py | 3 +- .../src/execution_testing/vm/helpers.py | 22 +++ .../src/execution_testing/vm/tests/test_vm.py | 30 ++++ .../test_transfer_logs.py | 76 ++++------ .../test_block_access_lists.py | 9 +- .../test_block_access_lists_opcodes.py | 9 +- .../test_fork_transition.py | 13 +- .../test_max_code_size.py | 19 +-- .../test_max_initcode_size.py | 7 +- .../test_state_gas_call.py | 25 +--- .../test_state_gas_create.py | 134 +++++------------- .../test_state_gas_cross_frame_refund.py | 7 +- .../test_state_gas_delegation_pointer.py | 12 +- .../test_state_gas_ordering.py | 20 +-- .../test_state_gas_selfdestruct.py | 8 +- .../test_state_gas_sstore.py | 11 +- .../test_create_gas.py | 9 +- .../test_selfdestruct_no_burn.py | 18 +-- .../test_request_predeploys.py | 20 +-- 20 files changed, 180 insertions(+), 274 deletions(-) diff --git a/packages/testing/src/execution_testing/__init__.py b/packages/testing/src/execution_testing/__init__.py index d95bd4ea123..d7dd000abb7 100644 --- a/packages/testing/src/execution_testing/__init__.py +++ b/packages/testing/src/execution_testing/__init__.py @@ -139,6 +139,7 @@ OpcodeCallArg, Opcodes, call_return_code, + create_op, ) __all__ = ( @@ -256,6 +257,7 @@ "compute_create_address", "compute_create2_address", "compute_deterministic_create2_address", + "create_op", "Create2PreimageLayout", "CreatePreimageLayout", "extend_with_defaults", diff --git a/packages/testing/src/execution_testing/vm/__init__.py b/packages/testing/src/execution_testing/vm/__init__.py index b07127aabf0..aa94cc7eb78 100644 --- a/packages/testing/src/execution_testing/vm/__init__.py +++ b/packages/testing/src/execution_testing/vm/__init__.py @@ -6,7 +6,7 @@ OpcodeGasCalculator, ) from .bytecode import Bytecode -from .helpers import MemoryVariable, call_return_code +from .helpers import MemoryVariable, call_return_code, create_op from .opcodes import ( Macro, Macros, @@ -31,4 +31,5 @@ "OpcodeGasCalculator", "Opcodes", "call_return_code", + "create_op", ) diff --git a/packages/testing/src/execution_testing/vm/helpers.py b/packages/testing/src/execution_testing/vm/helpers.py index 9cf6c67e3e7..2bf7b2903c3 100644 --- a/packages/testing/src/execution_testing/vm/helpers.py +++ b/packages/testing/src/execution_testing/vm/helpers.py @@ -1,6 +1,9 @@ """Helper functions for the EVM.""" +from typing import Any + from .bytecode import Bytecode +from .opcodes import Opcode from .opcodes import Opcodes as Op @@ -98,3 +101,22 @@ def call_return_code(opcode: Op, success: bool) -> int: if opcode in [Op.CALL, Op.CALLCODE, Op.DELEGATECALL, Op.STATICCALL]: return int(success) raise ValueError(f"Not a call opcode: {opcode}") + + +def create_op( + opcode: Opcode, + *, + value: int | Bytecode = 0, + offset: int | Bytecode = 0, + size: int | Bytecode = 0, + salt: int | Bytecode = 0, + **metadata: Any, +) -> Bytecode: + """Return a CREATE or CREATE2 call, passing `salt` only to CREATE2.""" + if opcode == Op.CREATE: + return opcode(value=value, offset=offset, size=size, **metadata) + if opcode == Op.CREATE2: + return opcode( + value=value, offset=offset, size=size, salt=salt, **metadata + ) + raise ValueError(f"Not a create opcode: {opcode}") 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 abd3f08deb1..5d40a908247 100644 --- a/packages/testing/src/execution_testing/vm/tests/test_vm.py +++ b/packages/testing/src/execution_testing/vm/tests/test_vm.py @@ -5,6 +5,7 @@ from execution_testing.base_types import Address from execution_testing.forks.forks.forks import Prague +from ..helpers import create_op from ..opcodes import Bytecode from ..opcodes import Macros as Om from ..opcodes import Opcodes as Op @@ -684,3 +685,32 @@ def test_placeholder_in_complex_bytecode() -> None: # Verify the value 1000 (0x03E8) appears in the bytecode assert b"\x03\xe8" in bytes(code) + + +def test_create_op_create2_passes_salt() -> None: + """Test that `create_op` forwards `salt` on the CREATE2 path.""" + assert create_op( + Op.CREATE2, value=1, offset=2, size=3, salt=4 + ) == Op.CREATE2(value=1, offset=2, size=3, salt=4) + assert create_op(Op.CREATE2, salt=4) != create_op(Op.CREATE2, salt=5) + + +def test_create_op_create_drops_salt() -> None: + """Test that `create_op` drops `salt` on the CREATE path.""" + assert create_op( + Op.CREATE, value=1, offset=2, size=3, salt=4 + ) == Op.CREATE(value=1, offset=2, size=3) + assert create_op(Op.CREATE, salt=4) == create_op(Op.CREATE, salt=5) + + +def test_create_op_forwards_metadata() -> None: + """Test that `create_op` forwards metadata to the create opcode.""" + for opcode in (Op.CREATE, Op.CREATE2): + code = create_op(opcode, size=3, salt=4, init_code_size=32) + assert code.opcode_list[-1].metadata["init_code_size"] == 32 + + +def test_create_op_rejects_non_create_opcode() -> None: + """Test that `create_op` raises for a non-create opcode.""" + with pytest.raises(ValueError, match="Not a create opcode: CALL"): + create_op(Op.CALL) 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 80f215354d2..8348eebb2fa 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/test_transfer_logs.py @@ -30,8 +30,8 @@ Transaction, TransactionLog, TransactionReceipt, - compute_create2_address, compute_create_address, + create_op, ) from execution_testing.base_types import ZeroPaddedHexNumber @@ -421,38 +421,26 @@ def test_initcode_calls_with_value( initcode = Op.CALL(address=recipient, value=1) + Op.RETURN(0, 0) initcode_bytes = bytes(initcode) - # Use Initcode helper or direct memory setup for longer initcode - if create_opcode == Op.CREATE: - # Store initcode in memory using code copy approach - factory_code = ( - Op.CODECOPY( - 0, - Op.SUB(Op.CODESIZE, len(initcode_bytes)), - len(initcode_bytes), - ) - + Op.CREATE(value=1, offset=0, size=len(initcode_bytes)) - + Op.STOP - ) + initcode - else: - factory_code = ( - Op.CODECOPY( - 0, - Op.SUB(Op.CODESIZE, len(initcode_bytes)), - len(initcode_bytes), - ) - + Op.CREATE2(value=1, offset=0, size=len(initcode_bytes), salt=0) - + Op.STOP - ) + initcode + # Store initcode in memory using code copy approach + factory_code = ( + Op.CODECOPY( + 0, + Op.SUB(Op.CODESIZE, len(initcode_bytes)), + len(initcode_bytes), + ) + + create_op(create_opcode, value=1, size=len(initcode_bytes)) + + Op.STOP + ) + initcode factory = pre.deploy_contract(factory_code, balance=2) - # Compute created address - if create_opcode == Op.CREATE: - created_address = compute_create_address(address=factory, nonce=1) - else: - created_address = compute_create2_address( - address=factory, salt=0, initcode=initcode_bytes - ) + created_address = compute_create_address( + address=factory, + nonce=1, + salt=0, + initcode=initcode_bytes, + opcode=create_opcode, + ) tx = Transaction( sender=sender, @@ -696,26 +684,22 @@ def test_create_collision_no_log( initcode_len = len(initcode_bytes) # Deploy factory first to compute created address - if create_opcode == Op.CREATE: - factory_code = Op.MSTORE( - 0, Op.PUSH32(initcode_bytes.rjust(32, b"\x00")) - ) + Op.CREATE(value=1, offset=32 - initcode_len, size=initcode_len) - else: - factory_code = Op.MSTORE( - 0, Op.PUSH32(initcode_bytes.rjust(32, b"\x00")) - ) + Op.CREATE2( - value=1, offset=32 - initcode_len, size=initcode_len, salt=0 - ) + factory_code = Op.MSTORE( + 0, Op.PUSH32(initcode_bytes.rjust(32, b"\x00")) + ) + create_op( + create_opcode, value=1, offset=32 - initcode_len, size=initcode_len + ) factory = pre.deploy_contract(factory_code, balance=1) # Compute and pre-populate the collision address - if create_opcode == Op.CREATE: - collision_address = compute_create_address(address=factory, nonce=1) - else: - collision_address = compute_create2_address( - address=factory, salt=0, initcode=initcode_bytes - ) + collision_address = compute_create_address( + address=factory, + nonce=1, + salt=0, + initcode=initcode_bytes, + opcode=create_opcode, + ) # Pre-deploy contract at collision address to cause collision pre.deploy_contract(Op.STOP, address=collision_address) 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 ca631c7e1be..e128e87f96d 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 @@ -38,6 +38,7 @@ Withdrawal, add_kzg_version, compute_create_address, + create_op, ) from execution_testing import Macros as Om @@ -3043,13 +3044,7 @@ def test_bal_cross_tx_deploy_then_call( initcode_bytes = bytes(initcode) salt = 0 - is_create2 = create_opcode == Op.CREATE2 - if is_create2: - deploy_op = Op.CREATE2( - value=0, offset=0, size=Op.CALLDATASIZE, salt=salt - ) - else: - deploy_op = Op.CREATE(value=0, offset=0, size=Op.CALLDATASIZE) + deploy_op = create_op(create_opcode, size=Op.CALLDATASIZE, salt=salt) factory_code = ( Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + Op.SSTORE(0, deploy_op) 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 754247feb71..6b524255598 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 @@ -41,6 +41,7 @@ StateTestFiller, Transaction, compute_create_address, + create_op, ) from execution_testing import Macros as Om @@ -3847,13 +3848,7 @@ def test_bal_create_storage_op_then_selfdestruct_same_tx( initcode_bytes = bytes(initcode) salt = 0 - is_create2 = create_opcode == Op.CREATE2 - if is_create2: - deploy_op = Op.CREATE2( - value=0, offset=0, size=Op.CALLDATASIZE, salt=salt - ) - else: - deploy_op = Op.CREATE(value=0, offset=0, size=Op.CALLDATASIZE) + deploy_op = create_op(create_opcode, size=Op.CALLDATASIZE, salt=salt) factory_code = ( Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) diff --git a/tests/amsterdam/eip7954_increase_max_contract_size/test_fork_transition.py b/tests/amsterdam/eip7954_increase_max_contract_size/test_fork_transition.py index d0836bb165f..89a9cfe62bc 100644 --- a/tests/amsterdam/eip7954_increase_max_contract_size/test_fork_transition.py +++ b/tests/amsterdam/eip7954_increase_max_contract_size/test_fork_transition.py @@ -22,6 +22,7 @@ TransactionException, TransitionFork, compute_create_address, + create_op, ) from execution_testing import Macros as Om @@ -138,11 +139,7 @@ def test_max_code_size_via_create_fork_transition( size = code_size(fork) initcode_bytes = bytes(Op.RETURN(offset=0, size=size)) - create_call = ( - create_opcode(value=0, offset=0, size=len(initcode_bytes), salt=0) - if create_opcode == Op.CREATE2 - else create_opcode(value=0, offset=0, size=len(initcode_bytes)) - ) + create_call = create_op(create_opcode, size=len(initcode_bytes)) factory_code = ( Om.MSTORE(initcode_bytes, 0) + Op.SSTORE(0, create_call) + Op.STOP ) @@ -295,11 +292,7 @@ def test_max_initcode_size_via_create_fork_transition( initcode_prologue = bytes(Op.RETURN(offset=0, size=0)) initcode_bytes = initcode_prologue.ljust(size, b"\x00") - 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_op(create_opcode, size=size) factory_code = ( Om.MSTORE(initcode_prologue, 0) + Op.SSTORE(0, create_call) + Op.STOP ) diff --git a/tests/amsterdam/eip7954_increase_max_contract_size/test_max_code_size.py b/tests/amsterdam/eip7954_increase_max_contract_size/test_max_code_size.py index a27fd1cc74c..e6bfda7853f 100644 --- a/tests/amsterdam/eip7954_increase_max_contract_size/test_max_code_size.py +++ b/tests/amsterdam/eip7954_increase_max_contract_size/test_max_code_size.py @@ -18,6 +18,7 @@ Transaction, TransactionReceipt, compute_create_address, + create_op, keccak256, ) from execution_testing import Macros as Om @@ -97,11 +98,7 @@ def test_max_code_size_via_create( alice = pre.fund_eoa() - create_call = ( - create_opcode(value=0, offset=0, size=Op.CALLDATASIZE, salt=0) - if create_opcode == Op.CREATE2 - else create_opcode(value=0, offset=0, size=Op.CALLDATASIZE) - ) + create_call = create_op(create_opcode, size=Op.CALLDATASIZE) factory_code = ( Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) @@ -416,17 +413,7 @@ def test_warm_after_failed_create_over_max_code_size( """ initcode = Op.RETURN(offset=0, size=fork.max_code_size() + 1) initcode_bytes = bytes(initcode) - if create_opcode == Op.CREATE2: - create_call = create_opcode( - value=0, - offset=0, - size=len(initcode_bytes), - salt=0, - ) - else: - create_call = create_opcode( - value=0, offset=0, size=len(initcode_bytes) - ) + create_call = create_op(create_opcode, size=len(initcode_bytes)) creator_code = Op.MSTORE( 0, Op.PUSH32(initcode_bytes.ljust(32, b"\0")) diff --git a/tests/amsterdam/eip7954_increase_max_contract_size/test_max_initcode_size.py b/tests/amsterdam/eip7954_increase_max_contract_size/test_max_initcode_size.py index 43c268a0434..ce8f745f507 100644 --- a/tests/amsterdam/eip7954_increase_max_contract_size/test_max_initcode_size.py +++ b/tests/amsterdam/eip7954_increase_max_contract_size/test_max_initcode_size.py @@ -20,6 +20,7 @@ TransactionException, TransactionReceipt, compute_create_address, + create_op, keccak256, ) from execution_testing import Macros as Om @@ -112,11 +113,7 @@ def test_max_initcode_size_via_create( alice = pre.fund_eoa() - create_call = ( - create_opcode(value=0, offset=0, size=Op.CALLDATASIZE, salt=0) - if create_opcode == Op.CREATE2 - else create_opcode(value=0, offset=0, size=Op.CALLDATASIZE) - ) + create_call = create_op(create_opcode, size=Op.CALLDATASIZE) factory_code = ( Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) 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 665c2e3ea66..35a01926137 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 @@ -38,6 +38,7 @@ TransactionReceipt, WhileGas, compute_create_address, + create_op, ) from execution_testing.checklists import EIPChecklist @@ -1473,11 +1474,7 @@ def test_call_value_to_self_destructed_same_tx_account( storage = Storage() orchestrator_code = ( Op.MSTORE(0, mstore_value) - + ( - Op.CREATE2(1, 0, size, 0) - if create_opcode == Op.CREATE2 - else Op.CREATE(1, 0, size) - ) + + create_op(create_opcode, value=1, size=size) + Op.MSTORE(0x20, Op.DUP1) + Op.POP + Op.SSTORE( @@ -1542,11 +1539,7 @@ def test_call_value_to_self_destructed_header_gas_used( orchestrator_code = ( Op.MSTORE(0, mstore_value) - + ( - Op.CREATE2(1, 0, size, 0) - if create_opcode == Op.CREATE2 - else Op.CREATE(1, 0, size) - ) + + create_op(create_opcode, value=1, size=size) + Op.MSTORE(0x20, Op.DUP1) + Op.POP + Op.POP( @@ -1636,11 +1629,7 @@ def test_call_zero_value_to_self_destructed_same_tx_account( orchestrator_code = ( Op.MSTORE(0, mstore_value) - + ( - Op.CREATE2(1, 0, size, 0) - if create_opcode == Op.CREATE2 - else Op.CREATE(1, 0, size) - ) + + create_op(create_opcode, value=1, size=size) + Op.MSTORE(0x20, Op.DUP1) + Op.POP + Op.POP(Op.CALL(gas=Op.GAS, address=Op.MLOAD(0x20), value=0)) @@ -1926,11 +1915,7 @@ def test_create_oog_during_state_gas_charge( sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) init_code = Op.STOP - inner_create_call = ( - create_opcode(value=0, offset=31, size=1, salt=0) - if create_opcode == Op.CREATE2 - else create_opcode(value=0, offset=31, size=1) - ) + inner_create_call = create_op(create_opcode, offset=31, size=1) inner_code = Op.MSTORE(0, init_code_at_high_bytes(init_code)[0]) + Op.POP( inner_create_call 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 23d9cbb0dfd..85c04a99528 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 @@ -26,6 +26,7 @@ TransactionReceipt, compute_create2_address, compute_create_address, + create_op, ) from execution_testing.checklists import EIPChecklist @@ -1491,21 +1492,11 @@ def test_max_initcode_size_gas_metering_via_create( alice = pre.fund_eoa() initcode_len = len(initcode) - create_call = ( - create_opcode( - value=0, - offset=0, - size=Op.CALLDATASIZE, - salt=0xC0FFEE, - init_code_size=initcode_len, - ) - if create_opcode == Op.CREATE2 - else create_opcode( - value=0, - offset=0, - size=Op.CALLDATASIZE, - init_code_size=initcode_len, - ) + create_call = create_op( + create_opcode, + size=Op.CALLDATASIZE, + salt=0xC0FFEE, + init_code_size=initcode_len, ) factory_code = ( @@ -1952,13 +1943,7 @@ def test_failed_create_header_gas_used( mstore_value, size = init_code_at_high_bytes(init_code) - create_call = ( - 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=size, init_code_size=size) - ) + create_call = create_op(create_opcode, 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) @@ -2035,12 +2020,8 @@ def test_create_preflight_failure_skips_state_gas( mstore_value, size = init_code_at_high_bytes(Op.STOP) value = 1 if failure_mode == "insufficient_balance" else 0 - create_call = ( - Op.CREATE(value=value, offset=0, size=size, init_code_size=size) - if create_opcode == Op.CREATE - else Op.CREATE2( - value=value, offset=0, size=size, salt=0, init_code_size=size - ) + create_call = create_op( + create_opcode, value=value, size=size, init_code_size=size ) storage = Storage() @@ -2179,11 +2160,7 @@ def test_create_child_halt_refunds_state_gas( 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_op(create_opcode, size=size) storage = Storage() factory = pre.deploy_contract( @@ -2242,9 +2219,7 @@ def test_create_mixed_success_and_failure_block_accounting( fail_value, fail_size = init_code_at_high_bytes(Op.REVERT(0, 0)) def call(size: int, salt: int) -> Bytecode: - if create_opcode == Op.CREATE2: - return create_opcode(value=0, offset=0, size=size, salt=salt) - return create_opcode(value=0, offset=0, size=size) + return create_op(create_opcode, size=size, salt=salt) factory_code = ( Op.MSTORE(0, success_value) @@ -2301,11 +2276,7 @@ def test_create_collision_refunds_state_gas( salt = 0 storage = Storage() - create_call = ( - create_opcode(value=0, offset=0, size=size, salt=salt) - if create_opcode == Op.CREATE2 - else create_opcode(value=0, offset=0, size=size) - ) + create_call = create_op(create_opcode, size=size, salt=salt) factory_code = ( Op.MSTORE(0, mstore_value) + Op.POP(create_call) @@ -2373,11 +2344,7 @@ def test_create_code_deposit_oog_refunds_state_gas( init_code = Op.RETURN(0, max_code_size + 1) 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_op(create_opcode, size=size) storage = Storage() factory = pre.deploy_contract( @@ -2563,11 +2530,7 @@ def test_create_account_charge_reduces_child_gas( init_code = Op.MSTORE(sink_offset, 0) + Op.RETURN(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_op(create_opcode, size=size) storage = Storage() expected = 1 if reservoir_covers else 0 @@ -2582,12 +2545,13 @@ def test_create_account_charge_reduces_child_gas( # Pre-existing balance-only target: the success-refund path. Under # the old conditional approach this alive target skips NEW_ACCOUNT, # so the child gets the full share and fits in both cases. - if create_opcode == Op.CREATE2: - create_address = compute_create2_address( - address=factory, salt=0, initcode=bytes(init_code) - ) - else: - create_address = compute_create_address(address=factory, nonce=1) + create_address = compute_create_address( + address=factory, + nonce=1, + salt=0, + initcode=bytes(init_code), + opcode=create_opcode, + ) pre.fund_address(create_address, amount=1) # Execution gas the factory spends before the NEW_ACCOUNT charge: the @@ -2934,23 +2898,11 @@ def test_oversized_initcode_opcode_no_state_gas( size = max_size + initcode_size_delta initcode = bytes(size) - factory_code = ( - create_opcode( - value=0, - offset=0, - size=size, - salt=0, - init_code_size=size, - new_memory_size=size, - ) - if create_opcode == Op.CREATE2 - else create_opcode( - value=0, - offset=0, - size=size, - init_code_size=size, - new_memory_size=size, - ) + factory_code = create_op( + create_opcode, + size=size, + init_code_size=size, + new_memory_size=size, ) factory = pre.deploy_contract(factory_code) @@ -3105,10 +3057,7 @@ def test_inner_create_succeeds_code_deposit_state_gas( 0, int.from_bytes(inner_bytes, "big") << (256 - 8 * len(inner_bytes)), ) - if create_opcode == Op.CREATE2: - inner_create = Op.POP(Op.CREATE2(0, 0, len(inner_bytes), 0)) - else: - inner_create = Op.POP(Op.CREATE(0, 0, len(inner_bytes))) + inner_create = Op.POP(create_op(create_opcode, size=len(inner_bytes))) # Inner account creation plus the inner contract's code deposit. inner_state_gas = inner_create.state_cost(fork) + inner_code_deposit @@ -3208,23 +3157,11 @@ def test_nested_create_failure_refunds_state_gas_before_parent_exit( new_memory_size=32, ) - create_call = ( - 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), - # gas accounting - init_code_size=len(init_code), - ) + create_call = create_op( + create_opcode, + size=len(init_code), + # gas accounting + init_code_size=len(init_code), ) create_state_gas = create_call.state_cost(fork) @@ -3341,10 +3278,9 @@ def test_inner_create_fail_refunds_in_creation_tx( inner_ops = Bytecode() for i in range(num_inner_ops): - if create_opcode == Op.CREATE2: - inner_ops += Op.POP(Op.CREATE2(0, 0, len(inner_initcode), i)) - else: - inner_ops += Op.POP(Op.CREATE(0, 0, len(inner_initcode))) + inner_ops += Op.POP( + create_op(create_opcode, size=len(inner_initcode), salt=i) + ) if outer_outcome == "succeeds": termination = Op.RETURN(0, 0) 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 83b3d13983d..60f25c1b968 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 @@ -34,6 +34,7 @@ Storage, Transaction, TransactionReceipt, + create_op, ) from tests.prague.eip7702_set_code_tx.spec import Spec as Spec7702 @@ -1281,9 +1282,9 @@ def test_cross_frame_refund_repays_spill_at_a_create( 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)) + return Op.POP( + create_op(create_opcode, offset=code_offset, size=size, salt=salt) + ) contract = pre.deploy_contract( code=Op.MSTORE(code_offset, mstore_value) 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 7973379da27..57f3b54cdc1 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 @@ -22,6 +22,7 @@ Storage, Transaction, TransactionReceipt, + create_op, ) from .spec import ref_spec_8037 @@ -280,12 +281,13 @@ def test_account_state_gas_via_delegation_pointer( Each still bills its state charge from the reservoir, and the header reports it in the state dimension. """ - if state_op == Op.CREATE: - code = Op.POP(Op.CREATE(0, 0, 0)) - elif state_op == Op.CREATE2: - code = Op.POP(Op.CREATE2(0, 0, 0, 0)) - else: + if state_op in (Op.CREATE, Op.CREATE2): + code = Op.POP(create_op(state_op)) + elif state_op == Op.SELFDESTRUCT: code = Op.SELFDESTRUCT(pre.nonexistent_account(), account_new=True) + else: + raise ValueError(f"unexpected state_op {state_op}") + contract = pre.deploy_contract(code=code) delegator = pre.fund_eoa(delegation=contract, amount=1) 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 23520d4b852..56aa799b22a 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 @@ -22,6 +22,7 @@ Header, Op, Transaction, + create_op, ) from .spec import ref_spec_8037 @@ -57,23 +58,14 @@ def test_create_oog_full_burn_no_state_credit( 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, - ) + create_call = create_op( + create_opcode, size=initcode_size, init_code_size=initcode_size + ) if oog_step == "create_base": - factory_code = create_op + factory_code = create_call else: - factory_code = Op.MSTORE(0, 0, new_memory_size=WORD_SIZE) + create_op + factory_code = Op.MSTORE(0, 0, new_memory_size=WORD_SIZE) + create_call factory = pre.deploy_contract(factory_code) # One gas short of the CREATE's full cost (execution plus the NEW_ACCOUNT 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 41a7a888043..8ee9458a412 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 @@ -26,6 +26,7 @@ Transaction, TransactionReceipt, compute_create_address, + create_op, ) from execution_testing.checklists import EIPChecklist @@ -448,12 +449,7 @@ def test_create_selfdestruct_no_refund_account_and_storage( mstore = Op.MSTORE.with_metadata(new_memory_size=32, old_memory_size=0)( 0, mstore_value ) - create_metadata = create_opcode.with_metadata(init_code_size=size) - create_call = ( - create_metadata(value=0, offset=0, size=size, salt=0) - if create_opcode == Op.CREATE2 - else create_metadata(value=0, offset=0, size=size) - ) + create_call = create_op(create_opcode, size=size, init_code_size=size) factory_code = mstore + Op.POP(create_call) factory = pre.deploy_contract(code=factory_code) 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 251eddd35c9..b2139f149ed 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 @@ -34,6 +34,7 @@ Storage, Transaction, TransactionReceipt, + create_op, ) from execution_testing.checklists import EIPChecklist @@ -1683,10 +1684,7 @@ def test_sstore_restoration_create_init_revert( 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, init_code_size) - else: - create_call = Op.CREATE2(0, 0, init_code_size, 0) + create_call = create_op(create_opcode, size=init_code_size) # Inner contract performs the CREATE then REVERTs. inner = pre.deploy_contract( @@ -1748,10 +1746,7 @@ def test_sstore_restoration_create_init_success( ) mstore_value, init_code_size = init_code_at_high_bytes(init_code) - if create_opcode == Op.CREATE: - create_call = Op.CREATE(0, 0, init_code_size) - else: - create_call = Op.CREATE2(0, 0, init_code_size, 0) + create_call = create_op(create_opcode, size=init_code_size) probe_code = Op.SSTORE(0, 1) probe = pre.deploy_contract(code=probe_code) 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 f74d039b48a..b806ef4a35d 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 @@ -36,6 +36,7 @@ Transaction, TransactionException, compute_create_address, + create_op, ) from execution_testing.checklists import EIPChecklist @@ -100,13 +101,9 @@ def test_create_execution_gas( # opcode's per-init-word charge. padded_init = b"\x00" * init_code_size - create_call = ( - Op.CREATE2(value=0, offset=0, size=init_code_size, salt=0) - if create_opcode == Op.CREATE2 - else Op.CREATE(value=0, offset=0, size=init_code_size) - ) + create_call = create_op(create_opcode, size=init_code_size) push_cost = Op.PUSH1(0).execution_cost(fork) - arg_pushes = (4 if create_opcode == Op.CREATE2 else 3) * push_cost + arg_pushes = create_opcode.popped_stack_items * push_cost memory_setup = ( Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE, new_memory_size=init_code_size) diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py index e7e1a77664a..63c99807deb 100644 --- a/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/test_selfdestruct_no_burn.py @@ -27,6 +27,7 @@ TransactionReceipt, compute_create2_address, compute_create_address, + create_op, keccak256, ) from execution_testing import ( @@ -129,17 +130,12 @@ def test_selfdestructing_initcode_preserves_balance( # Build selfdestruct target contract via CREATE/CREATE2 salt = 0 - if create_opcode == Op.CREATE2: - create_call = create_opcode( - value=initial_balance, - size=len(selfdestruct_initcode), - salt=salt, - ) - else: - create_call = create_opcode( - value=initial_balance, - size=len(selfdestruct_initcode), - ) + create_call = create_op( + create_opcode, + value=initial_balance, + size=len(selfdestruct_initcode), + salt=salt, + ) # Selfdestruct target contract factory # Exits via STOP/REVERT/OOG for different scenario diff --git a/tests/amsterdam/eip8282_builder_execution_requests/test_request_predeploys.py b/tests/amsterdam/eip8282_builder_execution_requests/test_request_predeploys.py index f816abeddb8..c091210d2c2 100644 --- a/tests/amsterdam/eip8282_builder_execution_requests/test_request_predeploys.py +++ b/tests/amsterdam/eip8282_builder_execution_requests/test_request_predeploys.py @@ -39,6 +39,7 @@ TransactionException, TransactionReceipt, compute_create_address, + create_op, relay_contract_code, ) from execution_testing import Macros as Om @@ -546,27 +547,26 @@ def test_request_from_initcode( sender=sender, to=None, data=initcode, value=request.value ) created = compute_create_address(address=sender, nonce=0) - elif create_opcode == Op.CREATE: - factory = pre.deploy_contract( - Om.MSTORE(bytes(initcode), 0) - + Op.SSTORE(0, Op.CREATE(Op.CALLVALUE, 0, len(initcode))) - ) - tx = Transaction(sender=sender, to=factory, value=request.value) - created = compute_create_address(address=factory, nonce=1) - elif create_opcode == Op.CREATE2: + elif create_opcode in (Op.CREATE, Op.CREATE2): factory = pre.deploy_contract( Om.MSTORE(bytes(initcode), 0) + Op.SSTORE( 0, - Op.CREATE2(Op.CALLVALUE, 0, len(initcode), CREATE2_SALT), + create_op( + create_opcode, + value=Op.CALLVALUE, + size=len(initcode), + salt=CREATE2_SALT, + ), ) ) tx = Transaction(sender=sender, to=factory, value=request.value) created = compute_create_address( address=factory, + nonce=1, salt=CREATE2_SALT, initcode=initcode, - opcode=Op.CREATE2, + opcode=create_opcode, ) else: raise ValueError(f"unhandled create opcode {create_opcode}") From f541fc1f76e8109ba419da243da9a8abdecb099b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Fri, 25 Sep 2026 09:18:02 +0200 Subject: [PATCH 15/28] feat(test-benchmark): pre-fund CREATE targets and add randomized jumpdest analysis (#3631) * feat(test-benchmark): pre-fund CREATE targets and add randomized jumpdest analysis Pre-fund each CREATE target so it skips NEW_ACCOUNT (EIP-8037), isolating the analysis cost; genesis pre-allocation in fill. Add non-repeating random initcode arms (four alphabets, one weighted) that defeat the branch predictor, delivered via EXTCODECOPY to avoid the calldata floor. New deploy_contracts_once lets the test derive CREATE addresses before the loop contract is deployed. Worst random arm per client, 60M-gas Amsterdam block on an isolated 3.97 GHz core (rest of the package at 3.6 GHz), lowest is most damaging: nethermind 35.8 MGas/s random_stop_jumpdest_push1 (approx: JIT, no timer) besu 36.3 MGas/s random_stop_jumpdest evmone 43.2 MGas/s random_stop_jumpdest reth/revm 45.8 MGas/s random_stop_jumpdest geth 50.8 MGas/s random_jumpdest_push1 erigon 74.9 MGas/s random_stop_jumpdest_2push1 The periodic warm-predictor arms run roughly 2-3x faster, so that gap is the branch-prediction cost this benchmark exposes. * refactor(test-benchmark): gate CREATE target pre-funding on the new account charge Pre-fund only where CREATE carries NEW_ACCOUNT, so forks before Amsterdam keep their fixtures unchanged. Shorten the docstring and comments, use the tx_gas_limit fixture, and restore the named calldata length. * refactor: code generator logic in the test --------- Co-authored-by: fselmo Co-authored-by: LouisTsai --- .../compute/scenario/test_mix_operations.py | 146 +++++++++++++----- 1 file changed, 110 insertions(+), 36 deletions(-) diff --git a/tests/benchmark/compute/scenario/test_mix_operations.py b/tests/benchmark/compute/scenario/test_mix_operations.py index ef7f4993325..b3d5bde32df 100644 --- a/tests/benchmark/compute/scenario/test_mix_operations.py +++ b/tests/benchmark/compute/scenario/test_mix_operations.py @@ -1,18 +1,39 @@ """Benchmark mixed operations.""" +import random +from dataclasses import dataclass + import pytest from execution_testing import ( + Alloc, BenchmarkTestFiller, Bytecode, Fork, - JumpLoopGenerator, Op, + Transaction, + compute_create_address, ) +@dataclass(frozen=True) +class RandomInitcode: + """Non-repeating random initcode over an opcode alphabet (fixed seed).""" + + alphabet: tuple[Op, ...] + weights: tuple[int, ...] | None = None + + def code(self, size: int) -> bytes: + """Return ``size`` pseudo-random bytes over the alphabet.""" + rng = random.Random(0) + values = [bytes(op)[0] for op in self.alphabet] + return bytes(rng.choices(values, weights=self.weights, k=size)) + + @pytest.mark.parametrize( "pattern", [ + # Periodic tiles: the branch predictor learns the period, so these + # measure analysis with the predictor warm. Op.STOP, Op.JUMPDEST, Op.PUSH1[bytes(Op.JUMPDEST)], @@ -22,71 +43,124 @@ Op.SWAPN[bytes(Op.JUMPDEST)], Op.DUPN[bytes(Op.JUMPDEST)], Op.EXCHANGE[bytes(Op.JUMPDEST)], + # Non-repeating random initcode defeats the predictor. The alphabets + # straddle the thresholds analysis loops branch on (0x5b JUMPDEST, + # 0x60 PUSH1); the weighted variant makes PUSH1 half of all bytes, so + # the PUSH1 threshold is a 50/50 branch. + pytest.param( + RandomInitcode((Op.STOP, Op.JUMPDEST, Op.PUSH1)), + id="random_stop_jumpdest_push1", + ), + pytest.param( + RandomInitcode((Op.STOP, Op.JUMPDEST)), + id="random_stop_jumpdest", + ), + pytest.param( + RandomInitcode((Op.JUMPDEST, Op.PUSH1)), + id="random_jumpdest_push1", + ), + pytest.param( + RandomInitcode( + (Op.STOP, Op.JUMPDEST, Op.PUSH1), weights=(1, 1, 2) + ), + id="random_stop_jumpdest_2push1", + ), ], ids=lambda x: x.hex(), ) def test_jumpdest_analysis( benchmark_test: BenchmarkTestFiller, + pre: Alloc, fork: Fork, - pattern: Bytecode, + pattern: Bytecode | RandomInitcode, + gas_benchmark_value: int, + tx_gas_limit: int, ) -> None: """ - Test the jumpdest analysis performance of the initcode. + Benchmark jumpdest analysis of CREATE initcode. - This benchmark places a very long initcode in the memory and then invoke - CREATE instructions with this initcode up to the block gas limit. The - initcode itself has minimal execution time but forces the EVM to perform - the full jumpdest analysis on the parametrized byte pattern. The initicode - is modified by mixing-in the returned create address between CREATE - invocations to prevent caching. + Each CREATE runs a max-size initcode that jumps straight to its last byte, + so its cost is almost all analysis. Tiled patterns repeat with a period the + branch predictor learns; ``RandomInitcode`` does not repeat. """ initcode_size = fork.max_initcode_size() - # Expand the initcode pattern to the transaction data so it can be used in - # CALLDATACOPY in the main contract. TODO: tune the tx_data_len param. - tx_data_len = 1024 - tx_data = pattern * (tx_data_len // len(pattern)) - tx_data += (tx_data_len - len(tx_data)) * bytes(Op.JUMPDEST) - assert len(tx_data) == tx_data_len - assert initcode_size % len(tx_data) == 0 - - # Prepare the initcode in memory. - code_prepare_initcode = sum( - ( - Op.CALLDATACOPY( - dest_offset=i * len(tx_data), offset=0, size=Op.CALLDATASIZE + if isinstance(pattern, RandomInitcode): + # Deploy the random bytes as two max-size contracts and EXTCODECOPY + # them in; no calldata, whose floor would exceed small gas budgets. + chunk = fork.max_code_size() + assert initcode_size % chunk == 0 + full = pattern.code(initcode_size) + code_prepare_initcode = Bytecode() + for offset in range(0, initcode_size, chunk): + source = pre.deploy_contract(code=full[offset : offset + chunk]) + code_prepare_initcode += Op.EXTCODECOPY( + address=source, dest_offset=offset, size=chunk ) - for i in range(initcode_size // len(tx_data)) - ), - Bytecode(), - ) + tx_data = b"" + else: + # Tile a small calldata window to fill the initcode: cheap, but the + # period is learnable. + tx_data_len = 1024 + tx_data = bytes(pattern) * (tx_data_len // len(pattern)) + tx_data += (tx_data_len - len(tx_data)) * bytes(Op.JUMPDEST) + assert initcode_size % len(tx_data) == 0 + code_prepare_initcode = sum( + ( + Op.CALLDATACOPY( + dest_offset=i * len(tx_data), + offset=0, + size=Op.CALLDATASIZE, + ) + for i in range(initcode_size // len(tx_data)) + ), + Bytecode(), + ) - # At the start of the initcode execution, jump to the last opcode. - # This forces EVM to do the full jumpdest analysis. + # Jump to the last byte, forcing a full analysis, and make it a JUMPDEST. initcode_prefix = Op.JUMP(initcode_size - 1) code_prepare_initcode += Op.MSTORE( 0, Op.PUSH32[bytes(initcode_prefix).ljust(32, bytes(Op.JUMPDEST))] ) - - # Make sure the last opcode in the initcode is JUMPDEST. code_prepare_initcode += Op.MSTORE( initcode_size - 32, Op.PUSH32[bytes(Op.JUMPDEST) * 32] ) + # Mix the returned address into the initcode so each analysis is unique. attack_block = ( Op.PUSH1[len(initcode_prefix)] + Op.MSTORE + Op.CREATE(value=Op.PUSH0, offset=Op.PUSH0, size=Op.MSIZE) ) - setup = code_prepare_initcode + Op.CREATE( value=Op.PUSH0, offset=Op.PUSH0, size=Op.MSIZE ) + overhead = len(setup) + len(Op.JUMPDEST) + len(Op.JUMP(len(setup))) + iterations = (fork.max_code_size() - overhead) // len(attack_block) + loop_contract = pre.deploy_contract( + code=setup + + Op.JUMPDEST + + attack_block * iterations + + Op.JUMP(len(setup)) + ) + + create = Op.CREATE.with_metadata(init_code_size=initcode_size) + if create.state_cost(fork) > 0: + # Pre-fund the CREATE targets so creation skips NEW_ACCOUNT. Each + # transaction runs out of gas and reverts, so every transaction reuses + # the same targets and one transaction's gas bounds how many there are. + create_cost = create.execution_cost(fork) + tx_budget = min(gas_benchmark_value, tx_gas_limit) + for nonce in range(1, tx_budget // create_cost + 2): + pre.fund_address( + compute_create_address(address=loop_contract, nonce=nonce), 1 + ) benchmark_test( - code_generator=JumpLoopGenerator( - setup=setup, - attack_block=attack_block, - tx_kwargs={"data": tx_data}, - ), + tx=Transaction( + to=loop_contract, + data=tx_data, + gas_limit=gas_benchmark_value, + sender=pre.fund_eoa(), + ) ) From f1d9a04433e800857007f01130bac779d61e3414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Fri, 25 Sep 2026 09:21:49 +0200 Subject: [PATCH 16/28] feat(test-benchmark): add TSTORE key-distribution benchmark (#3637) * feat(test-benchmark): add TSTORE key-distribution benchmark test_tstore_key_distribution parametrizes over spread vs colliding transient storage keys. The colliding arm writes distinct slots that share one Arrays.hashCode, so every key maps to a single bucket of besu's transient store (a HashMap keyed by AdrressStorageSlotKey) and exercises its treeified single-bin path. Keys are constant literals independent of the contract address, so the scenario is replayable against a freshly deployed contract. * refactor: tstore benchmark nit --------- Co-authored-by: LouisTsai --- .../compute/instruction/test_storage.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/benchmark/compute/instruction/test_storage.py b/tests/benchmark/compute/instruction/test_storage.py index 5c51faa47e2..ffa0b163822 100644 --- a/tests/benchmark/compute/instruction/test_storage.py +++ b/tests/benchmark/compute/instruction/test_storage.py @@ -680,3 +680,58 @@ def deepest_frame(execution_gas: int) -> int: expected_receipt_status=1, blocks=[Block(txs=txs)], ) + + +def _besu_colliding_slots(n: int) -> list[int]: + """ + Return ``n`` distinct slots that share one Java ``Arrays.hashCode``. + + Each of 16 byte pairs is ``(0x00, 0x1F)`` or ``(0x01, 0x00)``; both add + the same amount to the polynomial hash (``31*0 + 31 == 31*1 + 0``), so + the 16 bits of the index pick a distinct slot with an unchanged hash. + """ + assert n <= 1 << 16, "only 65536 distinct colliding slots (16 byte pairs)" + slots = [] + for index in range(n): + slot = bytearray(32) + for pair in range(16): + if (index >> pair) & 1: + slot[2 * pair : 2 * pair + 2] = b"\x01\x00" + else: + slot[2 * pair : 2 * pair + 2] = b"\x00\x1f" + slots.append(int.from_bytes(slot, "big")) + return slots + + +@pytest.mark.parametrize("distribution", ["spread", "besu_collision"]) +def test_tstore_key_distribution( + benchmark_test: BenchmarkTestFiller, + fork: Fork, + distribution: str, +) -> None: + """ + Benchmark TSTORE with colliding versus spread transient-storage keys. + + The ``besu_collision`` arm writes distinct slots that share one + ``Arrays.hashCode``, so a client keying transient storage by a plain hash + of the slot funnels every write into a single bucket; the ``spread`` arm + writes sequential slots. The value is a fixed nonzero so the write is not + elided. besu keys this way; other clients are unaffected. + """ + value = 0x2A + max_write_bytes = len(Op.TSTORE(Op.PUSH32(0), value)) + loop_overhead = len(Op.JUMPDEST) + len(Op.JUMP(0)) + n = (fork.max_code_size() - loop_overhead) // max_write_bytes + + if distribution == "spread": + slots = list(range(1, n + 1)) + elif distribution == "besu_collision": + slots = _besu_colliding_slots(n) + else: + raise ValueError(f"unknown distribution: {distribution}") + + attack_block = sum((Op.TSTORE(slot, value) for slot in slots), Bytecode()) + benchmark_test( + target_opcode=Op.TSTORE, + code_generator=JumpLoopGenerator(attack_block=attack_block), + ) From 84e7d2c266e3319fc3882e72f379282bb1c40f2d 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, 26 Sep 2026 00:39:37 +0800 Subject: [PATCH 17/28] perf(test-fixtures): measure block RLP size without encoding it (#3642) * refactor: measure block RLP size without encoding it * refactor: derive block RLP size from block_rlp_encode_list and add size tests --- .../execution_testing/base_types/__init__.py | 11 ++- .../base_types/serialization.py | 87 ++++++++++++++++++- .../base_types/tests/test_serialization.py | 50 +++++++++++ .../execution_testing/fixtures/blockchain.py | 64 +++++++++++--- .../src/execution_testing/specs/blockchain.py | 27 +++++- .../specs/tests/test_types.py | 54 +++++++++++- .../test_types/transaction_types.py | 25 +++++- 7 files changed, 296 insertions(+), 22 deletions(-) create mode 100644 packages/testing/src/execution_testing/base_types/tests/test_serialization.py diff --git a/packages/testing/src/execution_testing/base_types/__init__.py b/packages/testing/src/execution_testing/base_types/__init__.py index 9203aae5d0f..8e9160580b2 100644 --- a/packages/testing/src/execution_testing/base_types/__init__.py +++ b/packages/testing/src/execution_testing/base_types/__init__.py @@ -44,7 +44,13 @@ from .conversions import to_bytes, to_hex from .pydantic import CamelModel, EthereumTestBaseModel, EthereumTestRootModel from .reference_spec import ReferenceSpec -from .serialization import RLPSerializable, SignableRLPSerializable +from .serialization import ( + KnownEncodedSize, + RLPSerializable, + SignableRLPSerializable, + encoded_prefixed_size, + encoded_size, +) from .typing_utils import unwrap_annotation __all__ = ( @@ -76,8 +82,11 @@ "Number", "NumberBoundTypeVar", "ReferenceSpec", + "KnownEncodedSize", "RLPSerializable", "SignableRLPSerializable", + "encoded_prefixed_size", + "encoded_size", "StateCommitment", "Storage", "StorageKey", diff --git a/packages/testing/src/execution_testing/base_types/serialization.py b/packages/testing/src/execution_testing/base_types/serialization.py index 5d5f75e7afd..e22a8a81b07 100644 --- a/packages/testing/src/execution_testing/base_types/serialization.py +++ b/packages/testing/src/execution_testing/base_types/serialization.py @@ -1,9 +1,11 @@ """Ethereum test types for serialization and encoding.""" +from dataclasses import astuple, is_dataclass from typing import Any, ClassVar, List, Self, Sequence import ethereum_rlp as eth_rlp -from ethereum_types.numeric import Uint +from ethereum_rlp.exceptions import EncodingError +from ethereum_types.numeric import FixedUnsigned, Uint from trie import HexaryTrie from execution_testing.base_types import Bytes @@ -26,6 +28,81 @@ def to_serializable_element(v: Any) -> Any: raise Exception(f"Unable to serialize element {v} of type {type(v)}.") +class KnownEncodedSize: + """Stand in for an item whose encoded size is already known.""" + + __slots__ = ("size",) + + def __init__(self, size: int) -> None: + """Record the encoded size of the item this stands in for.""" + self.size = size + + +def encoded_prefixed_size(payload_size: int) -> int: + """ + Return the encoded size of an item with a payload of `payload_size` + bytes, its length prefix included. + """ + if payload_size < 0x38: + return 1 + payload_size + return 1 + len(Uint(payload_size).to_be_bytes()) + payload_size + + +def _encoded_bytes_size(raw_bytes: bytes | bytearray) -> int: + """Return the length of `eth_rlp.encode_bytes(raw_bytes)`.""" + if len(raw_bytes) == 1 and raw_bytes[0] < 0x80: + return 1 + return encoded_prefixed_size(len(raw_bytes)) + + +def _encoded_sequence_size(raw_sequence: Sequence[Any]) -> int: + """Return the length of `eth_rlp.encode_sequence(raw_sequence)`.""" + return encoded_prefixed_size( + sum(encoded_size(item) for item in raw_sequence) + ) + + +def _encoded_unsigned_size(value: int) -> int: + """Return the length of `eth_rlp.encode(Uint(value))`.""" + if value < 0x80: + return 1 + return encoded_prefixed_size((value.bit_length() + 7) // 8) + + +def encoded_size(raw_data: Any) -> int: + """ + Return `len(eth_rlp.encode(raw_data))` without building the encoding. + + This covers the same cases as `ethereum_rlp.encode`, plus + `KnownEncodedSize` placeholders, so measuring the size of a large + structure costs a walk over it instead of megabytes of intermediate byte + strings. The concrete types come first: the abstract + `Sequence` check is slow enough to dominate the walk. + """ + if isinstance(raw_data, (bytearray, bytes)): + return _encoded_bytes_size(raw_data) + elif isinstance(raw_data, (list, tuple)): + return _encoded_sequence_size(raw_data) + elif isinstance(raw_data, (Uint, FixedUnsigned)): + return _encoded_unsigned_size(int(raw_data)) + elif isinstance(raw_data, KnownEncodedSize): + return raw_data.size + elif isinstance(raw_data, bool): + return 1 + elif isinstance(raw_data, str): + return _encoded_bytes_size(raw_data.encode()) + elif isinstance(raw_data, Sequence): + return _encoded_sequence_size(raw_data) + elif is_dataclass(raw_data) and not isinstance(raw_data, type): + return _encoded_sequence_size(astuple(raw_data)) + else: + raise EncodingError( + "RLP encoded size of type {} is not supported".format( + type(raw_data) + ) + ) + + class RLPSerializable: """Class that adds RLP serialization to another class.""" @@ -154,6 +231,14 @@ def rlp(self) -> Bytes: self.get_rlp_prefix() + eth_rlp.encode(self.to_list(signing=False)) ) + def rlp_size(self) -> int: + """Return `len(self.rlp())` without building the encoding.""" + if self.rlp_override is not None: + return len(self.rlp_override) + return len(self.get_rlp_prefix()) + encoded_size( + self.to_list(signing=False) + ) + @classmethod def list_root(cls, element_list: Sequence[Self]) -> bytes: """Return the root of a list of the given type.""" diff --git a/packages/testing/src/execution_testing/base_types/tests/test_serialization.py b/packages/testing/src/execution_testing/base_types/tests/test_serialization.py new file mode 100644 index 00000000000..c4225a6e31d --- /dev/null +++ b/packages/testing/src/execution_testing/base_types/tests/test_serialization.py @@ -0,0 +1,50 @@ +"""Test the RLP size helpers in `serialization`.""" + +from typing import Any + +import ethereum_rlp as eth_rlp +import pytest +from ethereum_types.numeric import U64, U256, Uint + +from ..serialization import KnownEncodedSize, encoded_size + + +@pytest.mark.parametrize( + "raw_data", + [ + pytest.param(b"", id="empty_bytes"), + pytest.param(b"\x00", id="single_byte_zero"), + pytest.param(b"\x7f", id="single_byte_below_0x80"), + pytest.param(b"\x80", id="single_byte_0x80"), + pytest.param(b"\x01" * 55, id="bytes_55"), + pytest.param(b"\x01" * 56, id="bytes_56"), + pytest.param(b"\x01" * 256, id="bytes_256"), + pytest.param(b"\x01" * 70_000, id="bytes_70000"), + pytest.param([], id="empty_list"), + pytest.param([b"\x01" * 53], id="list_payload_55"), + pytest.param([b"\x01" * 54], id="list_payload_56"), + pytest.param([b"\x01" * 254], id="list_payload_256"), + pytest.param([b"\x01", [b"", [b"\x02" * 60]]], id="nested_list"), + pytest.param(Uint(0), id="uint_zero"), + pytest.param(Uint(0x7F), id="uint_0x7f"), + pytest.param(Uint(0x80), id="uint_0x80"), + pytest.param(U64(2**64 - 1), id="u64_max"), + pytest.param(U256(2**256 - 1), id="u256_max"), + pytest.param(True, id="bool_true"), + pytest.param(False, id="bool_false"), + pytest.param("a" * 56, id="str_56"), + ], +) +def test_encoded_size_matches_encode(raw_data: Any) -> None: + """Test that `encoded_size` equals the length of the real encoding.""" + assert encoded_size(raw_data) == len(eth_rlp.encode(raw_data)) + + +@pytest.mark.parametrize("payload_size", [1, 55, 56, 256]) +def test_known_encoded_size_placeholder(payload_size: int) -> None: + """Test that a placeholder counts as the item it stands in for.""" + item = b"\x01" * payload_size + placeholder = KnownEncodedSize(len(eth_rlp.encode(item))) + assert encoded_size([b"\x02", placeholder]) == len( + eth_rlp.encode([b"\x02", item]) + ) diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index af1dcce1dbd..12ffaff1a5a 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -45,8 +45,10 @@ Hash, HeaderNonce, HexNumber, + KnownEncodedSize, Number, ZeroPaddedHexNumber, + encoded_size, ssz, unwrap_annotation, ) @@ -812,6 +814,48 @@ def from_withdrawal(cls, w: WithdrawalGeneric) -> Self: return cls(**w.model_dump()) +def block_rlp_encode_list( + header: FixtureHeader, + serialized_txs: List[Any], + ommers: List[FixtureHeader], + withdrawals: List[FixtureWithdrawal] | None, +) -> List[Any]: + """Return the RLP-serializable list that makes up a block.""" + block: List[Any] = [ + header.rlp_encode_list, + serialized_txs, + ommers, + ] + + if withdrawals is not None: + block.append([w.to_serializable_list() for w in withdrawals]) + + return block + + +def block_rlp_size( + header: FixtureHeader, + txs: List[Transaction], + ommers: List[FixtureHeader], + withdrawals: List[FixtureWithdrawal] | None, +) -> int: + """ + Return the encoded size of the block, without encoding it. + + Each transaction enters `block_rlp_encode_list` as a placeholder carrying + its `Transaction.serializable_size`, so the transaction bytes are never + built. + """ + return encoded_size( + block_rlp_encode_list( + header, + [KnownEncodedSize(tx.serializable_size) for tx in txs], + ommers, + withdrawals, + ) + ) + + class FixtureBlockBase(CamelModel): """ Representation of an Ethereum block within a test Fixture without RLP @@ -853,20 +897,16 @@ def block_number(self) -> Number: def with_rlp(self, txs: List[Transaction]) -> "FixtureBlock": """Return FixtureBlock with the RLP bytes set.""" - block = [ - self.header.rlp_encode_list, - [tx.serializable_list for tx in txs], - # TODO: This is incorrect, and we probably - # need to serialize the ommers - self.ommers, - ] - - if self.withdrawals is not None: - block.append([w.to_serializable_list() for w in self.withdrawals]) - return FixtureBlock( **self.model_dump(), - rlp=eth_rlp.encode(block), + rlp=eth_rlp.encode( + block_rlp_encode_list( + self.header, + [tx.serializable_list for tx in txs], + self.ommers, + self.withdrawals, + ) + ), ) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 82b8d1290fa..c4e7656f299 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -77,6 +77,7 @@ FixtureTransaction, FixtureWithdrawal, InvalidFixtureBlock, + block_rlp_size, ) from execution_testing.fixtures.common import ( FixtureBlobSchedule, @@ -618,6 +619,30 @@ def verify_transactions( transition_tool_exceptions_reliable=transition_tool_exceptions_reliable, ) + def rlp_size(self) -> int: + """ + Return the RLP size of the block. + + ``get_block_rlp`` reaches it through ``get_fixture_block``, which + first converts every transaction and receipt into its fixture model + and dumps the whole block; on a benchmark block carrying tens of + thousands of transactions that costs more than executing the block. + Measuring the size needs neither those models nor the encoded bytes. + """ + return block_rlp_size( + header=self.header, + txs=self.txs, + ommers=[], + withdrawals=( + [ + FixtureWithdrawal.from_withdrawal(w) + for w in self.withdrawals + ] + if self.withdrawals is not None + else None + ), + ) + def verify_block_exception( self, transition_tool_exceptions_reliable: bool ) -> None: @@ -628,7 +653,7 @@ def verify_block_exception( # Verify exceptions that are not caught by the transition tool. fork_block_rlp_size_limit = self.fork.block_rlp_size_limit() if fork_block_rlp_size_limit is not None: - rlp_size = len(self.get_block_rlp()) + rlp_size = self.rlp_size() if rlp_size > fork_block_rlp_size_limit: got_exception = BlockExceptionWithMessage( exceptions=[BlockException.RLP_BLOCK_LIMIT_EXCEEDED], 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 9544c9517c5..ce7292f1c4b 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,6 @@ """Test types from execution_testing.specs.""" +from typing import List from unittest.mock import sentinel import pytest @@ -19,7 +20,12 @@ FixtureHeader, ) from execution_testing.forks import Amsterdam, Fork, Osaka -from execution_testing.test_types import Alloc, Environment +from execution_testing.test_types import ( + Alloc, + Environment, + Transaction, + Withdrawal, +) from execution_testing.test_types.block_access_list import ( BlockAccessList, BlockAccessListExpectation, @@ -170,6 +176,8 @@ def built_block( rlp_modifier: Header | None = None, block_access_list: BlockAccessList | None = None, engine_new_payload_block_access_list: Bytes | None = None, + txs: List[Transaction] | None = None, + withdrawals: List[Withdrawal] | None = None, ) -> BuiltBlock: """Generate a dummy built block with all default values.""" return BuiltBlock( @@ -177,9 +185,9 @@ def built_block( env=Environment(), alloc=LazyAllocStr(raw="", _state_root=Hash(0)), state_root=Hash(0), - txs=[], + txs=txs if txs is not None else [], ommers=[], - withdrawals=None, + withdrawals=withdrawals, requests=None, result=result_empty, fork=fork, @@ -189,6 +197,46 @@ def built_block( ) +@pytest.mark.parametrize( + "withdrawals", + [ + pytest.param(None, id="no_withdrawals"), + pytest.param([], id="empty_withdrawals"), + pytest.param( + [ + Withdrawal( + index=i, validator_index=i, address=Address(i), amount=i + ) + for i in range(3) + ], + id="withdrawals", + ), + ], +) +@pytest.mark.parametrize( + "txs", + [ + pytest.param([], id="no_txs"), + pytest.param([Transaction(ty=0)], id="legacy_tx"), + pytest.param([Transaction(ty=2)], id="typed_tx"), + pytest.param( + [ + Transaction(ty=0, data=b"\x01" * 300), + Transaction(ty=1, nonce=1), + Transaction(ty=2, nonce=2, data=b"\x02" * 60), + ], + id="mixed_txs", + ), + ], +) +def test_built_block_rlp_size( + txs: List[Transaction], withdrawals: List[Withdrawal] | None +) -> None: + """Test that `BuiltBlock.rlp_size` equals the length of the block RLP.""" + block = built_block(txs=txs, withdrawals=withdrawals) + assert block.rlp_size() == len(block.get_block_rlp()) + + class TestDeriveEnginePayloadModifier: """ Verify the auto-propagation from ``rlp_modifier``'s header-only changes 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 071b9f4d4f2..0b0c6d4e368 100644 --- a/packages/testing/src/execution_testing/test_types/transaction_types.py +++ b/packages/testing/src/execution_testing/test_types/transaction_types.py @@ -30,6 +30,8 @@ SignableRLPSerializable, TestAddress, TestPrivateKey, + encoded_prefixed_size, + encoded_size, ) from execution_testing.exceptions import TransactionException from execution_testing.forks import Fork @@ -540,13 +542,16 @@ def signature_bytes(self) -> Bytes: def sign(self: "Transaction") -> None: """Signs the authorization tuple with a private key.""" - signature_bytes: bytes | None = None - rlp_signing_bytes = self.rlp_signing_bytes() - if ( + needs_signature = ( "v" not in self.model_fields_set and "r" not in self.model_fields_set and "s" not in self.model_fields_set - ): + ) + if not needs_signature and self.sender is not None: + return + signature_bytes: bytes | None = None + rlp_signing_bytes = self.rlp_signing_bytes() + if needs_signature: signing_key: Hash | None = None if self.secret_key is not None: signing_key = self.secret_key @@ -943,6 +948,18 @@ def serializable_list(self) -> Any: """ return self.rlp() if self.ty > 0 else self.to_list(signing=False) + @cached_property + def serializable_size(self) -> int: + """ + Return the encoded size of `serializable_list`, without building the + encoding. + """ + if self.ty == 0: + return encoded_size(self.serializable_list) + # A typed transaction is embedded as an opaque byte string, never + # short enough to encode as a single byte. + return encoded_prefixed_size(self.rlp_size()) + @staticmethod def list_blob_versioned_hashes( input_txs: List["Transaction"], From 86f1982e5e273e03f9f1c60b588e3b57d6df18e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Mon, 28 Sep 2026 03:44:38 +0200 Subject: [PATCH 18/28] feat(test-benchmark): add gas-mutated point-evaluation benchmark (#3660) Add a `point_evaluation_unique_y` variant to `test_point_evaluation` that overwrites the input's y field with the current GAS before every call, so each STATICCALL runs a fresh KZG pairing no client can cache by input, while calldata stays a constant 192 bytes. The mutated input makes the call fail, so it forwards exactly the fixed precompile cost (a failing precompile consumes all gas forwarded to it, so Op.GAS would spend the whole budget on the first call). Its per-call cost matches the existing valid and `test_point_evaluation_uncachable` variants across geth/besu/evmone, but it needs no per-call proof generation and no long calldata. Maintainers may therefore want to consider consolidating the `test_point_evaluation_uncachable` test into this cheaper form. --- .../precompile/test_point_evaluation.py | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/tests/benchmark/compute/precompile/test_point_evaluation.py b/tests/benchmark/compute/precompile/test_point_evaluation.py index a9bdad6b64e..0ba5c00a8cc 100644 --- a/tests/benchmark/compute/precompile/test_point_evaluation.py +++ b/tests/benchmark/compute/precompile/test_point_evaluation.py @@ -27,7 +27,7 @@ @pytest.mark.repricing @pytest.mark.parametrize( - "precompile_address,calldata", + "precompile_address,calldata,mutate_y", [ pytest.param( BlobsSpec.POINT_EVALUATION_PRECOMPILE_ADDRESS, @@ -47,8 +47,34 @@ "FBEAFDAD446CBC7BCDBDCD780AF2C16A" ), ), + False, id="point_evaluation", ), + pytest.param( + BlobsSpec.POINT_EVALUATION_PRECOMPILE_ADDRESS, + PointEvaluationInput( + versioned_hash=bytes.fromhex( + "01E798154708FE7789429634053CBF9F" + "99B619F9F084048927333FCE637F549B" + ), + z=0x564C0A11A0F704F4FC3E8ACFE0F8245F0AD1347B378FBF96E206DA11A5D36306, + # y is overwritten in memory before each call with the current + # GAS value: unique per iteration and always < BLS_MODULUS, so + # every call runs a fresh pairing that no client can cache by + # input, while calldata stays a constant 192 bytes. + y=0x24D25032E67A7E6A4910DF5834B8FE70E6BCFEEAC0352434196BDF4B2485D5A1, + commitment=bytes.fromhex( + "8F59A8D2A1A625A17F3FEA0FE5EB8C896DB3764F3185481BC22F91B4AAFFCCA2" + "5F26936857BC3A7C2539EA8EC3A952B7" + ), + proof=bytes.fromhex( + "873033E038326E87ED3E1276FD140253FA08E9FC25FB2D9A98527FC22A2C9612" + "FBEAFDAD446CBC7BCDBDCD780AF2C16A" + ), + ), + True, + id="point_evaluation_unique_y", + ), ], ) def test_point_evaluation( @@ -56,18 +82,33 @@ def test_point_evaluation( fork: Fork, precompile_address: Address, calldata: bytes, + mutate_y: bool, ) -> None: """Benchmark POINT EVALUATION precompile.""" if precompile_address not in fork.precompiles(): pytest.skip("Precompile not enabled") - attack_block = Op.POP( + # The mutated variant makes every call fail, and a failing precompile + # consumes all gas forwarded to it, so forwarding Op.GAS would burn the + # whole budget on the first call. Forward exactly the fixed precompile + # cost so each failure costs only that and the loop keeps issuing calls. + call_gas = ( + int(fork.gas_costs().PRECOMPILE_POINT_EVALUATION) + if mutate_y + else Op.GAS + ) + # y occupies bytes [64:96] of the 192-byte input. + y_offset = 64 + call = Op.POP( Op.STATICCALL( - gas=Op.GAS, + gas=call_gas, address=precompile_address, args_size=Op.CALLDATASIZE, ), ) + # Overwrite y with the current GAS each iteration: a distinct field + # element (< BLS_MODULUS) per call, defeating any input-keyed cache. + attack_block = Op.MSTORE(y_offset, Op.GAS) + call if mutate_y else call benchmark_test( target_opcode=Precompile.POINT_EVALUATION, From 2b19bf5fa4969dcad3a3fa1b7fa997416a03f5bf Mon Sep 17 00:00:00 2001 From: spencer Date: Mon, 28 Sep 2026 04:25:22 +0200 Subject: [PATCH 19/28] feat(tests): cover a null EIP-8070 custodyColumns and repin to the current EIP blob (#3622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tests): cover a null EIP-8070 custodyColumns and repin to the current EIP blob * fix(tests): send custodyColumns as ordered updates and cover null after a custody set Co-authored-by: 蔡佳誠 Louis Tsai <72684086+LouisTsai-Csie@users.noreply.github.com> --------- Co-authored-by: 蔡佳誠 Louis Tsai <72684086+LouisTsai-Csie@users.noreply.github.com> --- .../execution/blob_transaction.py | 103 +++++++++++------- .../testing/src/execution_testing/rpc/rpc.py | 11 ++ .../src/execution_testing/specs/blobs.py | 25 ++++- .../specs/tests/test_specs.py | 23 +++- .../amsterdam/eip8070_sparse_blobpool/spec.py | 2 +- .../test_custody_columns.py | 79 ++++++++++++-- 6 files changed, 186 insertions(+), 57 deletions(-) diff --git a/packages/testing/src/execution_testing/execution/blob_transaction.py b/packages/testing/src/execution_testing/execution/blob_transaction.py index 9277202529b..a4735d94f57 100644 --- a/packages/testing/src/execution_testing/execution/blob_transaction.py +++ b/packages/testing/src/execution_testing/execution/blob_transaction.py @@ -248,7 +248,7 @@ class BlobTransaction(BaseExecute): interleave_nonexisting_blob_hashes: bool = False get_blobs_version: int | None = None cell_mask: int | None = None - custody_columns: bytes | None = None + custody_columns_updates: List[bytes | None] | None = None def prepare_transactions( self, @@ -305,21 +305,24 @@ def get_required_sender_balances( balances[sender] += tx.signer_minimum_balance(fork=fork) return balances - def _update_custody_columns( + def _send_custody_columns_updates( self, fork: Fork, eth_rpc: EthRPC, engine_rpc: EngineRPC, ) -> None: """ - Send a forkchoice update carrying the `custodyColumns` bitmap. + Send one forkchoice update per entry of `custody_columns_updates`. - A 16-byte bitmap must be accepted with a VALID payload status - (custody set update errors must not affect the forkchoice flow, - per `engine_forkchoiceUpdatedV4`); any other length must be - rejected with `-32602: Invalid params`. + A 16-byte bitmap or an explicit `null` must be accepted with a + VALID payload status (`null` and a bitmap identical to the current + set are blobpool no-ops, and custody set update errors must not + affect the forkchoice flow, per `engine_forkchoiceUpdatedV4`); any + other length must be rejected with `-32602: Invalid params`. """ - assert self.custody_columns is not None + assert self.custody_columns_updates, ( + "custody_columns_updates must hold at least one update." + ) fcu_version = fork.engine_forkchoice_updated_version() assert fcu_version is not None and fcu_version >= 4, ( "custodyColumns requires engine_forkchoiceUpdatedV4." @@ -329,39 +332,55 @@ def _update_custody_columns( forkchoice_state = ForkchoiceState( head_block_hash=Hash(latest_block["hash"]), ) - valid_length = len(self.custody_columns) == CUSTODY_COLUMNS_BYTE_LENGTH - try: - response = engine_rpc.forkchoice_updated( - forkchoice_state, - None, - version=fcu_version, - custody_columns=self.custody_columns, - ) - except JSONRPCError as e: - if valid_length: - raise - if e.code != -32602: + for custody_columns in self.custody_columns_updates: + if custody_columns is None: + response = engine_rpc.forkchoice_updated( + forkchoice_state, + None, + version=fcu_version, + custody_columns_null=True, + ) + status = response.payload_status.status + if status != PayloadStatusEnum.VALID: + raise ValueError( + f"forkchoiceUpdatedV{fcu_version} with a null " + f"custodyColumns returned payload status {status}, " + "expected VALID." + ) + continue + valid_length = len(custody_columns) == CUSTODY_COLUMNS_BYTE_LENGTH + try: + response = engine_rpc.forkchoice_updated( + forkchoice_state, + None, + version=fcu_version, + custody_columns=custody_columns, + ) + except JSONRPCError as e: + if valid_length: + raise + if e.code != -32602: + raise ValueError( + f"Expected error -32602 (Invalid params) for a " + f"{len(custody_columns)}-byte custodyColumns, " + f"got {e.code}: {e.message}" + ) from e + logger.info( + f"Client correctly rejected a " + f"{len(custody_columns)}-byte custodyColumns bitmap." + ) + continue + if not valid_length: raise ValueError( - f"Expected error -32602 (Invalid params) for a " - f"{len(self.custody_columns)}-byte custodyColumns, " - f"got {e.code}: {e.message}" - ) from e - logger.info( - f"Client correctly rejected a " - f"{len(self.custody_columns)}-byte custodyColumns bitmap." - ) - return - if not valid_length: - raise ValueError( - f"Client accepted a {len(self.custody_columns)}-byte " - "custodyColumns bitmap; expected -32602 (Invalid params)." - ) - status = response.payload_status.status - if status != PayloadStatusEnum.VALID: - raise ValueError( - f"forkchoiceUpdatedV{fcu_version} with custodyColumns " - f"returned payload status {status}, expected VALID." - ) + f"Client accepted a {len(custody_columns)}-byte " + "custodyColumns bitmap; expected -32602 (Invalid params)." + ) + status = response.payload_status.status + if status != PayloadStatusEnum.VALID: + raise ValueError( + f"forkchoiceUpdatedV{fcu_version} with custodyColumns " + f"returned payload status {status}, expected VALID." + ) def execute( self, @@ -435,8 +454,8 @@ def execute( else: list_versioned_hashes.extend(self.nonexisting_blob_hashes) - if self.custody_columns is not None: - self._update_custody_columns(fork, eth_rpc, engine_rpc) + if self.custody_columns_updates is not None: + self._send_custody_columns_updates(fork, eth_rpc, engine_rpc) indices_bitarray = self.cell_mask if version >= 4 else None blob_response: GetBlobsResponse | GetBlobsV4Response | None = ( diff --git a/packages/testing/src/execution_testing/rpc/rpc.py b/packages/testing/src/execution_testing/rpc/rpc.py index 58cfb14035d..13db494f175 100644 --- a/packages/testing/src/execution_testing/rpc/rpc.py +++ b/packages/testing/src/execution_testing/rpc/rpc.py @@ -1494,12 +1494,20 @@ def forkchoice_updated( *, version: int, custody_columns: bytes | None = None, + custody_columns_null: bool = False, ) -> ForkchoiceUpdateResponse: """ `engine_forkchoiceUpdatedVX`: Updates the forkchoice state of the execution client. + + `custody_columns_null` sends an explicit `null` as the + `custodyColumns` parameter (EIP-8070: the client treats it as a + no-op for its blobpool). """ method = f"forkchoiceUpdatedV{version}" + assert custody_columns is None or not custody_columns_null, ( + "Pass either custody_columns or custody_columns_null, not both." + ) params: List[Any] if payload_attributes is None: @@ -1511,6 +1519,9 @@ def forkchoice_updated( # a bitmap of the blob columns custodied by the node. assert version >= 4, "custodyColumns requires forkchoiceUpdatedV4." params.append(f"0x{custody_columns.hex()}") + elif custody_columns_null: + assert version >= 4, "custodyColumns requires forkchoiceUpdatedV4." + params.append(None) return ForkchoiceUpdateResponse.model_validate( self.post_request( diff --git a/packages/testing/src/execution_testing/specs/blobs.py b/packages/testing/src/execution_testing/specs/blobs.py index 9b2565b520f..2b1683eecd0 100644 --- a/packages/testing/src/execution_testing/specs/blobs.py +++ b/packages/testing/src/execution_testing/specs/blobs.py @@ -2,6 +2,9 @@ from typing import Callable, ClassVar, Generator, List, Sequence, Type +from pydantic import model_validator +from typing_extensions import Self + from execution_testing.base_types import Alloc from execution_testing.base_types.base_types import Hash from execution_testing.client_clis import TransitionTool @@ -27,7 +30,25 @@ class BlobsTest(BaseTest): interleave_nonexisting_blob_hashes: bool = False get_blobs_version: int | None = None cell_mask: int | None = None - custody_columns: bytes | None = None + custody_columns_updates: List[bytes | None] | None = None + """ + `custodyColumns` values to send in order, one + `engine_forkchoiceUpdatedV4` each, before `engine_getBlobsV*`: a bitmap + sends that value and `None` sends an explicit `null`. Leave unset to + send no update. + """ + + @model_validator(mode="after") + def _check_custody_columns_updates(self) -> Self: + """Reject an empty update list, which would send nothing.""" + if self.custody_columns_updates is not None and not ( + self.custody_columns_updates + ): + raise ValueError( + "custody_columns_updates must hold at least one update; " + "leave it unset to send no custodyColumns update." + ) + return self supported_execute_formats: ClassVar[Sequence[LabeledExecuteFormat]] = [ LabeledExecuteFormat( @@ -62,7 +83,7 @@ def execute( ), get_blobs_version=self.get_blobs_version, cell_mask=self.cell_mask, - custody_columns=self.custody_columns, + custody_columns_updates=self.custody_columns_updates, ) raise Exception(f"Unsupported execute format: {execute_format}") 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 ddf2b9c8e80..efd3c3c4de5 100644 --- a/packages/testing/src/execution_testing/specs/tests/test_specs.py +++ b/packages/testing/src/execution_testing/specs/tests/test_specs.py @@ -11,10 +11,11 @@ LabeledFixtureFormat, StateFixture, ) -from execution_testing.forks import Istanbul +from execution_testing.forks import Amsterdam, Istanbul from execution_testing.test_types import Alloc, Environment, Transaction from ..base import BaseTest +from ..blobs import BlobsTest from ..blockchain import BlockchainTest from ..state import StateTest @@ -199,3 +200,23 @@ def test_state_test_conversion_checks_the_env_first() -> None: ) with pytest.raises(ValueError, match="excess_blob_gas"): state_test.generate_blockchain_test() + + +def test_blobs_test_rejects_empty_custody_columns_updates() -> None: + """ + Verify a blobs test accepts any non-empty sequence of `custodyColumns` + updates, with `None` for an explicit `null`, and rejects an empty one, + which would send nothing while looking like a request to. + """ + bitmap = b"\xff" * 16 + for updates in ([bitmap], [None], [bitmap, None], [bitmap, bitmap]): + BlobsTest( + pre=Alloc(), + txs=[], + fork=Amsterdam, + custody_columns_updates=updates, + ) + with pytest.raises(ValueError, match="custody_columns_updates"): + BlobsTest( + pre=Alloc(), txs=[], fork=Amsterdam, custody_columns_updates=[] + ) diff --git a/tests/amsterdam/eip8070_sparse_blobpool/spec.py b/tests/amsterdam/eip8070_sparse_blobpool/spec.py index 8b7ab0ca7ed..8b7a4424b9d 100644 --- a/tests/amsterdam/eip8070_sparse_blobpool/spec.py +++ b/tests/amsterdam/eip8070_sparse_blobpool/spec.py @@ -12,7 +12,7 @@ class ReferenceSpec: ref_spec_8070 = ReferenceSpec( - "EIPS/eip-8070.md", "43c7af020f1641924bed60565fde86f6ad3469df" + "EIPS/eip-8070.md", "5f1154bdeba6fb473a49dbbb430383fa71bc5b3f" ) diff --git a/tests/amsterdam/eip8070_sparse_blobpool/test_custody_columns.py b/tests/amsterdam/eip8070_sparse_blobpool/test_custody_columns.py index 52c6fcd78e8..2e22eafa7db 100644 --- a/tests/amsterdam/eip8070_sparse_blobpool/test_custody_columns.py +++ b/tests/amsterdam/eip8070_sparse_blobpool/test_custody_columns.py @@ -8,9 +8,17 @@ `custodyColumns` is an optional 16-byte bitmap informing the execution client of the blob columns it must custody. A well-formed bitmap must be accepted (custody set update errors must not affect the forkchoice flow); -a bitmap of any other length must be rejected with `-32602: Invalid -params`. Blob serving via `engine_getBlobsV4` must be unaffected either -way, since the client holds the full blobs. +a `null` value or a bitmap identical to the current set is a blobpool +no-op and must also be accepted; a bitmap of any other length must be +rejected with `-32602: Invalid params`. Blob serving via +`engine_getBlobsV4` must be unaffected either way, since the client +holds the full blobs. + +Each test sends its `custodyColumns` values in order, one forkchoice +update each, before requesting the blobs. The custody set itself only +steers devp2p sampling of peer-announced transactions and is not +observable through the Engine API, so these tests pin acceptance and +unaffected blob serving, not the resulting custody set. """ from typing import List @@ -35,6 +43,9 @@ CELLS = Spec.CELLS_PER_EXT_BLOB ALL_CELLS_MASK = (1 << CELLS) - 1 BITMAP_BYTES = Spec.CUSTODY_BITMAP_BYTES +CUSTODY_ALIGNED_8 = ((1 << Spec.SAMPLES_PER_SLOT) - 1).to_bytes( + BITMAP_BYTES, "little" +) def generate_single_blob_layout(fork: Fork) -> List: @@ -48,12 +59,7 @@ def generate_single_blob_layout(fork: Fork) -> List: "custody_columns", [ pytest.param(b"\xff" * BITMAP_BYTES, id="all_columns"), - pytest.param( - ((1 << Spec.SAMPLES_PER_SLOT) - 1).to_bytes( - BITMAP_BYTES, "little" - ), - id="custody_aligned_8", - ), + pytest.param(CUSTODY_ALIGNED_8, id="custody_aligned_8"), pytest.param(b"\x00" * BITMAP_BYTES, id="no_columns"), ], ) @@ -75,7 +81,58 @@ def test_fcu_custody_columns( txs=txs, get_blobs_version=4, cell_mask=ALL_CELLS_MASK, - custody_columns=custody_columns, + custody_columns_updates=[custody_columns], + ) + + +@pytest.mark.parametrize( + "custody_columns_updates", + [ + pytest.param([None], id="fresh"), + pytest.param([CUSTODY_ALIGNED_8, None], id="after_custody_set"), + ], +) +@pytest.mark.parametrize_by_fork("txs_blobs", generate_single_blob_layout) +@pytest.mark.exception_test +def test_fcu_custody_columns_null( + blobs_test: BlobsTestFiller, + pre: Alloc, + txs: List[NetworkWrappedTransaction | Transaction], + custody_columns_updates: List[bytes | None], +) -> None: + """ + Test that `engine_forkchoiceUpdatedV4` accepts a `null` + `custodyColumns` with a VALID payload status, both on a client with no + custody set and after one was configured, and that `getBlobsV4` still + serves every cell of the pending transaction afterwards. + """ + blobs_test( + pre=pre, + txs=txs, + get_blobs_version=4, + cell_mask=ALL_CELLS_MASK, + custody_columns_updates=custody_columns_updates, + ) + + +@pytest.mark.parametrize_by_fork("txs_blobs", generate_single_blob_layout) +@pytest.mark.exception_test +def test_fcu_custody_columns_identical( + blobs_test: BlobsTestFiller, + pre: Alloc, + txs: List[NetworkWrappedTransaction | Transaction], +) -> None: + """ + Test that resending the current custody set, the other no-op case the + EIP lists alongside `null`, is accepted with a VALID payload status + and that `getBlobsV4` still serves every cell afterwards. + """ + blobs_test( + pre=pre, + txs=txs, + get_blobs_version=4, + cell_mask=ALL_CELLS_MASK, + custody_columns_updates=[CUSTODY_ALIGNED_8, CUSTODY_ALIGNED_8], ) @@ -104,5 +161,5 @@ def test_fcu_custody_columns_invalid_length( txs=txs, get_blobs_version=4, cell_mask=ALL_CELLS_MASK, - custody_columns=custody_columns, + custody_columns_updates=[custody_columns], ) From 3e170675290673a68eeb4652501fb2ae74fea0cb Mon Sep 17 00:00:00 2001 From: shubham shinde Date: Mon, 28 Sep 2026 23:03:15 +0530 Subject: [PATCH 20/28] feat(tests): cover BAL same-index nonce keep and code replace (#3654) * feat(tests): cover BAL same-index nonce keep and code replace Add a post-execution two-call fixture that CREATEs then value-CALLs at one block access index, hitting the previously untested keep/replace branches in add_nonce_change and add_code_change. Co-authored-by: Cursor * style(tests): format same-index BAL coverage test Co-authored-by: Cursor * refactor(tests): reach the same-index BAL branches with one call per predeploy * chore(tests): fix the eip7928 missed_lines entry and its re-verify command * chore(skills): update checklist skill wording for better results --------- Co-authored-by: Cursor Co-authored-by: fselmo --- .agents/skills/eip-checklist/SKILL.md | 9 ++ .../eip_testing_checklist_template.md | 2 +- .../eip_checklist_external_coverage.txt | 2 +- .../test_block_access_lists_cross_index.py | 112 ++++++++++++++++++ .../test_cases.md | 1 + 5 files changed, 124 insertions(+), 2 deletions(-) diff --git a/.agents/skills/eip-checklist/SKILL.md b/.agents/skills/eip-checklist/SKILL.md index 7cb0313e8d8..26efab23c10 100644 --- a/.agents/skills/eip-checklist/SKILL.md +++ b/.agents/skills/eip-checklist/SKILL.md @@ -51,6 +51,15 @@ precompile/ = EIP-7702 does not introduce a precompile (trailing `/` marks entire category as N/A) +## Code Coverage Items + +`general/code_coverage/missed_lines` claims that every remaining miss is acceptable; it is not a place to park gaps. + +- Measure branches: `uv run fill --cov=ethereum --cov-branch --cov-report=term-missing --until `. A line report counts an `if` whose false path never ran as covered, and without `--until` a fork under development fills nothing. Before calling a miss uncovered, rerun it against every suite that can reach that code. +- A missed branch is a test to write. Only two kinds of miss are acceptable: code that runs only outside `fill` (such as block validation in `fork.py`, run by `just json-loader`), and code the spec makes unreachable. +- An unreachable claim cites the guard that rules the branch out and a construction that was tried and failed, ideally by a second person or agent. "Nothing on mainnet does this" is not a reason while custom predeploy code (`pre_alloc_mutable`) or a crafted transaction reaches the branch at fill time. +- The entry lists only what is still missed, why, and a re-verify command that has been run as written; never covered branches or test names, which go stale. Re-derive the entry whenever the code it reasons about changes. + ## Completed Examples Reference these for patterns: diff --git a/docs/writing_tests/checklist_templates/eip_testing_checklist_template.md b/docs/writing_tests/checklist_templates/eip_testing_checklist_template.md index 05b9616b7d5..264674c8de4 100644 --- a/docs/writing_tests/checklist_templates/eip_testing_checklist_template.md +++ b/docs/writing_tests/checklist_templates/eip_testing_checklist_template.md @@ -17,7 +17,7 @@ Depending on the changes introduced by an EIP, the following template is the min | ID | Description | Status | Tests | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | ----- | -| `general/code_coverage/eels` | Run produced tests against [EELS](https://github.com/ethereum/execution-specs) and verify that line code coverage of new added lines for the EIP is 100%, with only exceptions being unreachable code lines. | | | +| `general/code_coverage/eels` | Run produced tests against [EELS](https://github.com/ethereum/execution-specs) and verify that branch coverage of new added lines for the EIP is 100%, with only exceptions being unreachable code lines. | | | | `general/code_coverage/test_coverage` | Run coverage on the test code itself (as a basic logic sanity check), i.e., `uv run fill --cov tests`. | | | | `general/code_coverage/missed_lines` | Document any lines missed in coverage reports and explain why they are acceptable (e.g., unreachable code, general infrastructure not related to the EIP). | | | | `general/code_coverage/second_client` | Optional - Run against a second client and verify sufficient code coverage over new code added for the EIP. | | | diff --git a/tests/amsterdam/eip7928_block_level_access_lists/eip_checklist_external_coverage.txt b/tests/amsterdam/eip7928_block_level_access_lists/eip_checklist_external_coverage.txt index 5c3382ae8a4..fee1271ddea 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/eip_checklist_external_coverage.txt +++ b/tests/amsterdam/eip7928_block_level_access_lists/eip_checklist_external_coverage.txt @@ -1,4 +1,4 @@ general/code_coverage/eels = Run produced tests against EELS and verify line coverage of new BAL code (block_access_list/, modifiers, builder) is at the expected level general/code_coverage/test_coverage = Run tests with `uv run fill --cov tests/amsterdam/eip7928_block_level_access_lists` to verify test code coverage general/code_coverage/second_client = Cross-client coverage tracked separately via the multi-client BAL conformance effort -general/code_coverage/missed_lines = `add_nonce_change`'s keep-the-existing-entry branch runs only when a later unit at the same block access index rewrites an account without changing its nonce, resubmitting the value already recorded. No fixture does that: the one test with two units at index 0 creates a contract from both, so the second call always carries a higher nonce. `add_code_change`'s replace branch needs two system calls at one index changing the same account's code, which nothing on mainnet does; its storage, balance and nonce twins are covered. The BAL lines of `fork.py` in `state_transition` and `apply_body` are off the fill path, which drives the t8n directly, and run when fixtures are consumed by EELS (`just json-loader`). Re-verify with `uv run fill --cov=ethereum --cov-report=term-missing tests/amsterdam/eip7928_block_level_access_lists`. +general/code_coverage/missed_lines = The BAL lines of `fork.py` in `execute_block` and `apply_body` are off the fill path, which drives the t8n directly, and run when fixtures are consumed by EELS (`just json-loader`). Re-verify with `uv run fill --cov=ethereum --cov-branch --cov-report=term-missing tests/amsterdam/eip7928_block_level_access_lists --until Amsterdam`. diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py index 869d405038f..b35d93d8399 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py @@ -17,6 +17,7 @@ BalAccountAbsentValues, BalAccountExpectation, BalBalanceChange, + BalCodeChange, BalNonceChange, BalStorageChange, BalStorageSlot, @@ -26,6 +27,7 @@ Bytecode, ConsolidationRequest, Fork, + Initcode, Op, SystemCallPhase, Transaction, @@ -648,6 +650,116 @@ def test_bal_post_execution_calls_net_storage_at_last_index( ) +@pytest.mark.pre_alloc_mutable() +def test_bal_same_index_call_keeps_one_nonce_and_code_change( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, +) -> None: + """ + Ensure funding a contract created earlier at the same block access index + leaves one nonce change and one code change for it. + + The withdrawal predeploy's system call creates the contract and the + consolidation predeploy's call, which runs after it, sends it value. + """ + post_execution = _system_contracts_called( + fork, SystemCallPhase.AFTER_TRANSACTIONS + ) + assert { + WITHDRAWAL_REQUEST_ADDRESS, + CONSOLIDATION_REQUEST_ADDRESS, + }.issubset(post_execution), ( + "the request predeploys are no longer called after transactions" + ) + + endowment = 1 + deploy_code = Op.STOP + initcode = Initcode(deploy_code=deploy_code) + pre[WITHDRAWAL_REQUEST_ADDRESS] = Account( + nonce=1, + code=Om.MSTORE(initcode, 0) + + Op.POP(Op.CREATE(offset=0, size=len(initcode))), + ) + created = compute_create_address( + address=WITHDRAWAL_REQUEST_ADDRESS, nonce=1 + ) + # Recording the contract's code size proves this call runs after the + # create; in the other order the test would pass without reaching the + # branches. A zero-value call would not write the contract at all. + code_size_slot = 0 + pre[CONSOLIDATION_REQUEST_ADDRESS] = Account( + balance=endowment, + code=Op.SSTORE(code_size_slot, Op.EXTCODESIZE(created)) + + Op.POP(Op.CALL(address=created, value=endowment)), + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + WITHDRAWAL_REQUEST_ADDRESS: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=1, post_nonce=2 + ) + ], + ), + CONSOLIDATION_REQUEST_ADDRESS: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=code_size_slot, + slot_changes=[ + BalStorageChange( + block_access_index=1, + post_value=len(deploy_code), + ) + ], + ), + ], + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=0 + ) + ], + ), + created: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=1, post_nonce=1 + ) + ], + code_changes=[ + BalCodeChange( + block_access_index=1, + new_code=bytes(deploy_code), + ) + ], + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=endowment, + ) + ], + ), + SYSTEM_ADDRESS: None, + } + ), + ) + ], + post={ + WITHDRAWAL_REQUEST_ADDRESS: Account(nonce=2), + CONSOLIDATION_REQUEST_ADDRESS: Account( + balance=0, storage={code_size_slot: len(deploy_code)} + ), + created: Account(nonce=1, code=deploy_code, balance=endowment), + }, + ) + + @pytest.mark.pre_alloc_mutable() def test_bal_tx_change_kept_when_system_call_restores_slot( pre: Alloc, 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 db714dfa157..47795f5173c 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -36,6 +36,7 @@ | `test_bal_noop_write_filtering` | Ensure BAL filters NOOP storage writes (writing same value or 0 to empty slot) | Contract writes 0 to uninitialized slot 1 (noop), 42 to slot 2 (real change), 100 to slot 3 (same as pre-state, noop), 200 to slot 4 (different from pre-state 150, real change). | BAL **MUST** include `storage_changes` only for slots 2 and 4 (actual changes). Slots 1 and 3 **MUST NOT** appear in `storage_changes` (no-op writes filtered). | ✅ Completed | | `test_bal_system_contract_noop_filtering` | Ensure system contract post-execution calls filter net-zero storage writes | Simple transfer that doesn't interact with system contracts. Post-execution system calls read withdrawal/consolidation contract slots 0-3 but don't modify them. | Withdrawal and consolidation system contracts **MUST** have `storage_reads` for slots 0x00-0x03 but **MUST NOT** have `storage_changes` (no actual modifications occurred). | ✅ Completed | | `test_bal_post_execution_calls_net_storage_at_last_index` | Ensure writes by several system calls sharing one block access index are netted against the state at the start of the index, not per call, and pin the order of the post-execution calls | Custom code at two request predeploys: the consolidation contract toggles a slot, bumps a counter, records its caller and runs a CREATE; the withdrawal contract calls it, so it runs twice at the last index. Requests are made in ascending request type, so the consolidation contract's own system call comes second. EIP-7002 and EIP-7251 require the EVM call, so unlike the pre-execution predeploys no client may skip the substituted code. | The toggled slot **MUST** appear in `storage_reads` and not in `storage_changes`; the counter **MUST** be a single change `(1, 2)`, the nonce a single change `(1, 3)`, and the last-caller slot a single change holding `SYSTEM_ADDRESS`; each created account has one nonce change at index 1. | ✅ Completed | +| `test_bal_same_index_call_keeps_one_nonce_and_code_change` | Ensure a later system call at the same index that rewrites an account without changing its nonce or code adds no second change | Custom code at two request predeploys: the withdrawal contract CREATEs a `STOP` contract, then the consolidation contract's own system call at the same index records its code size and sends it value. | The created account **MUST** have one `nonce_changes` entry `(1, 1)`, one `code_changes` entry with `STOP` and one `balance_changes` entry with the endowment; the withdrawal contract a single nonce change `(1, 2)`. The recorded code size **MUST** be that of `STOP`, which fails the test if the calls run in the other order and the branches go unreached. | ✅ Completed | | `test_bal_tx_change_kept_when_system_call_restores_slot` | Ensure netting stops at the index boundary | Custom consolidation contract code records its caller and counts its calls; the caller slot starts holding `SYSTEM_ADDRESS`, so Alice's transaction moves it and the post-execution system call restores it. | The caller slot **MUST** keep both changes, `(1, alice)` and `(2, SYSTEM_ADDRESS)`, with no `storage_reads` entry, although the block leaves it at its starting value. The counter reaching two separates that round trip from neither call running. | ✅ Completed | | `test_bal_4788_noop_writes_are_reads` | Ensure the beacon roots system call's unchanged writes are recorded as reads | The two ring-buffer slots are seeded with the timestamp and root the block will write, so both system-call writes leave their slots unchanged. Canonical contract code, so the test holds whether a client runs the contract or writes the slots directly, as EIP-4788 permits. | `BEACON_ROOTS_ADDRESS` **MUST** have both slots in `storage_reads` and no `storage_changes`; `SYSTEM_ADDRESS` **MUST** be absent. | ✅ Completed | | `test_bal_withdrawals_and_dequeues_net_balance_at_last_index` | Ensure withdrawals and the system calls at the post-execution index net their balance effects, for every predeploy the fork calls after the transactions | A withdrawal credits each post-execution predeploy (taken from `fork.system_contract_call_phases()`), whose custom code forwards its balance to one sink during its dequeue call. Parameterized: (1) forward all, (2) forward half. | Forwarding all: no predeploy has `balance_changes`. Forwarding half: each records one change with the kept half. The sink records one balance change at `len(txs) + 1` with every forwarded amount summed. | ✅ Completed | From 05e14bd8778d350a5a005800acb3e68cc753a69c Mon Sep 17 00:00:00 2001 From: kenny-ish <114967656+kenny-ish@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:32:40 +0700 Subject: [PATCH 21/28] refactor(spec-specs): remove unreachable except in calculate_memory_gas_cost (#3651) --- src/ethereum/forks/amsterdam/vm/gas.py | 5 +---- src/ethereum/forks/arrow_glacier/vm/gas.py | 5 +---- src/ethereum/forks/berlin/vm/gas.py | 5 +---- src/ethereum/forks/bpo1/vm/gas.py | 5 +---- src/ethereum/forks/bpo2/vm/gas.py | 5 +---- src/ethereum/forks/bpo3/vm/gas.py | 5 +---- src/ethereum/forks/bpo4/vm/gas.py | 5 +---- src/ethereum/forks/bpo5/vm/gas.py | 5 +---- src/ethereum/forks/byzantium/vm/gas.py | 5 +---- src/ethereum/forks/cancun/vm/gas.py | 5 +---- src/ethereum/forks/constantinople/vm/gas.py | 5 +---- src/ethereum/forks/dao_fork/vm/gas.py | 5 +---- src/ethereum/forks/frontier/vm/gas.py | 5 +---- src/ethereum/forks/gray_glacier/vm/gas.py | 5 +---- src/ethereum/forks/homestead/vm/gas.py | 5 +---- src/ethereum/forks/istanbul/vm/gas.py | 5 +---- src/ethereum/forks/london/vm/gas.py | 5 +---- src/ethereum/forks/muir_glacier/vm/gas.py | 5 +---- src/ethereum/forks/osaka/vm/gas.py | 5 +---- src/ethereum/forks/paris/vm/gas.py | 5 +---- src/ethereum/forks/prague/vm/gas.py | 5 +---- src/ethereum/forks/shanghai/vm/gas.py | 5 +---- src/ethereum/forks/spurious_dragon/vm/gas.py | 5 +---- src/ethereum/forks/tangerine_whistle/vm/gas.py | 5 +---- 24 files changed, 24 insertions(+), 96 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/gas.py b/src/ethereum/forks/amsterdam/vm/gas.py index ec1f9890925..338e72d1c2b 100644 --- a/src/ethereum/forks/amsterdam/vm/gas.py +++ b/src/ethereum/forks/amsterdam/vm/gas.py @@ -764,10 +764,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> ExecutionGas: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return ExecutionGas(total_gas_cost) - except ValueError as e: - raise OutOfGasError from e + return ExecutionGas(total_gas_cost) def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/arrow_glacier/vm/gas.py b/src/ethereum/forks/arrow_glacier/vm/gas.py index 55d32b00ef2..14a222fc3ef 100644 --- a/src/ethereum/forks/arrow_glacier/vm/gas.py +++ b/src/ethereum/forks/arrow_glacier/vm/gas.py @@ -238,10 +238,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/berlin/vm/gas.py b/src/ethereum/forks/berlin/vm/gas.py index 0672024c06f..6e896fb9b32 100644 --- a/src/ethereum/forks/berlin/vm/gas.py +++ b/src/ethereum/forks/berlin/vm/gas.py @@ -238,10 +238,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/bpo1/vm/gas.py b/src/ethereum/forks/bpo1/vm/gas.py index 8fa8f640164..78b0daab6ed 100644 --- a/src/ethereum/forks/bpo1/vm/gas.py +++ b/src/ethereum/forks/bpo1/vm/gas.py @@ -271,10 +271,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/bpo2/vm/gas.py b/src/ethereum/forks/bpo2/vm/gas.py index eea2025a13e..823f9562112 100644 --- a/src/ethereum/forks/bpo2/vm/gas.py +++ b/src/ethereum/forks/bpo2/vm/gas.py @@ -271,10 +271,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/bpo3/vm/gas.py b/src/ethereum/forks/bpo3/vm/gas.py index ee928c641b0..b66c5844886 100644 --- a/src/ethereum/forks/bpo3/vm/gas.py +++ b/src/ethereum/forks/bpo3/vm/gas.py @@ -271,10 +271,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/bpo4/vm/gas.py b/src/ethereum/forks/bpo4/vm/gas.py index ae597533d56..dcac2f712e5 100644 --- a/src/ethereum/forks/bpo4/vm/gas.py +++ b/src/ethereum/forks/bpo4/vm/gas.py @@ -271,10 +271,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/bpo5/vm/gas.py b/src/ethereum/forks/bpo5/vm/gas.py index 3f569aaea87..021b4086701 100644 --- a/src/ethereum/forks/bpo5/vm/gas.py +++ b/src/ethereum/forks/bpo5/vm/gas.py @@ -271,10 +271,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/byzantium/vm/gas.py b/src/ethereum/forks/byzantium/vm/gas.py index 306c015e4d0..03c2f147a16 100644 --- a/src/ethereum/forks/byzantium/vm/gas.py +++ b/src/ethereum/forks/byzantium/vm/gas.py @@ -230,10 +230,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/cancun/vm/gas.py b/src/ethereum/forks/cancun/vm/gas.py index b1032fd5603..d87bc6fac12 100644 --- a/src/ethereum/forks/cancun/vm/gas.py +++ b/src/ethereum/forks/cancun/vm/gas.py @@ -254,10 +254,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/constantinople/vm/gas.py b/src/ethereum/forks/constantinople/vm/gas.py index d220ea4a0c4..af22957bac1 100644 --- a/src/ethereum/forks/constantinople/vm/gas.py +++ b/src/ethereum/forks/constantinople/vm/gas.py @@ -234,10 +234,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/dao_fork/vm/gas.py b/src/ethereum/forks/dao_fork/vm/gas.py index 6316f89874c..9df72836b25 100644 --- a/src/ethereum/forks/dao_fork/vm/gas.py +++ b/src/ethereum/forks/dao_fork/vm/gas.py @@ -223,10 +223,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/frontier/vm/gas.py b/src/ethereum/forks/frontier/vm/gas.py index 3592a10444d..ef16bf20d1e 100644 --- a/src/ethereum/forks/frontier/vm/gas.py +++ b/src/ethereum/forks/frontier/vm/gas.py @@ -221,10 +221,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/gray_glacier/vm/gas.py b/src/ethereum/forks/gray_glacier/vm/gas.py index 55d32b00ef2..14a222fc3ef 100644 --- a/src/ethereum/forks/gray_glacier/vm/gas.py +++ b/src/ethereum/forks/gray_glacier/vm/gas.py @@ -238,10 +238,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/homestead/vm/gas.py b/src/ethereum/forks/homestead/vm/gas.py index 6316f89874c..9df72836b25 100644 --- a/src/ethereum/forks/homestead/vm/gas.py +++ b/src/ethereum/forks/homestead/vm/gas.py @@ -223,10 +223,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/istanbul/vm/gas.py b/src/ethereum/forks/istanbul/vm/gas.py index a51fce6efab..5b2602dd24c 100644 --- a/src/ethereum/forks/istanbul/vm/gas.py +++ b/src/ethereum/forks/istanbul/vm/gas.py @@ -238,10 +238,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/london/vm/gas.py b/src/ethereum/forks/london/vm/gas.py index 55d32b00ef2..14a222fc3ef 100644 --- a/src/ethereum/forks/london/vm/gas.py +++ b/src/ethereum/forks/london/vm/gas.py @@ -238,10 +238,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/muir_glacier/vm/gas.py b/src/ethereum/forks/muir_glacier/vm/gas.py index a51fce6efab..5b2602dd24c 100644 --- a/src/ethereum/forks/muir_glacier/vm/gas.py +++ b/src/ethereum/forks/muir_glacier/vm/gas.py @@ -238,10 +238,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/osaka/vm/gas.py b/src/ethereum/forks/osaka/vm/gas.py index e87fa24414c..0eb791a73f9 100644 --- a/src/ethereum/forks/osaka/vm/gas.py +++ b/src/ethereum/forks/osaka/vm/gas.py @@ -271,10 +271,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/paris/vm/gas.py b/src/ethereum/forks/paris/vm/gas.py index e82c261fa48..925417fc0c7 100644 --- a/src/ethereum/forks/paris/vm/gas.py +++ b/src/ethereum/forks/paris/vm/gas.py @@ -238,10 +238,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/prague/vm/gas.py b/src/ethereum/forks/prague/vm/gas.py index 8ca45a3f612..8ef57522b68 100644 --- a/src/ethereum/forks/prague/vm/gas.py +++ b/src/ethereum/forks/prague/vm/gas.py @@ -265,10 +265,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/shanghai/vm/gas.py b/src/ethereum/forks/shanghai/vm/gas.py index 58915d8ba01..396c7cc84d5 100644 --- a/src/ethereum/forks/shanghai/vm/gas.py +++ b/src/ethereum/forks/shanghai/vm/gas.py @@ -240,10 +240,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/spurious_dragon/vm/gas.py b/src/ethereum/forks/spurious_dragon/vm/gas.py index 4ec2482bfe3..e74f341f80e 100644 --- a/src/ethereum/forks/spurious_dragon/vm/gas.py +++ b/src/ethereum/forks/spurious_dragon/vm/gas.py @@ -223,10 +223,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/tangerine_whistle/vm/gas.py b/src/ethereum/forks/tangerine_whistle/vm/gas.py index c6a3a31916c..f04e2475842 100644 --- a/src/ethereum/forks/tangerine_whistle/vm/gas.py +++ b/src/ethereum/forks/tangerine_whistle/vm/gas.py @@ -223,10 +223,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( From ecb68f21a7d28db101c769878184f6facb319da1 Mon Sep 17 00:00:00 2001 From: CPerezz <37264926+CPerezz@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:22:18 +0200 Subject: [PATCH 22/28] fix(test-consume): run and check nethtest block tests in consume direct (#3674) `consume direct --bin nethtest` passed every block test without running it. Since NethermindEth/nethermind#10211, nethtest matches `--filter` against the fixture key after `.py::`, so the full key EELS passed matched nothing; and the result was read from the exit code alone, which nethtest leaves at 0 when a test fails. Filter on the part after `.py::`, and read the verdict from the JSON array on stdout, failing when it is empty. Fixes #3609. --- .../client_clis/clis/nethermind.py | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/testing/src/execution_testing/client_clis/clis/nethermind.py b/packages/testing/src/execution_testing/client_clis/clis/nethermind.py index c393536ba7b..f24d84913fd 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/nethermind.py +++ b/packages/testing/src/execution_testing/client_clis/clis/nethermind.py @@ -120,10 +120,11 @@ def _build_command_with_options( assert fixture_name, "Fixture name must be provided for nethtest." command = [str(self.binary)] if fixture_format is BlockchainFixture: + # nethtest matches the filter against the key after `.py::`. command += [ "--blockTest", "--filter", - f"{re.escape(fixture_name)}", + re.escape(fixture_name.split(".py::", 1)[-1]) + "$", ] elif fixture_format is StateFixture: # TODO: consider using `--filter` here to readily access traces @@ -245,7 +246,6 @@ def consume_blockchain_test( ) -> None: """Execute the the fixture at `fixture_path` via `nethtest`.""" del fixture_path - del fixture_name result = subprocess.run( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) @@ -262,6 +262,27 @@ def consume_blockchain_test( f"{' '.join(command)}" ) + # nethtest exits 0 even when a test fails; the verdict is in the JSON + # array on stdout, and an empty one means the filter matched nothing. + try: + results = json.loads(result.stdout) + except json.JSONDecodeError as e: + raise Exception( + f"Failed to parse JSON output on stdout from nethtest:\n" + f"{result.stdout}" + ) from e + if not isinstance(results, list) or not results: + raise Exception( + f"nethtest ran no test matching '{fixture_name}':\n" + f"{result.stdout}\n{result.stderr}" + ) + failures = [r for r in results if not r["pass"]] + if failures: + raise Exception( + "Blockchain test failed:\n" + + "\n".join(f"{r['name']}: {r.get('error')}" for r in failures) + ) + def consume_fixture( self, fixture_format: FixtureFormat, From 05fec9ac6d1c1d1d8ce2379b28e528fa0106e395 Mon Sep 17 00:00:00 2001 From: CPerezz <37264926+CPerezz@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:03:32 +0200 Subject: [PATCH 23/28] fix(test-fill): fail collection of tests without a spec fixture (#3672) A test directory holding a pytest function that requests no spec fixture crashed `fill` with `INTERNALERROR> ValueError: No spec type format found in the test item.`: the filler's `pytest_collection_modifyitems` reached the item before the existing check in `pytest_runtest_call` could run. Move that check to a `tryfirst` `pytest_generate_tests`, so the module fails collection with a message naming the test, in a single process and under xdist alike. Fixes #3608. --- .../filler/tests/test_error_messages.py | 66 +++++++++++++++++++ .../plugins/shared/execute_fill.py | 27 +++++--- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_error_messages.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_error_messages.py index 92798394af0..abbeee78dad 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_error_messages.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_error_messages.py @@ -3,6 +3,8 @@ import textwrap from typing import Any +import pytest + invalid_fee_payment_test_module = textwrap.dedent( """\ from execution_testing import Transaction @@ -68,3 +70,67 @@ def test_fill_reports_conflicting_fee_fields( if pytestconfig.getoption("capture") == "no": with capsys.disabled(): print(error_line) + + +non_spec_test_module = textwrap.dedent( + """\ + from execution_testing import Transaction + + + def test_fillable(state_test, pre) -> None: + tx = Transaction(to=0, sender=pre.fund_eoa()) + state_test(pre=pre, post={}, tx=tx) + + + def test_plain_helper_check() -> None: + assert True + """ +) + + +@pytest.mark.parametrize("workers", ["0", "2"]) +def test_fill_rejects_test_without_spec_fixture( + pytester: Any, capsys: Any, workers: str +) -> None: + """ + Test that fill fails collection, naming a test without a spec fixture. + + The filler's collection hook used to crash on such an item with an + `INTERNALERROR`; under xdist no later hook can report it either. + """ + tests_dir = pytester.mkdir("tests") + berlin_tests_dir = tests_dir / "berlin" + berlin_tests_dir.mkdir() + module_dir = berlin_tests_dir / "non_spec_module" + module_dir.mkdir() + test_module = module_dir / "test_non_spec.py" + test_module.write_text(non_spec_test_module) + + pytester.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + + module_path = str(test_module.relative_to(pytester.path)) + result = pytester.runpytest_subprocess( + "-c", + "pytest-fill.ini", + "--fork", + "Berlin", + "-n", + workers, + "--no-html", + "--output=stdout", + module_path, + ) + capsys.readouterr() + + output = "\n".join(result.outlines + result.errlines) + assert "INTERNALERROR" not in output + assert result.ret not in ( + pytest.ExitCode.OK, + pytest.ExitCode.INTERNAL_ERROR, + ) + assert ( + f"{module_path}::test_plain_helper_check requests none of the spec " + "fixtures" + ) in output diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/shared/execute_fill.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/shared/execute_fill.py index a3bd1e20d0d..0cc646a281a 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/shared/execute_fill.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/shared/execute_fill.py @@ -316,6 +316,25 @@ def pytest_make_parametrize_id( SPEC_TYPES_PARAMETERS: List[str] = list(BaseTest.spec_types.keys()) +@pytest.hookimpl(tryfirst=True) +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """ + Fail collection of a test that requests no spec type fixture. + + Every test under a test directory must fill or execute a spec. Failing + at collection reports the test by name in every process, xdist workers + included; later hooks cannot handle an item without a spec type. + """ + if not any(p in metafunc.fixturenames for p in SPEC_TYPES_PARAMETERS): + pytest.fail( + f"{metafunc.definition.nodeid} requests none of the spec " + f"fixtures ({', '.join(SPEC_TYPES_PARAMETERS)}). Every test " + "collected from a test directory must request one; move helper " + "checks into a module whose name does not start with 'test_'.", + pytrace=False, + ) + + def pytest_runtest_call(item: pytest.Item) -> None: """Pytest hook called in the context of test execution.""" if isinstance(item, EIPSpecTestItem): @@ -337,14 +356,6 @@ def __init__(self, message: str): "blockchain test; not both." ) - # Check that the test defines either test type as parameter. - if not any(i for i in item.funcargs if i in SPEC_TYPES_PARAMETERS): - pytest.fail( - "Test must define either one of the following parameters to " - + "properly generate a test: " - + ", ".join(SPEC_TYPES_PARAMETERS) - ) - # Global `sender` fixture that can be overridden by tests. @pytest.fixture From 56dbe51551158dcf41ba819392e6d575cfb6f011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 30 Sep 2026 01:37:45 +0200 Subject: [PATCH 24/28] feat(tests): cover EIP-2780 preparation halt after repeated and self-sponsored authorizations (#3671) A client that undoes applied authorizations one by one, rather than restoring a snapshot, can get the undo wrong when the halt follows an authority authorized twice or a self-sponsored authorization, and still pass every existing rollback test, which only use distinct third-party authorities. Add test_repeated_authority_rolled_back and test_self_sponsored_authorization_rolled_back, each crossed with a starved recipient NEW_ACCOUNT or delegation-access charge and a succeeds control. Share the repeated-authority setup via build_repeated_authority and let authorization_transaction_cost take a recipient type. --- .../helpers.py | 76 ++++++- .../test_authorization_charges.py | 30 +-- .../test_authorization_oog.py | 214 ++++++++++++++++++ 3 files changed, 284 insertions(+), 36 deletions(-) diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/helpers.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/helpers.py index 6223ccb3fd2..ecf3d4ee657 100644 --- a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/helpers.py +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/helpers.py @@ -186,30 +186,86 @@ def build_authorization( ) +@dataclass +class RepeatedAuthorityScenario: + """ + Two authorizations applied in sequence to one third-party authority. + + ``applied_account`` is the authority's post-state once both have + applied; ``original_account`` is its pre-transaction state (``None`` + when the leaf does not yet exist). + """ + + authority: EOA + authorizations: list[AuthorizationTuple] + applied_account: Account + original_account: Account | None + + +def build_repeated_authority( + pre: Alloc, first_action: AuthorizationAction +) -> RepeatedAuthorityScenario: + """ + Build ``first_action`` on a fresh authority, followed by a second + authorization re-pointing it with the next nonce. + + The first authorization already wrote the authority's leaf and either + set a delegation in this transaction or found one from before it, so + the second one is neither a first write nor a net-new indicator. + """ + first = build_authorization(pre, first_action) + new_target = pre.deploy_contract(code=Op.STOP) + applied_nonce = int(first.applied_account.nonce) + second = AuthorizationTuple( + address=new_target, + nonce=applied_nonce, + signer=first.authority, + creates_account=False, + writes_delegation=False, + first_write=False, + ) + return RepeatedAuthorityScenario( + authority=first.authority, + authorizations=[first.authorization, second], + applied_account=Account( + nonce=applied_nonce + 1, + balance=int(first.applied_account.balance), + code=Spec7702.delegation_designation(new_target), + ), + original_account=first.original_account, + ) + + def authorization_transaction_cost( fork: Fork, authorization_list: list[AuthorizationTuple], *, access_list: list[AccessList] | None = None, + recipient_type: RecipientType = RecipientType.CONTRACT, + sends_value: bool = False, ) -> int: """ - Return the exact gas a value-free type-4 transaction to a plain - contract recipient consumes for the given authorizations. - - The recipient is a ``CONTRACT`` that runs no code, so no recipient - top-frame charge applies and the cost reduces to the intrinsic plus - the authorizations' own top-frame execution and state charges. Each - authorization's charge is driven by its ``creates_account`` / - ``writes_delegation`` / ``first_write`` annotations. + Return the exact gas a type-4 transaction consumes for the given + authorizations and recipient. + + By default the recipient is a ``CONTRACT`` that runs no code, so no + recipient top-frame charge applies and the cost reduces to the + intrinsic plus the authorizations' own top-frame execution and state + charges. Each authorization's charge is driven by its + ``creates_account`` / ``writes_delegation`` / ``first_write`` + annotations; another ``recipient_type`` adds that recipient's own + top-frame charge. """ intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( access_list=access_list, - recipient_type=RecipientType.CONTRACT, + recipient_type=recipient_type, + sends_value=sends_value, authorization_list_or_count=authorization_list, return_cost_deducted_prior_execution=True, ) return intrinsic_gas + fork.transaction_top_frame_gas_calculator()( - recipient_type=RecipientType.CONTRACT, + recipient_type=recipient_type, + sends_value=sends_value, authorizations=authorization_list, ) diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_charges.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_charges.py index f71b6e29e2b..c68324abec5 100644 --- a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_charges.py +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_charges.py @@ -20,6 +20,7 @@ AuthorizationAction, authorization_transaction_cost, build_authorization, + build_repeated_authority, ) from .spec import ref_spec_2780 @@ -150,32 +151,9 @@ def test_multi_authorization_intra_tx_state( "create_then_modify": AuthorizationAction.CREATES_ACCOUNT, "clear_then_set": AuthorizationAction.CLEARS_DELEGATION, }[scenario] - leg = build_authorization(pre, first_action) - new_target = pre.deploy_contract(code=Op.STOP) - - # The second authorization runs on the same authority right - # after the first, using the next nonce. The first already - # wrote the authority's leaf (no second ``ACCOUNT_WRITE``) and - # either set a delegation in this transaction or found one from - # before it, so the re-point writes no net-new indicator and - # pays no ``AUTH_BASE``. - applied_nonce = int(leg.applied_account.nonce) - second_auth = AuthorizationTuple( - address=new_target, - nonce=applied_nonce, - signer=leg.authority, - creates_account=False, - writes_delegation=False, - first_write=False, - ) - authorization_list = [leg.authorization, second_auth] - expected_authorities = { - leg.authority: Account( - nonce=applied_nonce + 1, - balance=int(leg.applied_account.balance), - code=Spec7702.delegation_designation(new_target), - ), - } + repeated = build_repeated_authority(pre, first_action) + authorization_list = repeated.authorizations + expected_authorities = {repeated.authority: repeated.applied_account} total_gas_cost = authorization_transaction_cost(fork, authorization_list) diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_oog.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_oog.py index df6116789e7..1db4645929e 100644 --- a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_oog.py +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/test_authorization_oog.py @@ -45,6 +45,7 @@ BlockAccessListExpectation, BlockchainTestFiller, Bytecode, + Bytes, Environment, Fork, Header, @@ -60,9 +61,13 @@ from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 from .helpers import ( EOA_INITIAL_BALANCE, + NULL_ADDRESS, AuthorizationAction, AuthorizationScenario, + authorization_transaction_cost, build_authorization, + build_repeated_authority, + setup_target, ) from .spec import ref_spec_2780 @@ -565,6 +570,215 @@ def test_recipient_charge_oog_rolls_back_delegations( ) +def _starved_recipient_value(recipient_type: RecipientType) -> int: + """Return the value that makes the recipient's charge ``NEW_ACCOUNT``.""" + return int(recipient_type == RecipientType.EMPTY_ACCOUNT) + + +def _starved_recipient_post( + recipient: Address, recipient_type: RecipientType, succeeds: bool +) -> dict[Address, Account | None]: + """ + Return the recipient's post-state: the value lands only when the + charge is covered. A delegated recipient is untouched either way. + """ + if recipient_type != RecipientType.EMPTY_ACCOUNT: + return {} + value = _starved_recipient_value(recipient_type) + return {recipient: Account(balance=value) if succeeds else None} + + +@EIPChecklist.GasCostChanges.Test.OutOfGas() +@pytest.mark.parametrize( + "succeeds", + [ + pytest.param(False, id="fails"), + pytest.param(True, id="succeeds"), + ], +) +@pytest.mark.parametrize( + "recipient_type", + [ + pytest.param(RecipientType.EMPTY_ACCOUNT, id="new_account"), + pytest.param(RecipientType.DELEGATION_7702, id="delegation_access"), + ], +) +@pytest.mark.parametrize( + "first_action", + [ + AuthorizationAction.SETS_NEW_DELEGATION, + AuthorizationAction.CREATES_ACCOUNT, + AuthorizationAction.CLEARS_DELEGATION, + ], + ids=lambda a: a.name.lower(), +) +def test_repeated_authority_rolled_back( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + first_action: AuthorizationAction, + recipient_type: RecipientType, + succeeds: bool, +) -> None: + """ + A recipient top-frame charge running out of gas rolls back an + authority that was authorized twice, to its state before the first + authorization. + + Both authorizations apply, then the recipient's charge is starved by + one gas. The rollback must undo the nonce bump of each applied + authorization and restore the code from before the first one, so the + authority ends exactly as it started: absent when the first + authorization created it. + + The ``succeeds`` control restores the one starved gas, and both + authorizations stick. + """ + sender = pre.fund_eoa() + repeated = build_repeated_authority(pre, first_action) + recipient = setup_target(pre, recipient_type, sender) + value = _starved_recipient_value(recipient_type) + + # Both authorizations apply, then the recipient's top-frame charge is + # starved by one gas (or, with ``succeeds``, covered exactly). + gas_limit = authorization_transaction_cost( + fork, + repeated.authorizations, + recipient_type=recipient_type, + sends_value=bool(value), + ) + if not succeeds: + gas_limit -= 1 + + tx = Transaction( + sender=sender, + to=recipient, + value=value, + authorization_list=repeated.authorizations, + gas_limit=gas_limit, + expected_receipt=TransactionReceipt( + cumulative_gas_used=gas_limit, + ), + ) + + state_test( + pre=pre, + tx=tx, + post={ + repeated.authority: ( + repeated.applied_account + if succeeds + else repeated.original_account + ), + **_starved_recipient_post(recipient, recipient_type, succeeds), + }, + ) + + +@EIPChecklist.GasCostChanges.Test.OutOfGas() +@pytest.mark.parametrize( + "succeeds", + [ + pytest.param(False, id="fails"), + pytest.param(True, id="succeeds"), + ], +) +@pytest.mark.parametrize( + "recipient_type", + [ + pytest.param(RecipientType.EMPTY_ACCOUNT, id="new_account"), + pytest.param(RecipientType.DELEGATION_7702, id="delegation_access"), + ], +) +@pytest.mark.parametrize("sender_action", ["sets", "clears"]) +def test_self_sponsored_authorization_rolled_back( + fork: Fork, + pre: Alloc, + state_test: StateTestFiller, + sender_action: str, + recipient_type: RecipientType, + succeeds: bool, +) -> None: + """ + A recipient top-frame charge running out of gas rolls back a + self-sponsored authorization, but not the sender's own transaction + nonce bump. + + The sender sets or clears its own delegation, then the recipient's + charge is starved by one gas. The authorization's nonce bump and + code change are undone; the nonce bump applied at inclusion, before + the preparation snapshot, stays. + + The ``succeeds`` control restores the one starved gas, and the + authorization sticks. + """ + original_code = Bytes() + original_target = None + if sender_action == "clears": + original_target = pre.deploy_contract(code=Op.STOP) + original_code = Spec7702.delegation_designation(original_target) + sender = pre.fund_eoa(delegation=original_target) + recipient = setup_target(pre, recipient_type, sender) + value = _starved_recipient_value(recipient_type) + + tx_nonce = int(sender.nonce) + if sender_action == "sets": + delegation_target = pre.deploy_contract(code=Op.STOP) + applied_code = Spec7702.delegation_designation(delegation_target) + authorization = AuthorizationTuple( + address=delegation_target, + # The sender's nonce is bumped at inclusion, before the + # authorizations are processed. + nonce=tx_nonce + 1, + signer=sender, + first_write=False, + ) + else: + applied_code = Bytes() + authorization = AuthorizationTuple( + address=NULL_ADDRESS, + nonce=tx_nonce + 1, + signer=sender, + writes_delegation=False, + first_write=False, + ) + authorization_list = [authorization] + + gas_limit = authorization_transaction_cost( + fork, + authorization_list, + recipient_type=recipient_type, + sends_value=bool(value), + ) + if not succeeds: + gas_limit -= 1 + + tx = Transaction( + sender=sender, + nonce=tx_nonce, + to=recipient, + value=value, + authorization_list=authorization_list, + gas_limit=gas_limit, + expected_receipt=TransactionReceipt( + cumulative_gas_used=gas_limit, + ), + ) + + state_test( + pre=pre, + tx=tx, + post={ + sender: ( + Account(nonce=tx_nonce + 2, code=applied_code) + if succeeds + else Account(nonce=tx_nonce + 1, code=original_code) + ), + **_starved_recipient_post(recipient, recipient_type, succeeds), + }, + ) + + @EIPChecklist.GasCostChanges.Test.OutOfGas() @EIPChecklist.GasCostChanges.Test.GasUpdatesMeasurement() @pytest.mark.parametrize( From 404c242a8c32d27330faaf3f7b29023aebe9a615 Mon Sep 17 00:00:00 2001 From: Jevin Jojo Date: Wed, 30 Sep 2026 11:53:19 +0530 Subject: [PATCH 25/28] fix(test-clis): cast binary_path to string to prevent shutil.which crash on Windows (#3643) --- .../testing/src/execution_testing/client_clis/ethereum_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/testing/src/execution_testing/client_clis/ethereum_cli.py b/packages/testing/src/execution_testing/client_clis/ethereum_cli.py index c2ca9d11d58..b0fd5699f50 100644 --- a/packages/testing/src/execution_testing/client_clis/ethereum_cli.py +++ b/packages/testing/src/execution_testing/client_clis/ethereum_cli.py @@ -239,7 +239,7 @@ def is_installed(cls, binary_path: Optional[Path] = None) -> bool: resolved_path = Path(os.path.expanduser(binary_path)).resolve() if resolved_path.exists(): binary_path = resolved_path - binary = shutil.which(binary_path) + binary = shutil.which(str(binary_path)) return binary is not None @classmethod From 949856f3c29014beff31c34500c08da725e9cf6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Wed, 30 Sep 2026 10:57:15 +0200 Subject: [PATCH 26/28] feat(tests): cover EIP-8037 partial-reservoir new-account charge and precompile OOG after spill (#3673) --- .../test_state_gas_reservoir.py | 131 ++++++++++++++++++ 1 file changed, 131 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 95dde796f39..dba5632786b 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 @@ -28,6 +28,7 @@ Fork, Header, Op, + RecipientType, StateTestFiller, Storage, Transaction, @@ -717,6 +718,136 @@ def test_create_tx_reservoir( state_test(pre=pre, post={}, tx=tx) +@pytest.mark.parametrize( + "tx_kind", + [ + pytest.param("create", id="create_tx"), + pytest.param("value_transfer", id="value_to_empty_account"), + ], +) +@pytest.mark.valid_from("EIP8037") +def test_top_frame_new_account_charge_split_across_reservoir( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + tx_kind: str, +) -> None: + """ + Test a top-frame new-account charge that the reservoir covers only + in part. + + The reservoir holds half of the charge and the other half spills + into `gas_left`. The sender is billed the full charge. + """ + sender = pre.fund_eoa() + + if tx_kind == "create": + init_code = Op.STOP + top_frame_state_gas = fork.transaction_top_frame_state_gas( + contract_creation=True + ) + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()( + calldata=init_code, + contract_creation=True, + return_cost_deducted_prior_execution=True, + ) + to = None + data: Bytecode = init_code + value = 0 + target = compute_create_address(address=sender, nonce=0) + expected_target = Account(nonce=1) + elif tx_kind == "value_transfer": + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, recipient_type=RecipientType.EMPTY_ACCOUNT + ) + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + return_cost_deducted_prior_execution=True, + ) + target = pre.fund_eoa(amount=0) + to = target + data = Bytecode() + value = 1 + expected_target = Account(balance=value) + else: + raise ValueError(f"Unknown tx_kind: {tx_kind}") + + tx = Transaction( + to=to, + data=data, + value=value, + state_gas_reservoir=top_frame_state_gas // 2, + sender=sender, + # The whole charge is billed, whichever pool paid for it. + expected_receipt=TransactionReceipt( + cumulative_gas_used=intrinsic_cost + top_frame_state_gas + ), + ) + + state_test(pre=pre, post={target: expected_target}, tx=tx) + + +@pytest.mark.parametrize( + "precompile_gas_covered", + [ + pytest.param(True, id="precompile_gas_covered"), + pytest.param(False, id="one_gas_short"), + ], +) +@pytest.mark.valid_from("EIP8037") +def test_top_frame_new_account_spill_with_precompile_out_of_gas( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + precompile_gas_covered: bool, +) -> None: + """ + Test a value transfer to an empty precompile with no reservoir, so + the top-frame new-account charge spills into `gas_left` and the + precompile runs on what is left. + + With the precompile gas covered, the transfer succeeds. One gas + short, the precompile runs out of gas and the transaction burns its + whole gas limit, including the spilled charge. + """ + gas_limit_cap = fork.transaction_gas_limit_cap() + assert gas_limit_cap is not None + identity = Address(0x04) + value = 1 + precompile_gas = fork.gas_costs().PRECOMPILE_IDENTITY_BASE + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, recipient_type=RecipientType.EMPTY_ACCOUNT + ) + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()( + sends_value=True, + recipient_type=RecipientType.PRECOMPILE, + return_cost_deducted_prior_execution=True, + ) + exact_gas = intrinsic_cost + top_frame_state_gas + precompile_gas + + if precompile_gas_covered: + gas_limit = gas_limit_cap + expected_gas_used = exact_gas + expected_precompile: Account | None = Account(balance=value) + else: + gas_limit = exact_gas - 1 + expected_gas_used = gas_limit + expected_precompile = None + + tx = Transaction( + to=identity, + value=value, + gas_limit=gas_limit, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used + ), + ) + + state_test(pre=pre, post={identity: expected_precompile}, tx=tx) + + @pytest.mark.parametrize( "failure_mode", [ From 46f052c77b503227b4a7577c92912d33dc6290b7 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:27:19 +0000 Subject: [PATCH 27/28] resolve the upstream merge conflicts Keep the fork's Monad-only release config and reduce the ported static conftest to the Monad marker now that upstream dropped its skip list. Co-Authored-By: Claude --- .github/configs/feature.yaml | 34 ------------- .github/workflows/release_fixtures.yaml | 26 ---------- tests/ported_static/conftest.py | 68 +------------------------ 3 files changed, 1 insertion(+), 127 deletions(-) diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index 744d030cd62..ecd72f554df 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -1,40 +1,6 @@ -<<<<<<< HEAD monad: -||||||| c335bc4e9 -# Release feature definitions consumed by the `release_fixtures` workflow. -# -# Top-level keys are feature names used verbatim in the release tag -# (`tests-@vX.Y.Z`), except `tests`, which tags as `tests@vX.Y.Z`. -# Any `-devnet` input resolves to the shared `devnet` entry but keeps -# its name in the tag; the devnet number lives in the version (X), not the -# feature name, so this file needs no edits for new devnets. -# `tests` is also what the scheduled nightly run of the `release_fixtures` -# workflow fills: mainnet forks only, so the rotating nightly artifact is a -# release-ready rehearsal of the next tests@ release. -tests: -======= -# Release feature definitions consumed by the `release_fixtures` workflow. -# -# Top-level keys are feature names used verbatim in the release tag -# (`tests-@vX.Y.Z`), except `tests`, which tags as `tests@vX.Y.Z`. -# Any `-devnet` input resolves to the shared `devnet` entry but keeps -# its name in the tag; the devnet number lives in the version (X), not the -# feature name, so this file needs no edits for new devnets. -# `tests` is the stable release clients gate their master branches on; its -# `--until` tracks the fork those branches implement (the policy is stated -# in docs/running_tests/releases.md). `tests` is also what the scheduled -# nightly run of the `release_fixtures` workflow fills, so the rotating -# nightly artifact is a release-ready rehearsal of the next tests@ release. -tests: ->>>>>>> upstream/forks/amsterdam evm-type: eels -<<<<<<< HEAD fill-params: -m blockchain_test --from=MONAD_EIGHT --until=MONAD_TEN --chain-id=143 -k "not invalid_header" -||||||| c335bc4e9 - fill-params: --until=BPO4 --generate-all-formats -======= - fill-params: --until=Amsterdam --generate-all-formats ->>>>>>> upstream/forks/amsterdam monad_runloop: evm-type: eels diff --git a/.github/workflows/release_fixtures.yaml b/.github/workflows/release_fixtures.yaml index 2bc88c418ca..70b20109722 100644 --- a/.github/workflows/release_fixtures.yaml +++ b/.github/workflows/release_fixtures.yaml @@ -2,36 +2,10 @@ name: Create Fixture Release run-name: ${{ github.event_name == 'schedule' && 'Nightly Fill' || format('Create Fixture Release {0}@{1}{2}', inputs.feature, inputs.version, (inputs.cached || inputs.commit != '') && ' (cached)' || '') }} -<<<<<<< HEAD # Upstream also runs this on a nightly schedule to rehearse a mainnet # `tests` release and to feed the `cached` release path. This fork releases # the `monad` and `monad_runloop` features only, so the schedule is dropped # and the `cached` / `commit` inputs fail fast on their `tests`-only guard. -||||||| c335bc4e9 -# Scheduled runs fill the mainnet `tests` feature (all tests, all fixture -# formats, up to the latest mainnet fork -- no dev forks) through the exact -# release pipeline, but skip the `release` job, so no tag or draft release -# is created: a rotating, always-available artifact of the mainnet -# fixtures. The cron fires at 02:00 UTC: the self-hosted runners are past -# the EU/US daytime peaks and results are ready before the EU morning. -# -# A manual `tests` release can reuse the newest of those artifacts -# instead of refilling via the `cached` checkbox: `build` and `combine` -# are skipped and the `release` job drafts from the nightly's tarball, -# tagged at the commit the nightly built. Runs in minutes. -======= -# Scheduled runs fill the `tests` feature (all tests, all fixture formats, -# per its fill-params in .github/configs/feature.yaml) through the exact -# release pipeline, but skip the `release` job, so no tag or draft release -# is created: a rotating, always-available artifact of the `tests` -# fixtures. The cron fires at 02:00 UTC: the self-hosted runners are past -# the EU/US daytime peaks and results are ready before the EU morning. -# -# A manual `tests` release can reuse the newest of those artifacts -# instead of refilling via the `cached` checkbox: `build` and `combine` -# are skipped and the `release` job drafts from the nightly's tarball, -# tagged at the commit the nightly built. Runs in minutes. ->>>>>>> upstream/forks/amsterdam on: workflow_dispatch: inputs: diff --git a/tests/ported_static/conftest.py b/tests/ported_static/conftest.py index d28aa4b8cd4..292e10527dc 100644 --- a/tests/ported_static/conftest.py +++ b/tests/ported_static/conftest.py @@ -1,37 +1,6 @@ -""" -Conftest for ported static tests. - -Temporarily skip ported static tests that fail on Amsterdam and its -descendant forks due to EIP-8037's two-dimensional gas model. The gas -limits in these ported static test cases have not yet been updated to -account for state gas. - -TODO: Update gas limits in the 3452 failing ported static test cases and -remove this skip list. -""" - -from pathlib import Path +"""Pytest configuration for ported static tests.""" 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" -_AMSTERDAM_SKIP_CASES: frozenset[str] = frozenset( - line.strip() - for line in _SKIP_LIST_PATH.read_text().splitlines() - if line.strip() and not line.lstrip().startswith("#") -) - - -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 pytest_generate_tests(metafunc: pytest.Metafunc) -> None: @@ -39,38 +8,3 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: metafunc.definition.add_marker( pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) ) - - -def _normalize_nodeid(nodeid: str, tokens: tuple[str, ...]) -> str: - """Strip pytest fixture-format suffixes to match the skip list format.""" - for token in tokens: - nodeid = nodeid.replace(token, "") - return nodeid - - -def pytest_collection_modifyitems( - config: pytest.Config, items: list[pytest.Item] -) -> None: - """Skip ported static test cases listed in amsterdam_skip_list.txt.""" - 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 - callspec = getattr(item, "callspec", None) - fork = callspec.params.get("fork") if callspec else None - if fork is None or not fork >= Amsterdam: - continue - # The skip list is written against fork_Amsterdam, but the - # 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, tokens).replace( - f"fork_{fork.name()}", "fork_Amsterdam" - ) - for skip_case in _AMSTERDAM_SKIP_CASES: - if skip_case in normalized: - item.add_marker(skip_marker) - break From 191e62328f5cf48ee07bdd0d104f7300d583d74c Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:36:43 +0000 Subject: [PATCH 28/28] propagate the upstream fork changes into the Monad forks Record block-level storage wipes; drop MONAD_EIGHT's unreachable except. Co-Authored-By: Claude --- .../forks/monad_eight/state_tracker.py | 27 ++++++++++++++++++- src/ethereum/forks/monad_eight/vm/gas.py | 5 +--- .../forks/monad_next/state_tracker.py | 27 ++++++++++++++++++- .../forks/monad_nine/state_tracker.py | 27 ++++++++++++++++++- src/ethereum/forks/monad_ten/state_tracker.py | 27 ++++++++++++++++++- 5 files changed, 105 insertions(+), 8 deletions(-) diff --git a/src/ethereum/forks/monad_eight/state_tracker.py b/src/ethereum/forks/monad_eight/state_tracker.py index d9f2269f77d..1a0905154a4 100644 --- a/src/ethereum/forks/monad_eight/state_tracker.py +++ b/src/ethereum/forks/monad_eight/state_tracker.py @@ -43,6 +43,12 @@ class BlockState: Accumulate committed transaction-level changes across a block. Read chain: block writes -> pre_state. + + ``storage_clears`` records addresses whose pre-existing storage + was wiped earlier in the block, so later reads must not fall back + to ``pre_state``. Contract creation over a storage-only account + and deletion of an empty account that still holds storage both + wipe storage. """ pre_state: PreState @@ -53,6 +59,7 @@ class BlockState: default_factory=dict ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) + storage_clears: Set[Address] = field(default_factory=set) # Monad: per-block-number set of EOAs that sent or were authorized in # that block. Carried across blocks via t8n; used by the reserve # balance exception window (RESERVE_BALANCE_DELAY_BLOCKS lookback). @@ -79,6 +86,7 @@ class TransactionState: ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) created_accounts: Set[Address] = field(default_factory=set) + storage_clears: Set[Address] = field(default_factory=set) transient_storage: Dict[Tuple[Address, Bytes32], U256] = field( default_factory=dict ) @@ -192,9 +200,13 @@ def get_storage( if address in tx_state.storage_writes: if key in tx_state.storage_writes[address]: return tx_state.storage_writes[address][key] + if address in tx_state.storage_clears: + return U256(0) if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -222,6 +234,8 @@ def get_storage_original( if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -420,7 +434,9 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ Completely remove the storage at ``address``. - Only supports same transaction destruction. + Drop this transaction's writes and record the address in + ``storage_clears`` so that reads no longer fall back to earlier + block writes or ``pre_state``. Parameters ---------- @@ -432,6 +448,7 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ if address in tx_state.storage_writes: del tx_state.storage_writes[address] + tx_state.storage_clears.add(address) def mark_account_created(tx_state: TransactionState, address: Address) -> None: @@ -653,6 +670,7 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: }, code_writes=dict(tx_state.code_writes), created_accounts=tx_state.created_accounts, + storage_clears=set(tx_state.storage_clears), transient_storage=dict(tx_state.transient_storage), ) @@ -674,6 +692,7 @@ def restore_tx_state( tx_state.account_writes = snapshot.account_writes tx_state.storage_writes = snapshot.storage_writes tx_state.code_writes = snapshot.code_writes + tx_state.storage_clears = snapshot.storage_clears tx_state.transient_storage = snapshot.transient_storage @@ -695,6 +714,10 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: for address, account in tx_state.account_writes.items(): block.account_writes[address] = account + for address in tx_state.storage_clears: + block.storage_clears.add(address) + block.storage_writes.pop(address, None) + for address, slots in tx_state.storage_writes.items(): if address not in block.storage_writes: block.storage_writes[address] = {} @@ -706,6 +729,7 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: tx_state.storage_writes.clear() tx_state.code_writes.clear() tx_state.created_accounts.clear() + tx_state.storage_clears.clear() tx_state.transient_storage.clear() @@ -728,6 +752,7 @@ def extract_block_diff(block_state: BlockState) -> BlockDiff: account_changes=block_state.account_writes, storage_changes=block_state.storage_writes, code_changes=block_state.code_writes, + storage_clears=block_state.storage_clears, ) diff --git a/src/ethereum/forks/monad_eight/vm/gas.py b/src/ethereum/forks/monad_eight/vm/gas.py index 82e0b6878c3..ab61760a4e0 100644 --- a/src/ethereum/forks/monad_eight/vm/gas.py +++ b/src/ethereum/forks/monad_eight/vm/gas.py @@ -267,10 +267,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: linear_cost = size_in_words * GasCosts.MEMORY_PER_WORD quadratic_cost = size_in_words ** Uint(2) // Uint(512) total_gas_cost = linear_cost + quadratic_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + return total_gas_cost def calculate_gas_extend_memory( diff --git a/src/ethereum/forks/monad_next/state_tracker.py b/src/ethereum/forks/monad_next/state_tracker.py index ead87e8e361..182dfe1b9f7 100644 --- a/src/ethereum/forks/monad_next/state_tracker.py +++ b/src/ethereum/forks/monad_next/state_tracker.py @@ -43,6 +43,12 @@ class BlockState: Accumulate committed transaction-level changes across a block. Read chain: block writes -> pre_state. + + ``storage_clears`` records addresses whose pre-existing storage + was wiped earlier in the block, so later reads must not fall back + to ``pre_state``. Contract creation over a storage-only account + and deletion of an empty account that still holds storage both + wipe storage. """ pre_state: PreState @@ -53,6 +59,7 @@ class BlockState: default_factory=dict ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) + storage_clears: Set[Address] = field(default_factory=set) # Monad: per-block-number set of EOAs that sent or were authorized in # that block. Carried across blocks via t8n; used by the reserve # balance exception window (RESERVE_BALANCE_DELAY_BLOCKS lookback). @@ -79,6 +86,7 @@ class TransactionState: ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) created_accounts: Set[Address] = field(default_factory=set) + storage_clears: Set[Address] = field(default_factory=set) transient_storage: Dict[Tuple[Address, Bytes32], U256] = field( default_factory=dict ) @@ -192,9 +200,13 @@ def get_storage( if address in tx_state.storage_writes: if key in tx_state.storage_writes[address]: return tx_state.storage_writes[address][key] + if address in tx_state.storage_clears: + return U256(0) if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -222,6 +234,8 @@ def get_storage_original( if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -420,7 +434,9 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ Completely remove the storage at ``address``. - Only supports same transaction destruction. + Drop this transaction's writes and record the address in + ``storage_clears`` so that reads no longer fall back to earlier + block writes or ``pre_state``. Parameters ---------- @@ -432,6 +448,7 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ if address in tx_state.storage_writes: del tx_state.storage_writes[address] + tx_state.storage_clears.add(address) def mark_account_created(tx_state: TransactionState, address: Address) -> None: @@ -653,6 +670,7 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: }, code_writes=dict(tx_state.code_writes), created_accounts=tx_state.created_accounts, + storage_clears=set(tx_state.storage_clears), transient_storage=dict(tx_state.transient_storage), ) @@ -674,6 +692,7 @@ def restore_tx_state( tx_state.account_writes = snapshot.account_writes tx_state.storage_writes = snapshot.storage_writes tx_state.code_writes = snapshot.code_writes + tx_state.storage_clears = snapshot.storage_clears tx_state.transient_storage = snapshot.transient_storage @@ -695,6 +714,10 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: for address, account in tx_state.account_writes.items(): block.account_writes[address] = account + for address in tx_state.storage_clears: + block.storage_clears.add(address) + block.storage_writes.pop(address, None) + for address, slots in tx_state.storage_writes.items(): if address not in block.storage_writes: block.storage_writes[address] = {} @@ -706,6 +729,7 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: tx_state.storage_writes.clear() tx_state.code_writes.clear() tx_state.created_accounts.clear() + tx_state.storage_clears.clear() tx_state.transient_storage.clear() @@ -728,6 +752,7 @@ def extract_block_diff(block_state: BlockState) -> BlockDiff: account_changes=block_state.account_writes, storage_changes=block_state.storage_writes, code_changes=block_state.code_writes, + storage_clears=block_state.storage_clears, ) diff --git a/src/ethereum/forks/monad_nine/state_tracker.py b/src/ethereum/forks/monad_nine/state_tracker.py index ead87e8e361..182dfe1b9f7 100644 --- a/src/ethereum/forks/monad_nine/state_tracker.py +++ b/src/ethereum/forks/monad_nine/state_tracker.py @@ -43,6 +43,12 @@ class BlockState: Accumulate committed transaction-level changes across a block. Read chain: block writes -> pre_state. + + ``storage_clears`` records addresses whose pre-existing storage + was wiped earlier in the block, so later reads must not fall back + to ``pre_state``. Contract creation over a storage-only account + and deletion of an empty account that still holds storage both + wipe storage. """ pre_state: PreState @@ -53,6 +59,7 @@ class BlockState: default_factory=dict ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) + storage_clears: Set[Address] = field(default_factory=set) # Monad: per-block-number set of EOAs that sent or were authorized in # that block. Carried across blocks via t8n; used by the reserve # balance exception window (RESERVE_BALANCE_DELAY_BLOCKS lookback). @@ -79,6 +86,7 @@ class TransactionState: ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) created_accounts: Set[Address] = field(default_factory=set) + storage_clears: Set[Address] = field(default_factory=set) transient_storage: Dict[Tuple[Address, Bytes32], U256] = field( default_factory=dict ) @@ -192,9 +200,13 @@ def get_storage( if address in tx_state.storage_writes: if key in tx_state.storage_writes[address]: return tx_state.storage_writes[address][key] + if address in tx_state.storage_clears: + return U256(0) if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -222,6 +234,8 @@ def get_storage_original( if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -420,7 +434,9 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ Completely remove the storage at ``address``. - Only supports same transaction destruction. + Drop this transaction's writes and record the address in + ``storage_clears`` so that reads no longer fall back to earlier + block writes or ``pre_state``. Parameters ---------- @@ -432,6 +448,7 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ if address in tx_state.storage_writes: del tx_state.storage_writes[address] + tx_state.storage_clears.add(address) def mark_account_created(tx_state: TransactionState, address: Address) -> None: @@ -653,6 +670,7 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: }, code_writes=dict(tx_state.code_writes), created_accounts=tx_state.created_accounts, + storage_clears=set(tx_state.storage_clears), transient_storage=dict(tx_state.transient_storage), ) @@ -674,6 +692,7 @@ def restore_tx_state( tx_state.account_writes = snapshot.account_writes tx_state.storage_writes = snapshot.storage_writes tx_state.code_writes = snapshot.code_writes + tx_state.storage_clears = snapshot.storage_clears tx_state.transient_storage = snapshot.transient_storage @@ -695,6 +714,10 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: for address, account in tx_state.account_writes.items(): block.account_writes[address] = account + for address in tx_state.storage_clears: + block.storage_clears.add(address) + block.storage_writes.pop(address, None) + for address, slots in tx_state.storage_writes.items(): if address not in block.storage_writes: block.storage_writes[address] = {} @@ -706,6 +729,7 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: tx_state.storage_writes.clear() tx_state.code_writes.clear() tx_state.created_accounts.clear() + tx_state.storage_clears.clear() tx_state.transient_storage.clear() @@ -728,6 +752,7 @@ def extract_block_diff(block_state: BlockState) -> BlockDiff: account_changes=block_state.account_writes, storage_changes=block_state.storage_writes, code_changes=block_state.code_writes, + storage_clears=block_state.storage_clears, ) diff --git a/src/ethereum/forks/monad_ten/state_tracker.py b/src/ethereum/forks/monad_ten/state_tracker.py index ead87e8e361..182dfe1b9f7 100644 --- a/src/ethereum/forks/monad_ten/state_tracker.py +++ b/src/ethereum/forks/monad_ten/state_tracker.py @@ -43,6 +43,12 @@ class BlockState: Accumulate committed transaction-level changes across a block. Read chain: block writes -> pre_state. + + ``storage_clears`` records addresses whose pre-existing storage + was wiped earlier in the block, so later reads must not fall back + to ``pre_state``. Contract creation over a storage-only account + and deletion of an empty account that still holds storage both + wipe storage. """ pre_state: PreState @@ -53,6 +59,7 @@ class BlockState: default_factory=dict ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) + storage_clears: Set[Address] = field(default_factory=set) # Monad: per-block-number set of EOAs that sent or were authorized in # that block. Carried across blocks via t8n; used by the reserve # balance exception window (RESERVE_BALANCE_DELAY_BLOCKS lookback). @@ -79,6 +86,7 @@ class TransactionState: ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) created_accounts: Set[Address] = field(default_factory=set) + storage_clears: Set[Address] = field(default_factory=set) transient_storage: Dict[Tuple[Address, Bytes32], U256] = field( default_factory=dict ) @@ -192,9 +200,13 @@ def get_storage( if address in tx_state.storage_writes: if key in tx_state.storage_writes[address]: return tx_state.storage_writes[address][key] + if address in tx_state.storage_clears: + return U256(0) if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -222,6 +234,8 @@ def get_storage_original( if address in tx_state.parent.storage_writes: if key in tx_state.parent.storage_writes[address]: return tx_state.parent.storage_writes[address][key] + if address in tx_state.parent.storage_clears: + return U256(0) return tx_state.parent.pre_state.get_storage(address, key) @@ -420,7 +434,9 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ Completely remove the storage at ``address``. - Only supports same transaction destruction. + Drop this transaction's writes and record the address in + ``storage_clears`` so that reads no longer fall back to earlier + block writes or ``pre_state``. Parameters ---------- @@ -432,6 +448,7 @@ def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ if address in tx_state.storage_writes: del tx_state.storage_writes[address] + tx_state.storage_clears.add(address) def mark_account_created(tx_state: TransactionState, address: Address) -> None: @@ -653,6 +670,7 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: }, code_writes=dict(tx_state.code_writes), created_accounts=tx_state.created_accounts, + storage_clears=set(tx_state.storage_clears), transient_storage=dict(tx_state.transient_storage), ) @@ -674,6 +692,7 @@ def restore_tx_state( tx_state.account_writes = snapshot.account_writes tx_state.storage_writes = snapshot.storage_writes tx_state.code_writes = snapshot.code_writes + tx_state.storage_clears = snapshot.storage_clears tx_state.transient_storage = snapshot.transient_storage @@ -695,6 +714,10 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: for address, account in tx_state.account_writes.items(): block.account_writes[address] = account + for address in tx_state.storage_clears: + block.storage_clears.add(address) + block.storage_writes.pop(address, None) + for address, slots in tx_state.storage_writes.items(): if address not in block.storage_writes: block.storage_writes[address] = {} @@ -706,6 +729,7 @@ def incorporate_tx_into_block(tx_state: TransactionState) -> None: tx_state.storage_writes.clear() tx_state.code_writes.clear() tx_state.created_accounts.clear() + tx_state.storage_clears.clear() tx_state.transient_storage.clear() @@ -728,6 +752,7 @@ def extract_block_diff(block_state: BlockState) -> BlockDiff: account_changes=block_state.account_writes, storage_changes=block_state.storage_writes, code_changes=block_state.code_writes, + storage_clears=block_state.storage_clears, )